Skip to content

Commit 111dd0e

Browse files
committed
feat(functions): add remoteSource utility and path resolution helper
1 parent e183833 commit 111dd0e

File tree

4 files changed

+387
-0
lines changed

4 files changed

+387
-0
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
import * as fs from "fs";
4+
5+
import * as remoteSourceModule from "./remoteSource";
6+
import { cloneRemoteSource, GitClient } from "./remoteSource";
7+
import { FirebaseError } from "../../error";
8+
9+
describe("remoteSource", () => {
10+
describe("cloneRemoteSource", () => {
11+
let existsSyncStub: sinon.SinonStub;
12+
let isGitAvailableStub: sinon.SinonStub;
13+
let cloneStub: sinon.SinonStub;
14+
let fetchStub: sinon.SinonStub;
15+
let checkoutStub: sinon.SinonStub;
16+
let initSparseStub: sinon.SinonStub;
17+
let setSparseStub: sinon.SinonStub;
18+
let mockGitClient: GitClient;
19+
20+
beforeEach(() => {
21+
existsSyncStub = sinon.stub(fs, "existsSync");
22+
isGitAvailableStub = sinon.stub(remoteSourceModule, "isGitAvailable");
23+
24+
cloneStub = sinon.stub().returns({ status: 0 });
25+
fetchStub = sinon.stub().returns({ status: 0 });
26+
checkoutStub = sinon.stub().returns({ status: 0 });
27+
initSparseStub = sinon.stub().returns({ status: 0 });
28+
setSparseStub = sinon.stub().returns({ status: 0 });
29+
mockGitClient = {
30+
clone: cloneStub,
31+
fetch: fetchStub,
32+
checkout: checkoutStub,
33+
initSparseCheckout: initSparseStub,
34+
setSparsePaths: setSparseStub,
35+
} as unknown as GitClient;
36+
});
37+
38+
afterEach(() => {
39+
existsSyncStub.restore();
40+
isGitAvailableStub.restore();
41+
});
42+
43+
it("should handle clone failures with meaningful errors", async () => {
44+
isGitAvailableStub.returns(true);
45+
cloneStub.returns({
46+
status: 1,
47+
stderr: "fatal: unable to access 'https://github.com/org/repo': Could not resolve host",
48+
});
49+
50+
await expect(
51+
cloneRemoteSource("https://github.com/org/repo", "main", undefined, mockGitClient),
52+
).to.be.rejectedWith(FirebaseError, /Unable to access repository/);
53+
});
54+
55+
it("should handle fetch failures for invalid refs", async () => {
56+
isGitAvailableStub.returns(true);
57+
fetchStub.returns({
58+
status: 1,
59+
stderr: "error: pathspec 'bad-ref' did not match any file(s) known to git",
60+
});
61+
62+
await expect(
63+
cloneRemoteSource("https://github.com/org/repo", "bad-ref", undefined, mockGitClient),
64+
).to.be.rejectedWith(FirebaseError, /Git ref 'bad-ref' not found/);
65+
});
66+
67+
it("should validate subdirectory exists after clone", async () => {
68+
isGitAvailableStub.returns(true);
69+
setSparseStub.returns({
70+
status: 1,
71+
stderr: "fatal: pathspec 'subdir' did not match any files",
72+
});
73+
74+
await expect(
75+
cloneRemoteSource("https://github.com/org/repo", "main", "subdir", mockGitClient),
76+
).to.be.rejectedWith(FirebaseError, /Directory 'subdir' not found/);
77+
});
78+
79+
it("should validate functions.yaml exists", async () => {
80+
isGitAvailableStub.returns(true);
81+
existsSyncStub.withArgs(sinon.match(/firebase-functions-remote/)).returns(true);
82+
existsSyncStub.withArgs(sinon.match(/functions\.yaml$/)).returns(false);
83+
84+
await expect(
85+
cloneRemoteSource("https://github.com/org/repo", "main", undefined, mockGitClient),
86+
).to.be.rejectedWith(FirebaseError, /missing a required deployment manifest/);
87+
});
88+
});
89+
});
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import * as tmp from "tmp";
2+
import * as path from "path";
3+
import { spawnSync, SpawnSyncReturns } from "child_process";
4+
import { FirebaseError, hasMessage } from "../../error";
5+
import { logger } from "../../logger";
6+
import { logLabeledBullet } from "../../utils";
7+
import * as fs from "fs";
8+
import { resolveWithin } from "../../pathUtils";
9+
10+
export interface GitClient {
11+
clone(repository: string, destination: string): SpawnSyncReturns<string>;
12+
fetch(ref: string, cwd: string): SpawnSyncReturns<string>;
13+
checkout(ref: string, cwd: string): SpawnSyncReturns<string>;
14+
initSparseCheckout(cwd: string): SpawnSyncReturns<string>;
15+
setSparsePaths(paths: string[], cwd: string): SpawnSyncReturns<string>;
16+
}
17+
18+
export class DefaultGitClient implements GitClient {
19+
clone(repository: string, destination: string): SpawnSyncReturns<string> {
20+
return spawnSync(
21+
"git",
22+
["clone", "--filter=blob:none", "--no-checkout", "--depth=1", repository, destination],
23+
{ encoding: "utf8" },
24+
);
25+
}
26+
27+
fetch(ref: string, cwd: string): SpawnSyncReturns<string> {
28+
return spawnSync("git", ["fetch", "--depth=1", "--filter=blob:none", "origin", ref], {
29+
cwd,
30+
encoding: "utf8",
31+
});
32+
}
33+
34+
checkout(ref: string, cwd: string): SpawnSyncReturns<string> {
35+
return spawnSync("git", ["checkout", ref], { cwd, encoding: "utf8" });
36+
}
37+
38+
initSparseCheckout(cwd: string): SpawnSyncReturns<string> {
39+
return spawnSync("git", ["sparse-checkout", "init", "--cone"], { cwd, encoding: "utf8" });
40+
}
41+
42+
setSparsePaths(paths: string[], cwd: string): SpawnSyncReturns<string> {
43+
return spawnSync("git", ["sparse-checkout", "set", ...paths], { cwd, encoding: "utf8" });
44+
}
45+
}
46+
47+
export async function cloneRemoteSource(
48+
repository: string,
49+
ref: string,
50+
dir?: string,
51+
gitClient: GitClient = new DefaultGitClient(),
52+
): Promise<string> {
53+
/**
54+
* Shallow‑clones a Git repo to a temporary directory and returns the
55+
* absolute path to the source directory. If `dir` is provided, performs a
56+
* sparse checkout of that subdirectory. Verifies that a `functions.yaml`
57+
* manifest exists before returning. Throws `FirebaseError` with actionable
58+
* messages on common failures (network, auth, bad ref, missing directory).
59+
*
60+
* @param repository Remote Git URL (e.g. https://github.com/org/repo)
61+
* @param ref Git ref to fetch (tag/branch/commit)
62+
* @param dir Optional subdirectory within the repo to use
63+
* @param gitClient Optional Git client for testing/injection
64+
* @returns Absolute path to the checked‑out source directory
65+
*/
66+
logger.debug(`Cloning remote source: ${repository}@${ref} (dir: ${dir || "."})`);
67+
68+
const tmpDir = tmp.dirSync({
69+
prefix: "firebase-functions-remote-",
70+
unsafeCleanup: true,
71+
});
72+
73+
if (!isGitAvailable()) {
74+
throw new FirebaseError(
75+
"Git is required to deploy functions from a remote source. " +
76+
"Please install Git from https://git-scm.com/downloads and try again.",
77+
);
78+
}
79+
80+
try {
81+
// Info-level, labeled logging is handled by the caller (prepare.ts).
82+
// Keep clone details at debug to avoid duplicate, unlabeled lines.
83+
logger.debug(`Fetching remote source for ${repository}@${ref}...`);
84+
85+
const cloneResult = await runGitWithRetry(() => gitClient.clone(repository, tmpDir.name));
86+
if (cloneResult.error || cloneResult.status !== 0) {
87+
throw new Error(cloneResult.stderr || cloneResult.stdout || "Clone failed");
88+
}
89+
90+
// If a subdirectory is specified, use sparse checkout to limit the working tree.
91+
if (dir) {
92+
const initSparse = gitClient.initSparseCheckout(tmpDir.name);
93+
if (initSparse.error || initSparse.status !== 0) {
94+
throw new Error(initSparse.stderr || initSparse.stdout || "Sparse checkout init failed");
95+
}
96+
const setSparse = gitClient.setSparsePaths([dir], tmpDir.name);
97+
if (setSparse.error || setSparse.status !== 0) {
98+
throw new FirebaseError(`Directory '${dir}' not found in repository ${repository}@${ref}`);
99+
}
100+
}
101+
102+
// Fetch just the requested ref shallowly, then check it out.
103+
const fetchResult = await runGitWithRetry(() => gitClient.fetch(ref, tmpDir.name));
104+
if (fetchResult.error || fetchResult.status !== 0) {
105+
throw new Error(fetchResult.stderr || fetchResult.stdout || "Fetch failed");
106+
}
107+
108+
const checkoutResult = gitClient.checkout("FETCH_HEAD", tmpDir.name);
109+
if (checkoutResult.error || checkoutResult.status !== 0) {
110+
throw new Error(checkoutResult.stderr || checkoutResult.stdout || "Checkout failed");
111+
}
112+
113+
const sourceDir = dir
114+
? resolveWithin(
115+
tmpDir.name,
116+
dir,
117+
`Subdirectory '${dir}' in remote source must not escape the repository root.`,
118+
)
119+
: tmpDir.name;
120+
requireFunctionsYaml(sourceDir);
121+
try {
122+
const rev = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
123+
cwd: tmpDir.name,
124+
encoding: "utf8",
125+
});
126+
const sha = rev.status === 0 ? rev.stdout.trim() : undefined;
127+
const origin = `${repository}@${ref}/${dir ?? ""}`;
128+
if (sha) {
129+
logLabeledBullet("functions", `verified functions.yaml for ${origin}; using commit ${sha}`);
130+
} else {
131+
logLabeledBullet("functions", `verified functions.yaml in remote source (${origin})`);
132+
}
133+
} catch {
134+
const origin = `${repository}@${ref}/${dir ?? ""}`;
135+
logLabeledBullet("functions", `verified functions.yaml in remote source (${origin})`);
136+
}
137+
logger.debug(`Successfully cloned to ${sourceDir}`);
138+
return sourceDir;
139+
} catch (error: unknown) {
140+
if (error instanceof FirebaseError) {
141+
throw error;
142+
}
143+
144+
const errorMessage = hasMessage(error) ? error.message : String(error);
145+
if (
146+
errorMessage.includes("Could not resolve host") ||
147+
errorMessage.includes("unable to access")
148+
) {
149+
throw new FirebaseError(
150+
`Unable to access repository '${repository}'. ` +
151+
`Please check the repository URL and your network connection.`,
152+
);
153+
}
154+
if (errorMessage.includes("pathspec") || errorMessage.includes("did not match")) {
155+
throw new FirebaseError(
156+
`Git ref '${ref}' not found in repository '${repository}'. ` +
157+
`Please check that the ref (tag, branch, or commit) exists.`,
158+
);
159+
}
160+
if (
161+
errorMessage.includes("Permission denied") ||
162+
errorMessage.includes("Authentication failed")
163+
) {
164+
throw new FirebaseError(
165+
`Authentication failed for repository '${repository}'. ` +
166+
`For private repositories, please ensure you have configured Git authentication.`,
167+
);
168+
}
169+
170+
throw new FirebaseError(`Failed to clone repository '${repository}@${ref}': ${errorMessage}`);
171+
}
172+
}
173+
174+
/**
175+
* Checks whether the `git` binary is available in the current environment.
176+
* @returns true if `git --version` runs successfully; false otherwise.
177+
*/
178+
export function isGitAvailable(): boolean {
179+
const result = spawnSync("git", ["--version"], { encoding: "utf8" });
180+
return !result.error && result.status === 0;
181+
}
182+
183+
async function delay(ms: number): Promise<void> {
184+
return new Promise((resolve) => setTimeout(resolve, ms));
185+
}
186+
187+
function isTransientGitError(message: string): boolean {
188+
const m = message.toLowerCase();
189+
return (
190+
m.includes("could not resolve host") ||
191+
m.includes("unable to access") ||
192+
m.includes("connection reset") ||
193+
m.includes("timed out") ||
194+
m.includes("temporary failure in name resolution") ||
195+
m.includes("ssl_read") ||
196+
m.includes("network is unreachable")
197+
);
198+
}
199+
200+
async function runGitWithRetry(
201+
cmd: () => SpawnSyncReturns<string>,
202+
retries = 1,
203+
backoffMs = 200,
204+
): Promise<SpawnSyncReturns<string>> {
205+
let last: SpawnSyncReturns<string> | undefined;
206+
for (let attempt = 0; attempt <= retries; attempt++) {
207+
const res = cmd();
208+
last = res;
209+
const stderr = (res.stderr || res.stdout || "").toString();
210+
if (!res.error && res.status === 0) return res;
211+
if (attempt < retries && isTransientGitError(stderr)) {
212+
await delay(backoffMs * Math.max(1, attempt + 1));
213+
continue;
214+
}
215+
return res;
216+
}
217+
// Should not reach here, but return last if it exists.
218+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
219+
return last!;
220+
}
221+
222+
/**
223+
* Verifies that a `functions.yaml` manifest exists at the given directory.
224+
* Throws a FirebaseError with guidance if it is missing.
225+
*/
226+
export function requireFunctionsYaml(codeDir: string): void {
227+
const functionsYamlPath = path.join(codeDir, "functions.yaml");
228+
if (!fs.existsSync(functionsYamlPath)) {
229+
throw new FirebaseError(
230+
`The remote repository is missing a required deployment manifest (functions.yaml).\n\n` +
231+
`For your security, Firebase requires a static manifest to deploy functions from a remote source. ` +
232+
`This prevents the execution of arbitrary code on your machine during the function discovery process.\n\n` +
233+
`To resolve this, clone the repository locally, inspect the code for safety, and deploy it as a local source.`,
234+
);
235+
}
236+
}

