Skip to content

Commit b1c0572

Browse files
authored
feat(functions): add remoteSource utility and path resolution helper (#9077)
Add utility function required for remote source implementations.
1 parent 0fe09b3 commit b1c0572

File tree

4 files changed

+440
-0
lines changed

4 files changed

+440
-0
lines changed
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
import * as fs from "fs";
4+
import * as path from "path";
5+
import * as mockfs from "mock-fs";
6+
import * as archiver from "archiver";
7+
import { Writable } from "stream";
8+
9+
import { getRemoteSource, requireFunctionsYaml } from "./remoteSource";
10+
import { FirebaseError } from "../../error";
11+
import * as downloadUtils from "../../downloadUtils";
12+
13+
describe("remoteSource", () => {
14+
describe("requireFunctionsYaml", () => {
15+
afterEach(() => {
16+
mockfs.restore();
17+
});
18+
19+
it("should not throw if functions.yaml exists", () => {
20+
mockfs({
21+
"/app/functions.yaml": "runtime: nodejs22",
22+
});
23+
24+
expect(() => requireFunctionsYaml("/app")).to.not.throw();
25+
});
26+
27+
it("should throw FirebaseError if functions.yaml is missing", () => {
28+
mockfs({
29+
"/app/index.js": "console.log('hello')",
30+
});
31+
32+
expect(() => requireFunctionsYaml("/app")).to.throw(
33+
FirebaseError,
34+
/The remote repository is missing a required deployment manifest/,
35+
);
36+
});
37+
});
38+
39+
describe("getRemoteSource", () => {
40+
let downloadToTmpStub: sinon.SinonStub;
41+
42+
beforeEach(() => {
43+
downloadToTmpStub = sinon.stub(downloadUtils, "downloadToTmp");
44+
});
45+
46+
afterEach(() => {
47+
sinon.restore();
48+
mockfs.restore();
49+
});
50+
51+
async function createZipBuffer(
52+
files: { [path: string]: string },
53+
topLevelDir?: string,
54+
): Promise<Buffer> {
55+
const archive = archiver("zip", { zlib: { level: 9 } });
56+
const chunks: Buffer[] = [];
57+
const output = new Writable({
58+
write(chunk, _encoding, callback) {
59+
chunks.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk));
60+
callback();
61+
},
62+
});
63+
64+
return new Promise((resolve, reject) => {
65+
output.on("finish", () => resolve(Buffer.concat(chunks as unknown as Uint8Array[])));
66+
archive.on("error", (err) => reject(err));
67+
archive.pipe(output);
68+
69+
for (const [filePath, content] of Object.entries(files)) {
70+
const entryPath = topLevelDir ? path.join(topLevelDir, filePath) : filePath;
71+
archive.append(content, { name: entryPath });
72+
}
73+
archive.finalize();
74+
});
75+
}
76+
77+
it("should use GitHub Archive API for GitHub URLs", async () => {
78+
const zipBuffer = await createZipBuffer(
79+
{ "functions.yaml": "runtime: nodejs22" },
80+
"repo-main",
81+
);
82+
mockfs({
83+
"/tmp/source.zip": zipBuffer,
84+
"/dest": {},
85+
});
86+
downloadToTmpStub.resolves("/tmp/source.zip");
87+
88+
const sourceDir = await getRemoteSource("https://github.com/org/repo", "main", "/dest");
89+
90+
expect(downloadToTmpStub.calledOnce).to.be.true;
91+
expect(downloadToTmpStub.firstCall.args[0]).to.equal(
92+
"https://github.com/org/repo/archive/main.zip",
93+
);
94+
expect(sourceDir).to.match(/repo-main$/);
95+
expect(sourceDir).to.contain("/dest");
96+
expect(fs.statSync(path.join(sourceDir, "functions.yaml")).isFile()).to.be.true;
97+
});
98+
99+
it("should support org/repo shorthand", async () => {
100+
const zipBuffer = await createZipBuffer(
101+
{ "functions.yaml": "runtime: nodejs22" },
102+
"repo-main",
103+
);
104+
mockfs({
105+
"/tmp/source.zip": zipBuffer,
106+
"/dest": {},
107+
});
108+
downloadToTmpStub.resolves("/tmp/source.zip");
109+
110+
const sourceDir = await getRemoteSource("org/repo", "main", "/dest");
111+
112+
expect(downloadToTmpStub.calledOnce).to.be.true;
113+
expect(downloadToTmpStub.firstCall.args[0]).to.equal(
114+
"https://github.com/org/repo/archive/main.zip",
115+
);
116+
expect(sourceDir).to.match(/repo-main$/);
117+
});
118+
119+
it("should strip top-level directory from GitHub archive", async () => {
120+
const zipBuffer = await createZipBuffer(
121+
{ "functions.yaml": "runtime: nodejs22" },
122+
"repo-main",
123+
);
124+
mockfs({
125+
"/tmp/source.zip": zipBuffer,
126+
"/dest": {},
127+
});
128+
downloadToTmpStub.resolves("/tmp/source.zip");
129+
130+
const sourceDir = await getRemoteSource("https://github.com/org/repo", "main", "/dest");
131+
132+
expect(sourceDir).to.match(/repo-main$/);
133+
expect(fs.statSync(path.join(sourceDir, "functions.yaml")).isFile()).to.be.true;
134+
});
135+
136+
it("should NOT strip top-level directory if multiple files exist at root", async () => {
137+
const zipBuffer = await createZipBuffer({
138+
"file1.txt": "content",
139+
"functions.yaml": "runtime: nodejs22",
140+
"repo-main/index.js": "console.log('hello')",
141+
});
142+
mockfs({
143+
"/tmp/source.zip": zipBuffer,
144+
"/dest": {},
145+
});
146+
downloadToTmpStub.resolves("/tmp/source.zip");
147+
148+
const sourceDir = await getRemoteSource("https://github.com/org/repo", "main", "/dest");
149+
150+
expect(sourceDir).to.not.match(/repo-main$/);
151+
expect(sourceDir).to.equal("/dest");
152+
expect(fs.statSync(path.join(sourceDir, "file1.txt")).isFile()).to.be.true;
153+
expect(fs.statSync(path.join(sourceDir, "functions.yaml")).isFile()).to.be.true;
154+
});
155+
156+
it("should throw error if GitHub Archive download fails", async () => {
157+
mockfs({ "/dest": {} });
158+
downloadToTmpStub.rejects(new Error("404 Not Found"));
159+
160+
await expect(
161+
getRemoteSource("https://github.com/org/repo", "main", "/dest"),
162+
).to.be.rejectedWith(FirebaseError, /Failed to download GitHub archive/);
163+
});
164+
165+
it("should throw error for non-GitHub URLs", async () => {
166+
mockfs({ "/dest": {} });
167+
await expect(
168+
getRemoteSource("https://gitlab.com/org/repo", "main", "/dest"),
169+
).to.be.rejectedWith(FirebaseError, /Only GitHub repositories are supported/);
170+
});
171+
172+
it("should validate subdirectory exists after clone", async () => {
173+
const zipBuffer = await createZipBuffer(
174+
{ "functions.yaml": "runtime: nodejs22" },
175+
"repo-main",
176+
);
177+
mockfs({
178+
"/tmp/source.zip": zipBuffer,
179+
"/dest": {},
180+
});
181+
downloadToTmpStub.resolves("/tmp/source.zip");
182+
183+
await expect(
184+
getRemoteSource("https://github.com/org/repo", "main", "/dest", "nonexistent"),
185+
).to.be.rejectedWith(FirebaseError, /Directory 'nonexistent' not found/);
186+
});
187+
188+
it("should return source even if functions.yaml is missing", async () => {
189+
const zipBuffer = await createZipBuffer({ "index.js": "console.log('hello')" }, "repo-main");
190+
mockfs({
191+
"/tmp/source.zip": zipBuffer,
192+
"/dest": {},
193+
});
194+
downloadToTmpStub.resolves("/tmp/source.zip");
195+
196+
const sourceDir = await getRemoteSource("https://github.com/org/repo", "main", "/dest");
197+
198+
expect(sourceDir).to.match(/repo-main$/);
199+
expect(fs.statSync(path.join(sourceDir, "index.js")).isFile()).to.be.true;
200+
expect(() => fs.statSync(path.join(sourceDir, "functions.yaml"))).to.throw();
201+
});
202+
203+
it("should prevent path traversal in subdirectory", async () => {
204+
const zipBuffer = await createZipBuffer(
205+
{ "functions.yaml": "runtime: nodejs22" },
206+
"repo-main",
207+
);
208+
mockfs({
209+
"/tmp/source.zip": zipBuffer,
210+
"/dest": {},
211+
});
212+
downloadToTmpStub.resolves("/tmp/source.zip");
213+
214+
await expect(
215+
getRemoteSource("https://github.com/org/repo", "main", "/dest", "../outside"),
216+
).to.be.rejectedWith(FirebaseError, /must not escape/);
217+
});
218+
219+
it("should return subdirectory if specified", async () => {
220+
const zipBuffer = await createZipBuffer(
221+
{
222+
"functions.yaml": "runtime: nodejs22",
223+
"app/index.js": "console.log('hello')",
224+
"app/functions.yaml": "runtime: nodejs22",
225+
},
226+
"repo-main",
227+
);
228+
mockfs({
229+
"/tmp/source.zip": zipBuffer,
230+
"/dest": {},
231+
});
232+
downloadToTmpStub.resolves("/tmp/source.zip");
233+
234+
const sourceDir = await getRemoteSource(
235+
"https://github.com/org/repo",
236+
"main",
237+
"/dest",
238+
"app",
239+
);
240+
241+
expect(sourceDir).to.match(/repo-main\/app$/);
242+
expect(fs.statSync(path.join(sourceDir, "index.js")).isFile()).to.be.true;
243+
expect(fs.statSync(path.join(sourceDir, "functions.yaml")).isFile()).to.be.true;
244+
});
245+
});
246+
});
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import * as fs from "fs";
2+
import * as path from "path";
3+
4+
import { URL } from "url";
5+
6+
import { FirebaseError } from "../../error";
7+
import { logger } from "../../logger";
8+
import { logLabeledBullet, resolveWithin } from "../../utils";
9+
import { dirExistsSync, fileExistsSync } from "../../fsutils";
10+
import * as downloadUtils from "../../downloadUtils";
11+
import * as unzipModule from "../../unzip";
12+
13+
/**
14+
* Downloads a remote source to a temporary directory and returns the absolute path
15+
* to the source directory.
16+
*
17+
* @param repository Remote URL (e.g. https://github.com/org/repo) or shorthand (org/repo)
18+
* @param ref Git ref to fetch (tag/branch/commit)
19+
* @param destDir Directory to extract the source code to
20+
* @param subDir Optional subdirectory within the repo to use
21+
* @return Absolute path to the checked‑out source directory
22+
*/
23+
export async function getRemoteSource(
24+
repository: string,
25+
ref: string,
26+
destDir: string,
27+
subDir?: string,
28+
): Promise<string> {
29+
logger.debug(
30+
`Downloading remote source: ${repository}@${ref} (destDir: ${destDir}, subDir: ${subDir || "."})`,
31+
);
32+
33+
const gitHubInfo = parseGitHubUrl(repository);
34+
if (!gitHubInfo) {
35+
throw new FirebaseError(
36+
`Could not parse GitHub repository URL: ${repository}. ` +
37+
`Only GitHub repositories are supported.`,
38+
);
39+
}
40+
41+
let rootDir = destDir;
42+
try {
43+
logger.debug(`Attempting to download via GitHub Archive API for ${repository}@${ref}...`);
44+
const archiveUrl = `https://github.com/${gitHubInfo.owner}/${gitHubInfo.repo}/archive/${ref}.zip`;
45+
const archivePath = await downloadUtils.downloadToTmp(archiveUrl);
46+
logger.debug(`Downloaded archive to ${archivePath}, unzipping...`);
47+
48+
await unzipModule.unzip(archivePath, destDir);
49+
50+
// GitHub archives usually wrap content in a top-level directory (e.g. repo-ref).
51+
// We need to find it and use it as the root.
52+
const files = fs.readdirSync(destDir);
53+
54+
if (files.length === 1 && fs.statSync(path.join(destDir, files[0])).isDirectory()) {
55+
rootDir = path.join(destDir, files[0]);
56+
logger.debug(`Found top-level directory in archive: ${files[0]}`);
57+
}
58+
} catch (err: unknown) {
59+
throw new FirebaseError(
60+
`Failed to download GitHub archive for ${repository}@${ref}. ` +
61+
`Make sure the repository is public and the ref exists. ` +
62+
`Private repositories are not supported via this method.`,
63+
{ original: err as Error },
64+
);
65+
}
66+
67+
const sourceDir = subDir
68+
? resolveWithin(
69+
rootDir,
70+
subDir,
71+
`Subdirectory '${subDir}' in remote source must not escape the repository root.`,
72+
)
73+
: rootDir;
74+
75+
if (subDir && !dirExistsSync(sourceDir)) {
76+
throw new FirebaseError(`Directory '${subDir}' not found in repository ${repository}@${ref}`);
77+
}
78+
79+
const origin = `${repository}@${ref}${subDir ? `/${subDir}` : ""}`;
80+
logLabeledBullet("functions", `downloaded remote source (${origin})`);
81+
return sourceDir;
82+
}
83+
84+
/**
85+
* Parses a GitHub repository URL or shorthand string into its owner and repo components.
86+
*
87+
* Valid inputs include:
88+
* - "https://github.com/owner/repo"
89+
* - "https://github.com/owner/repo.git"
90+
* - "owner/repo"
91+
* @param url The URL or shorthand string to parse.
92+
* @return An object containing the owner and repo, or undefined if parsing fails.
93+
*/
94+
function parseGitHubUrl(url: string): { owner: string; repo: string } | undefined {
95+
// Handle "org/repo" shorthand
96+
const shorthandMatch = /^[a-zA-Z0-9-]+\/[a-zA-Z0-9-_.]+$/.exec(url);
97+
if (shorthandMatch) {
98+
const [owner, repo] = url.split("/");
99+
return { owner, repo };
100+
}
101+
102+
try {
103+
const u = new URL(url);
104+
if (u.hostname !== "github.com") {
105+
return undefined;
106+
}
107+
const parts = u.pathname.split("/").filter((p) => !!p);
108+
if (parts.length < 2) {
109+
return undefined;
110+
}
111+
const owner = parts[0];
112+
let repo = parts[1];
113+
if (repo.endsWith(".git")) {
114+
repo = repo.slice(0, -4);
115+
}
116+
return { owner, repo };
117+
} catch {
118+
return undefined;
119+
}
120+
}
121+
122+
/**
123+
* Verifies that a `functions.yaml` manifest exists at the given directory.
124+
* Throws a FirebaseError with guidance if it is missing.
125+
*/
126+
export function requireFunctionsYaml(codeDir: string): void {
127+
const functionsYamlPath = path.join(codeDir, "functions.yaml");
128+
if (!fileExistsSync(functionsYamlPath)) {
129+
throw new FirebaseError(
130+
`The remote repository is missing a required deployment manifest (functions.yaml).\n\n` +
131+
`For your security, Firebase requires a static manifest to deploy functions from a remote source. ` +
132+
`This prevents the execution of arbitrary code on your machine during the function discovery process.\n\n` +
133+
`If you trust this repository and want to use it anyway, clone the repository locally, inspect the code for safety, and deploy it as a local source.`,
134+
);
135+
}
136+
}

0 commit comments

Comments
 (0)