Skip to content

Commit 99ece3c

Browse files
committed
Merge branch 'master' into fr/cloud-run-fns-list3
merge
2 parents dce07ab + b1c0572 commit 99ece3c

File tree

7 files changed

+466
-20
lines changed

7 files changed

+466
-20
lines changed

npm-shrinkwrap.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "firebase-tools",
3-
"version": "14.25.0",
3+
"version": "14.25.1",
44
"description": "Command-Line Interface for Firebase",
55
"main": "./lib/index.js",
66
"bin": {

src/appUtils.ts

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -177,24 +177,29 @@ async function packageJsonToAdminOrWebApp(
177177
dirPath: string,
178178
packageJsonFile: string,
179179
): Promise<App[]> {
180-
const fullPath = path.join(dirPath, packageJsonFile);
181-
const packageJson = JSON.parse((await fs.readFile(fullPath)).toString()) as PackageJSON;
182-
const allDeps = getAllDepsFromPackageJson(packageJson);
183-
const detectedApps = [];
184-
if (allDeps.includes("firebase-admin") || allDeps.includes("firebase-functions")) {
185-
detectedApps.push({
186-
platform: Platform.ADMIN_NODE,
187-
directory: path.dirname(packageJsonFile),
188-
});
189-
}
190-
if (allDeps.includes("firebase") || detectedApps.length === 0) {
191-
detectedApps.push({
192-
platform: Platform.WEB,
193-
directory: path.dirname(packageJsonFile),
194-
frameworks: getFrameworksFromPackageJson(packageJson),
195-
});
180+
try {
181+
const fullPath = path.join(dirPath, packageJsonFile);
182+
const packageJson = JSON.parse((await fs.readFile(fullPath)).toString()) as PackageJSON;
183+
const allDeps = getAllDepsFromPackageJson(packageJson);
184+
const detectedApps = [];
185+
if (allDeps.includes("firebase-admin") || allDeps.includes("firebase-functions")) {
186+
detectedApps.push({
187+
platform: Platform.ADMIN_NODE,
188+
directory: path.dirname(packageJsonFile),
189+
});
190+
}
191+
if (allDeps.includes("firebase") || detectedApps.length === 0) {
192+
detectedApps.push({
193+
platform: Platform.WEB,
194+
directory: path.dirname(packageJsonFile),
195+
frameworks: getFrameworksFromPackageJson(packageJson),
196+
});
197+
}
198+
return detectedApps;
199+
} catch (err) {
200+
// If there is a malformed package.json, don't crash
201+
return [];
196202
}
197-
return detectedApps;
198203
}
199204

200205
const WEB_FRAMEWORKS: Framework[] = Object.values(Framework);
@@ -336,6 +341,7 @@ export async function detectFiles(dirPath: string, filePattern: string): Promise
336341
"**/coverage/**", // Test coverage reports
337342
],
338343
absolute: false,
344+
maxDepth: 4,
339345
};
340346
return glob(`**/${filePattern}`, options);
341347
}
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+
});

0 commit comments

Comments
 (0)