Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
111dd0e
feat(functions): add remoteSource utility and path resolution helper
taeold Sep 4, 2025
5ecd08a
refactor: apply gemini suggestions.
taeold Sep 4, 2025
920b86f
refactor: simplify git clone logic.
taeold Sep 4, 2025
ee5ed88
style: formatter
taeold Sep 4, 2025
ffbb5b7
Merge branch 'master' into pr-remote-src-pr2-master
taeold Sep 5, 2025
1d4a387
nit: simplify impl.
taeold Sep 5, 2025
f5fb8e2
fix: add missing return value.
taeold Sep 5, 2025
4576da0
Merge branch 'master' into pr-remote-src-pr2-master
taeold Sep 15, 2025
7364d00
Merge branch 'master' into pr-remote-src-pr2-master
taeold Sep 25, 2025
1ea6a1d
Merge branch 'master' into pr-remote-src-pr2-master
taeold Nov 14, 2025
91963ad
use GitHub archive API instead.
taeold Nov 15, 2025
5d53070
Update src/deploy/functions/remoteSource.ts
taeold Nov 17, 2025
9fb6ee1
Merge branch 'master' into pr-remote-src-pr2-master
taeold Nov 17, 2025
b94e744
refactor: Remove `pathUtils` module by adding `resolveWithin` to `uti…
taeold Nov 18, 2025
c871f88
Merge branch 'pr-remote-src-pr2-master' of https://github.com/firebas…
taeold Nov 18, 2025
266b424
refactor: improve remote source download and add functions.yaml valid…
taeold Nov 18, 2025
cc83bdd
nit: make formatter happy
taeold Nov 18, 2025
ec8f2fa
Merge branch 'master' into pr-remote-src-pr2-master
taeold Nov 18, 2025
a343c9e
nit: update misleading comment
taeold Nov 18, 2025
5fbbb73
nit: nit
taeold Nov 18, 2025
d7ee637
respond to pr comments.
taeold Nov 19, 2025
a6dd70f
nit: run formatter
taeold Nov 19, 2025
f23ee86
Merge branch 'master' into pr-remote-src-pr2-master
taeold Nov 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/deploy/functions/remoteSource.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { expect } from "chai";
import * as sinon from "sinon";
import * as fs from "fs";

import * as remoteSourceModule from "./remoteSource";
import { cloneRemoteSource, GitClient } from "./remoteSource";
import { FirebaseError } from "../../error";

describe("remoteSource", () => {
describe("cloneRemoteSource", () => {
let existsSyncStub: sinon.SinonStub;
let isGitAvailableStub: sinon.SinonStub;
let cloneStub: sinon.SinonStub;
let fetchStub: sinon.SinonStub;
let checkoutStub: sinon.SinonStub;
let initSparseStub: sinon.SinonStub;
let setSparseStub: sinon.SinonStub;
let mockGitClient: GitClient;

beforeEach(() => {
existsSyncStub = sinon.stub(fs, "existsSync");
isGitAvailableStub = sinon.stub(remoteSourceModule, "isGitAvailable");

cloneStub = sinon.stub().returns({ status: 0 });
fetchStub = sinon.stub().returns({ status: 0 });
checkoutStub = sinon.stub().returns({ status: 0 });
initSparseStub = sinon.stub().returns({ status: 0 });
setSparseStub = sinon.stub().returns({ status: 0 });
mockGitClient = {
clone: cloneStub,
fetch: fetchStub,
checkout: checkoutStub,
initSparseCheckout: initSparseStub,
setSparsePaths: setSparseStub,
} as unknown as GitClient;
});

afterEach(() => {
existsSyncStub.restore();
isGitAvailableStub.restore();
});

it("should handle clone failures with meaningful errors", async () => {
isGitAvailableStub.returns(true);
cloneStub.returns({
status: 1,
stderr: "fatal: unable to access 'https://github.com/org/repo': Could not resolve host",
});

await expect(
cloneRemoteSource("https://github.com/org/repo", "main", undefined, mockGitClient),
).to.be.rejectedWith(FirebaseError, /Unable to access repository/);
});

it("should handle fetch failures for invalid refs", async () => {
isGitAvailableStub.returns(true);
fetchStub.returns({
status: 1,
stderr: "error: pathspec 'bad-ref' did not match any file(s) known to git",
});

await expect(
cloneRemoteSource("https://github.com/org/repo", "bad-ref", undefined, mockGitClient),
).to.be.rejectedWith(FirebaseError, /Git ref 'bad-ref' not found/);
});

it("should validate subdirectory exists after clone", async () => {
isGitAvailableStub.returns(true);
setSparseStub.returns({
status: 1,
stderr: "fatal: pathspec 'subdir' did not match any files",
});

await expect(
cloneRemoteSource("https://github.com/org/repo", "main", "subdir", mockGitClient),
).to.be.rejectedWith(FirebaseError, /Directory 'subdir' not found/);
});

it("should validate functions.yaml exists", async () => {
isGitAvailableStub.returns(true);
existsSyncStub.withArgs(sinon.match(/firebase-functions-remote/)).returns(true);
existsSyncStub.withArgs(sinon.match(/functions\.yaml$/)).returns(false);

await expect(
cloneRemoteSource("https://github.com/org/repo", "main", undefined, mockGitClient),
).to.be.rejectedWith(FirebaseError, /missing a required deployment manifest/);
});
});
});
235 changes: 235 additions & 0 deletions src/deploy/functions/remoteSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import * as tmp from "tmp";
import * as path from "path";
import { spawnSync, SpawnSyncReturns } from "child_process";
import { FirebaseError, hasMessage } from "../../error";
import { logger } from "../../logger";
import { logLabeledBullet } from "../../utils";
import * as fs from "fs";
import { resolveWithin } from "../../pathUtils";

export interface GitClient {
clone(repository: string, destination: string): SpawnSyncReturns<string>;
fetch(ref: string, cwd: string): SpawnSyncReturns<string>;
checkout(ref: string, cwd: string): SpawnSyncReturns<string>;
initSparseCheckout(cwd: string): SpawnSyncReturns<string>;
setSparsePaths(paths: string[], cwd: string): SpawnSyncReturns<string>;
}

export class DefaultGitClient implements GitClient {
clone(repository: string, destination: string): SpawnSyncReturns<string> {
return spawnSync(
"git",
["clone", "--filter=blob:none", "--no-checkout", "--depth=1", repository, destination],
{ encoding: "utf8" },
);
}

fetch(ref: string, cwd: string): SpawnSyncReturns<string> {
return spawnSync("git", ["fetch", "--depth=1", "--filter=blob:none", "origin", ref], {
cwd,
encoding: "utf8",
});
}

checkout(ref: string, cwd: string): SpawnSyncReturns<string> {
return spawnSync("git", ["checkout", ref], { cwd, encoding: "utf8" });
}

initSparseCheckout(cwd: string): SpawnSyncReturns<string> {
return spawnSync("git", ["sparse-checkout", "init", "--cone"], { cwd, encoding: "utf8" });
}

setSparsePaths(paths: string[], cwd: string): SpawnSyncReturns<string> {
return spawnSync("git", ["sparse-checkout", "set", ...paths], { cwd, encoding: "utf8" });
}
}

export async function cloneRemoteSource(

Check warning on line 47 in src/deploy/functions/remoteSource.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
repository: string,
ref: string,
dir?: string,
gitClient: GitClient = new DefaultGitClient(),
): Promise<string> {
/**
* Shallow‑clones a Git repo to a temporary directory and returns the
* absolute path to the source directory. If `dir` is provided, performs a
* sparse checkout of that subdirectory. Verifies that a `functions.yaml`
* manifest exists before returning. Throws `FirebaseError` with actionable
* messages on common failures (network, auth, bad ref, missing directory).

Check warning on line 58 in src/deploy/functions/remoteSource.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Expected only 0 line after block description
*
* @param repository Remote Git URL (e.g. https://github.com/org/repo)
* @param ref Git ref to fetch (tag/branch/commit)
* @param dir Optional subdirectory within the repo to use
* @param gitClient Optional Git client for testing/injection
* @returns Absolute path to the checked‑out source directory

Check warning on line 64 in src/deploy/functions/remoteSource.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid JSDoc tag (preference). Replace "returns" JSDoc tag with "return"
*/
logger.debug(`Cloning remote source: ${repository}@${ref} (dir: ${dir || "."})`);

const tmpDir = tmp.dirSync({
prefix: "firebase-functions-remote-",
unsafeCleanup: true,
});

if (!isGitAvailable()) {
throw new FirebaseError(
"Git is required to deploy functions from a remote source. " +
"Please install Git from https://git-scm.com/downloads and try again.",
);
}

try {
// Info-level, labeled logging is handled by the caller (prepare.ts).
// Keep clone details at debug to avoid duplicate, unlabeled lines.
logger.debug(`Fetching remote source for ${repository}@${ref}...`);

const cloneResult = await runGitWithRetry(() => gitClient.clone(repository, tmpDir.name));
if (cloneResult.error || cloneResult.status !== 0) {
throw new Error(cloneResult.stderr || cloneResult.stdout || "Clone failed");
}

// If a subdirectory is specified, use sparse checkout to limit the working tree.
if (dir) {
const initSparse = gitClient.initSparseCheckout(tmpDir.name);
if (initSparse.error || initSparse.status !== 0) {
throw new Error(initSparse.stderr || initSparse.stdout || "Sparse checkout init failed");
}
const setSparse = gitClient.setSparsePaths([dir], tmpDir.name);
if (setSparse.error || setSparse.status !== 0) {
throw new FirebaseError(`Directory '${dir}' not found in repository ${repository}@${ref}`);
}
}

// Fetch just the requested ref shallowly, then check it out.
const fetchResult = await runGitWithRetry(() => gitClient.fetch(ref, tmpDir.name));
if (fetchResult.error || fetchResult.status !== 0) {
throw new Error(fetchResult.stderr || fetchResult.stdout || "Fetch failed");
}

const checkoutResult = gitClient.checkout("FETCH_HEAD", tmpDir.name);
if (checkoutResult.error || checkoutResult.status !== 0) {
throw new Error(checkoutResult.stderr || checkoutResult.stdout || "Checkout failed");
}

const sourceDir = dir
? resolveWithin(
tmpDir.name,
dir,
`Subdirectory '${dir}' in remote source must not escape the repository root.`,
)
: tmpDir.name;
requireFunctionsYaml(sourceDir);
const origin = `${repository}@${ref}${dir ? `/${dir}` : ""}`;
let sha: string | undefined;
const rev = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
cwd: tmpDir.name,
encoding: "utf8",
});
if (!rev.error && rev.status === 0) {
sha = rev.stdout.trim();
} else if (rev.error) {
logger.debug("Failed to get git revision for logging:", rev.error);
}
if (sha) {
logLabeledBullet("functions", `verified functions.yaml for ${origin}; using commit ${sha}`);
} else {
logLabeledBullet("functions", `verified functions.yaml in remote source (${origin})`);
}
logger.debug(`Successfully cloned to ${sourceDir}`);
return sourceDir;
} catch (error: unknown) {
if (error instanceof FirebaseError) {
throw error;
}

const errorMessage = hasMessage(error) ? error.message : String(error);
if (
errorMessage.includes("Could not resolve host") ||
errorMessage.includes("unable to access")
) {
throw new FirebaseError(
`Unable to access repository '${repository}'. ` +
`Please check the repository URL and your network connection.`,
);
}
if (errorMessage.includes("pathspec") || errorMessage.includes("did not match")) {
throw new FirebaseError(
`Git ref '${ref}' not found in repository '${repository}'. ` +
`Please check that the ref (tag, branch, or commit) exists.`,
);
}
if (
errorMessage.includes("Permission denied") ||
errorMessage.includes("Authentication failed")
) {
throw new FirebaseError(
`Authentication failed for repository '${repository}'. ` +
`For private repositories, please ensure you have configured Git authentication.`,
);
}

throw new FirebaseError(`Failed to clone repository '${repository}@${ref}': ${errorMessage}`);
}
}

/**
* Checks whether the `git` binary is available in the current environment.
* @returns true if `git --version` runs successfully; false otherwise.

Check warning on line 176 in src/deploy/functions/remoteSource.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid JSDoc tag (preference). Replace "returns" JSDoc tag with "return"
*/
export function isGitAvailable(): boolean {
const result = spawnSync("git", ["--version"], { encoding: "utf8" });
return !result.error && result.status === 0;
}

async function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function isTransientGitError(message: string): boolean {
const m = message.toLowerCase();
return (
m.includes("could not resolve host") ||
m.includes("unable to access") ||
m.includes("connection reset") ||
m.includes("timed out") ||
m.includes("temporary failure in name resolution") ||
m.includes("ssl_read") ||
m.includes("network is unreachable")
);
}

async function runGitWithRetry(
cmd: () => SpawnSyncReturns<string>,
retries = 1,
backoffMs = 200,
): Promise<SpawnSyncReturns<string>> {
let attempt = 0;
while (true) {

Check warning on line 206 in src/deploy/functions/remoteSource.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected constant condition
const res = cmd();
if (!res.error && res.status === 0) {
return res;
}
const stderr = (res.stderr || res.stdout || "").toString();
if (attempt < retries && isTransientGitError(stderr)) {
await delay(backoffMs * (attempt + 1));
attempt++;
continue;
}
return res;
}
}

/**
* Verifies that a `functions.yaml` manifest exists at the given directory.
* Throws a FirebaseError with guidance if it is missing.
*/
export function requireFunctionsYaml(codeDir: string): void {
const functionsYamlPath = path.join(codeDir, "functions.yaml");
if (!fs.existsSync(functionsYamlPath)) {
throw new FirebaseError(
`The remote repository is missing a required deployment manifest (functions.yaml).\n\n` +
`For your security, Firebase requires a static manifest to deploy functions from a remote source. ` +
`This prevents the execution of arbitrary code on your machine during the function discovery process.\n\n` +
`To resolve this, clone the repository locally, inspect the code for safety, and deploy it as a local source.`,
);
}
}
47 changes: 47 additions & 0 deletions src/pathUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { expect } from "chai";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";

import { resolveWithin } from "./pathUtils";
import { FirebaseError } from "./error";

describe("resolveWithin", () => {
let baseDir: string;

beforeEach(() => {
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "fb-pathutils-"));
});

it("returns absolute path when subpath is inside base (relative)", () => {
const p = resolveWithin(baseDir, "sub/dir");
expect(p).to.equal(path.join(baseDir, "sub/dir"));
});

it("returns base when subpath normalizes to base (e.g., nested/..)", () => {
const p = resolveWithin(baseDir, "nested/..");
expect(p).to.equal(baseDir);
});

it("throws when subpath escapes base using ..", () => {
expect(() => resolveWithin(baseDir, "../outside")).to.throw(FirebaseError);
});

it("throws with custom message when provided", () => {
expect(() => resolveWithin(baseDir, "../outside", "Custom error"))
.to.throw(FirebaseError)
.with.property("message")
.that.matches(/Custom error/);
});

it("throws when absolute subpath is outside base", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "fb-pathutils-out-"));
expect(() => resolveWithin(baseDir, outside)).to.throw(FirebaseError);
});

it("allows absolute subpath when inside base", () => {
const inside = path.join(baseDir, "child");
const p = resolveWithin(baseDir, inside);
expect(p).to.equal(inside);
});
});
15 changes: 15 additions & 0 deletions src/pathUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as path from "path";
import { FirebaseError } from "./error";

/**
* Resolves `subPath` against `base` and ensures the result is contained within `base`.
* Throws a FirebaseError with an optional message if the resolved path escapes `base`.
*/
export function resolveWithin(base: string, subPath: string, errMsg?: string): string {
const abs = path.resolve(base, subPath);
const rel = path.relative(base, abs);
if (rel.startsWith("..") || path.isAbsolute(rel)) {
throw new FirebaseError(errMsg || `Path "${subPath}" must be within "${base}".`);
}
return abs;
}
Loading