src/pathUtils.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { expect } from "chai";
2+
import * as fs from "fs";
3+
import * as os from "os";
4+
import * as path from "path";
5+
6+
import { resolveWithin } from "./pathUtils";
7+
import { FirebaseError } from "./error";
8+
9+
describe("resolveWithin", () => {
10+
let baseDir: string;
11+
12+
beforeEach(() => {
13+
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "fb-pathutils-"));
14+
});
15+
16+
it("returns absolute path when subpath is inside base (relative)", () => {
17+
const p = resolveWithin(baseDir, "sub/dir");
18+
expect(p).to.equal(path.join(baseDir, "sub/dir"));
19+
});
20+
21+
it("returns base when subpath normalizes to base (e.g., nested/..)", () => {
22+
const p = resolveWithin(baseDir, "nested/..");
23+
expect(p).to.equal(baseDir);
24+
});
25+
26+
it("throws when subpath escapes base using ..", () => {
27+
expect(() => resolveWithin(baseDir, "../outside")).to.throw(FirebaseError);
28+
});
29+
30+
it("throws with custom message when provided", () => {
31+
expect(() => resolveWithin(baseDir, "../outside", "Custom error"))
32+
.to.throw(FirebaseError)
33+
.with.property("message")
34+
.that.matches(/Custom error/);
35+
});
36+
37+
it("throws when absolute subpath is outside base", () => {
38+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "fb-pathutils-out-"));
39+
expect(() => resolveWithin(baseDir, outside)).to.throw(FirebaseError);
40+
});
41+
42+
it("allows absolute subpath when inside base", () => {
43+
const inside = path.join(baseDir, "child");
44+
const p = resolveWithin(baseDir, inside);
45+
expect(p).to.equal(inside);
46+
});
47+
});

src/pathUtils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as path from "path";
2+
import { FirebaseError } from "./error";
3+
4+
/**
5+
* Resolves `subPath` against `base` and ensures the result is contained within `base`.
6+
* Throws a FirebaseError with an optional message if the resolved path escapes `base`.
7+
*/
8+
export function resolveWithin(base: string, subPath: string, errMsg?: string): string {
9+
const abs = path.resolve(base, subPath);
10+
const rel = path.relative(base, abs);
11+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
12+
throw new FirebaseError(errMsg || `Path "${subPath}" must be within "${base}".`);
13+
}
14+
return abs;
15+
}

0 commit comments

Comments
 (0)