|
| 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 | +} |
0 commit comments