diff --git a/packages/miniflare/src/plugins/artifacts/git-client.ts b/packages/miniflare/src/plugins/artifacts/git-client.ts new file mode 100644 index 00000000000..84e80277047 --- /dev/null +++ b/packages/miniflare/src/plugins/artifacts/git-client.ts @@ -0,0 +1,733 @@ +import { Buffer } from "node:buffer"; +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { lstat, mkdtemp, open, rename, unlink } from "node:fs/promises"; +import { devNull, tmpdir } from "node:os"; +import path from "node:path"; +import { removeDir } from "@cloudflare/workers-utils/fs-helpers"; +import type { FileHandle } from "node:fs/promises"; + +const decoder = new TextDecoder(); +type GitOptions = { + allowFailure?: boolean; + timeout?: number; + httpAuthorization?: { origin: string; value: string }; +}; +type GitResult = { stdout: Buffer; stderr: Buffer; status: number }; + +// Git runs on the host, never in the user's Worker. Do not inherit Cloudflare +// tokens, Git config overrides, or credential helpers from the invoking shell. +export function gitEnvironment( + source: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + const keep = [ + "PATH", + "SystemRoot", + "WINDIR", + "PATHEXT", + "TMP", + "TEMP", + "TMPDIR", + "LANG", + // Explicit host CA bundles may be used to trust a private HTTPS Git origin. + // Do not inherit other Git TLS overrides (especially GIT_SSL_NO_VERIFY), + // credential helpers, or HTTP headers from the host environment. + "GIT_SSL_CAINFO", + ]; + const env: NodeJS.ProcessEnv = {}; + for (const key of keep) { + const value = source[key]; + if (value !== undefined) { + env[key] = value; + } + } + return { + ...env, + // libcurl can consult ~/.netrc even with Git credential helpers disabled. + // Point HOME at a unique, nonexistent path rather than the host account. + HOME: path.join(tmpdir(), `miniflare-artifacts-home-${randomUUID()}`), + GIT_CONFIG_NOSYSTEM: "1", + // Git for Windows cannot read null-device paths. A unique, nonexistent + // config path prevents it from falling back to the host's Git settings. + GIT_CONFIG_GLOBAL: + process.platform === "win32" + ? path.join(tmpdir(), `miniflare-artifacts-empty-${randomUUID()}`) + : devNull, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "credential.helper", + GIT_CONFIG_VALUE_0: "", + GIT_TERMINAL_PROMPT: "0", + }; +} + +/** Require Git 2.32's GIT_CONFIG_GLOBAL to isolate native Git subprocesses. */ +export function assertSupportedGitVersion(output: string): void { + const match = /^git version (\d+)\.(\d+)(?:\.\d+)?(?:[.+ -][^\r\n]*)?$/.exec( + output.trim() + ); + const major = Number(match?.[1]); + const minor = Number(match?.[2]); + const supported = + match !== null && + Number.isSafeInteger(major) && + Number.isSafeInteger(minor) && + (major > 2 || (major === 2 && minor >= 32)); + if (!supported) { + const found = + match && Number.isSafeInteger(major) && Number.isSafeInteger(minor) + ? ` Found ${match[0]}.` + : " Could not determine Git version from `git --version`."; + throw new Error( + `Local Artifacts requires Git 2.32 or newer.${found} Upgrade Git, then restart your dev server or test runner.` + ); + } +} + +/** Verify host Git at startup and report how to install or upgrade it. */ +export async function assertGitAvailable(): Promise { + let version: string; + try { + const result = await runGit(["--version"], { timeout: 30_000 }); + version = decoder.decode(result.stdout); + } catch (error) { + throw new Error( + "Local Artifacts requires Git 2.32 or newer on PATH. Install Git, then restart your dev server or test runner.", + { cause: error } + ); + } + assertSupportedGitVersion(version); +} + +/** Override repository-local hook configuration for any host Git command. */ +export function gitArgumentsWithoutHooks(args: string[]): string[] { + // Command-line config outranks repository-local config; environment config + // does not. Use a nonexistent path that is unique for each invocation. + const noHooks = path.join( + tmpdir(), + `miniflare-artifacts-no-hooks-${randomUUID()}` + ); + return ["-c", `core.hooksPath=${noHooks}`, ...args]; +} + +export async function runGit( + args: string[], + options: GitOptions = {} +): Promise { + return new Promise((resolve, reject) => { + const env = gitEnvironment(); + if (options.httpAuthorization) { + env.GIT_CONFIG_COUNT = "2"; + env.GIT_CONFIG_KEY_1 = `http.${options.httpAuthorization.origin}/.extraHeader`; + env.GIT_CONFIG_VALUE_1 = options.httpAuthorization.value; + } + const child = spawn("git", gitArgumentsWithoutHooks(args), { + env, + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const timeout = setTimeout(() => child.kill(), options.timeout ?? 300_000); + timeout.unref(); + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + child.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once("close", (status) => { + clearTimeout(timeout); + const result = { + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + status: status ?? 1, + }; + if (result.status === 0 || options.allowFailure) { + resolve(result); + } else { + reject( + new Error( + decoder.decode(result.stderr).trim() || + `git ${args[0]} exited with status ${result.status}` + ) + ); + } + }); + child.stdin.end(); + }); +} + +export interface RepositoryState { + defaultBranch: string; + refs: Record; +} + +export interface GitPerson { + name: string; + email: string; +} + +export interface CommitMetadata { + hash: string; + treeHash: string; + message: string; + author: GitPerson; + committer: GitPerson; + parents: string[]; + authoredAt: number; + committedAt: number; +} + +export type GitTreeEntryType = "tree" | "blob" | "symlink" | "gitlink" | "exec"; + +export interface GitTreeEntry { + name: string; + mode: string; + hash: string; + type: GitTreeEntryType; +} + +type GitObjectType = "blob" | "tree" | "commit" | "tag"; + +/** Only one host process may publish a repository at this path at a time. */ +export async function withRepositoryCreationLock( + repository: string, + action: () => Promise +): Promise { + const name = createHash("sha256") + .update(path.basename(repository)) + .digest("hex") + .slice(0, 32); + const lockPath = path.join( + path.dirname(repository), + `.artifacts-create-${name}.lock` + ); + let lock: FileHandle; + try { + lock = await open(lockPath, "wx", 0o600); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error( + `Another local process is creating this Git repository. Stop it and retry; if it crashed, remove "${lockPath}" only after confirming no process uses this storage path.` + ); + } + throw error; + } + try { + return await action(); + } finally { + await lock.close(); + await unlink(lockPath); + } +} + +/** Repository-scoped Git operations; clone/init are supported before it exists. */ +export class GitClient { + constructor(readonly repository: string) {} + + private git(args: string[], options?: GitOptions): Promise { + return runGit(["-C", this.repository, ...args], options); + } + + async init(defaultBranch: string): Promise { + await runGit([ + "init", + "--bare", + `--initial-branch=${defaultBranch}`, + this.repository, + ]); + } + + async forkFrom( + source: GitClient, + branch: string, + defaultBranchOnly: boolean + ): Promise { + const sourceBranch = `refs/heads/${branch}`; + const validBranch = await runGit(["check-ref-format", sourceBranch], { + allowFailure: true, + }); + if (validBranch.status !== 0) { + throw new Error("Git fork source branch is invalid"); + } + const sourceRepository = await source.git( + ["rev-parse", "--is-bare-repository"], + { allowFailure: true } + ); + if (sourceRepository.status !== 0) { + throw new Error("Git fork source is not a repository"); + } + const sourceHasBranch = await source.git( + ["rev-parse", "--verify", "--quiet", sourceBranch], + { allowFailure: true } + ); + if (defaultBranchOnly && sourceHasBranch.status === 0) { + await runGit([ + "clone", + "--bare", + "--local", + "--single-branch", + "--branch", + branch, + source.repository, + this.repository, + ]); + } else if (defaultBranchOnly) { + // Only an unborn HEAD on the requested branch permits an empty fork. + // A typo or a missing source must never create a repository instead. + const head = await source.git(["symbolic-ref", "HEAD"], { + allowFailure: true, + }); + if ( + head.status !== 0 || + decoder.decode(head.stdout).trim() !== sourceBranch + ) { + throw new Error("Git fork source branch does not exist"); + } + await this.init(branch); + } else { + await runGit([ + "clone", + "--bare", + "--local", + source.repository, + this.repository, + ]); + } + } + + async importFrom( + url: string, + branch?: string, + depth?: number + ): Promise { + return withRepositoryCreationLock(this.repository, async () => { + await assertImportTargetAvailable(this.repository); + const source = importSource(url); + if (depth !== undefined && (!Number.isSafeInteger(depth) || depth < 1)) { + throw new RangeError("Git import depth must be a positive integer"); + } + // Clone into a private, atomically reserved sibling. Even if another + // process creates the final path during the clone, cleanup only touches + // the directory we own, never that other process's repository. + const staged = await mkdtemp( + path.join(path.dirname(this.repository), ".artifacts-import-") + ); + // An import may carry credentials. Never let Git redirect them to a + // different destination (or silently expand the requested network access). + const args = [ + "-c", + "http.followRedirects=false", + "-c", + "protocol.ext.allow=never", + "-c", + "protocol.ssh.allow=never", + "clone", + "--bare", + ]; + if (branch) { + args.push("--branch", branch, "--single-branch"); + } + if (depth !== undefined) { + // Git's local-path optimization ignores --depth. Use its transport + // instead; --no-local has no effect on network remotes. + args.push("--depth", String(depth), "--no-local"); + } + args.push(source.url, staged); + try { + await runGit(args, { httpAuthorization: source.authorization }); + // A bare clone records remote.origin.url in its local Git config. It may + // include credentials used only for the import. Never retain the remote. + await new GitClient(staged).git(["remote", "remove", "origin"]); + await assertImportTargetAvailable(this.repository); + await rename(staged, this.repository); + } catch (error) { + // Git errors can echo URL credentials; do not propagate the raw error. + // A failed clone can also leave a config containing the import URL. + try { + await removeDir(staged); + } catch { + throw new Error( + `Git import failed and the partial repository at "${staged}" could not be removed. Delete it before retrying.` + ); + } + throw safeImportError(error); + } + }); + } + + async configure(generation: string): Promise { + await this.git(["config", "http.receivepack", "true"]); + await this.git(["config", "local-artifacts.generation", generation]); + } + + async generation(): Promise { + const result = await this.git( + ["config", "--get", "local-artifacts.generation"], + { allowFailure: true } + ); + return result.status === 0 ? decoder.decode(result.stdout).trim() : null; + } + + async state(): Promise { + const refsOutput = await this.git([ + "for-each-ref", + "--format=%(refname)%00%(objectname)", + ]); + const refs: Record = {}; + for (const line of decoder.decode(refsOutput.stdout).trim().split("\n")) { + if (!line) { + continue; + } + const [name, oid] = line.split("\0"); + if (name && oid) { + refs[name] = oid; + } + } + const head = await this.git(["symbolic-ref", "HEAD"], { + allowFailure: true, + }); + const defaultBranch = + decoder + .decode(head.stdout) + .trim() + .replace(/^refs\/heads\//, "") || "main"; + return { defaultBranch, refs }; + } + + async readBlob(hash: string): Promise<{ data: string } | null> { + if ((await this.objectType(hash)) !== "blob") { + return null; + } + const object = await this.git(["cat-file", "blob", hash]); + return { data: Buffer.from(object.stdout).toString("base64") }; + } + + async readTree(hash: string): Promise { + if ((await this.objectType(hash)) !== "tree") { + return null; + } + const tree = await this.git(["ls-tree", "-z", hash]); + return decoder + .decode(tree.stdout) + .split("\0") + .filter(Boolean) + .map((line) => { + const match = /^(\d+) \w+ ([0-9a-f]{40})\t([\s\S]+)$/.exec(line); + if (!match?.[1] || !match[2] || match[3] === undefined) { + throw new Error("Invalid git tree output"); + } + const mode = match[1] === "040000" ? "40000" : match[1]; + return { + name: match[3], + mode, + hash: match[2], + type: treeEntryType(mode), + }; + }); + } + + async readCommit(hash: string): Promise { + if ((await this.objectType(hash)) !== "commit") { + return null; + } + const object = await this.git(["cat-file", "commit", hash]); + return parseCommit(hash, decoder.decode(object.stdout)); + } + + async readFile(ref: string, path: string): Promise<{ data: string } | null> { + const commit = await this.resolveCommit(ref); + if (!commit) { + return null; + } + const entry = await this.git( + ["--literal-pathspecs", "ls-tree", "-z", commit, "--", path], + { allowFailure: true } + ); + if (entry.status !== 0 || entry.stdout.length === 0) { + return null; + } + const line = decoder.decode(entry.stdout).split("\0", 1)[0] ?? ""; + const match = /^(\d+) \w+ ([0-9a-f]{40})\t([\s\S]+)$/.exec(line); + if ( + !match?.[1] || + !match[2] || + match[3] !== path || + match[1] === "040000" + ) { + return null; + } + // Read the matched object, not a revision expression built from a path. + const object = await this.git(["cat-file", "blob", match[2]], { + allowFailure: true, + }); + return object.status === 0 + ? { data: Buffer.from(object.stdout).toString("base64") } + : null; + } + + async log(ref?: string, limit = 50, offset = 0): Promise { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) { + throw new RangeError( + "Git log limit must be an integer between 1 and 1000" + ); + } + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new RangeError("Git log offset must be a non-negative integer"); + } + const commit = await this.resolveCommit(ref ?? "HEAD"); + if (!commit) { + return []; + } + const revs = await this.git( + [ + "rev-list", + "--first-parent", + `--max-count=${limit}`, + `--skip=${offset}`, + commit, + ], + { allowFailure: true } + ); + if (revs.status !== 0) { + return []; + } + const hashes = decoder + .decode(revs.stdout) + .trim() + .split("\n") + .filter(Boolean); + const commits: CommitMetadata[] = []; + // Each read starts a native process. Bound concurrent reads even for the + // largest valid history page so one request cannot exhaust host resources. + for (let start = 0; start < hashes.length; start += 8) { + const batch = await Promise.all( + hashes.slice(start, start + 8).map(async (hash) => { + const commit = await this.readCommit(hash); + if (!commit) { + throw new Error(`Commit disappeared: ${hash}`); + } + return commit; + }) + ); + commits.push(...batch); + } + return commits; + } + + private async objectType(hash: string): Promise { + const result = await this.git(["cat-file", "-t", hash], { + allowFailure: true, + }); + return result.status === 0 + ? parseGitObjectType(decoder.decode(result.stdout).trim()) + : null; + } + + private async resolveCommit(ref: string): Promise { + let candidates: string[]; + if (ref === "HEAD" || /^[0-9a-f]{40}$/.test(ref)) { + candidates = [ref]; + } else if (ref.startsWith("refs/")) { + if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) { + return null; + } + candidates = [ref]; + } else { + candidates = [`refs/heads/${ref}`, `refs/tags/${ref}`]; + } + for (const candidate of candidates) { + if (candidate.startsWith("refs/")) { + const valid = await this.git(["check-ref-format", candidate], { + allowFailure: true, + }); + if (valid.status !== 0) { + continue; + } + } + const result = await this.git( + ["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], + { allowFailure: true } + ); + if (result.status === 0) { + return decoder.decode(result.stdout).trim(); + } + } + return null; + } +} + +function importSource(url: string): { + url: string; + authorization?: { origin: string; value: string }; +} { + // SSH reads host config and private keys outside the sanitized Git env. + // Local paths remain supported, but never hand an SSH source to Git. + if (/^ext::/i.test(url)) { + throw new Error("Git import protocol ext is not supported"); + } + if (/^[a-z][a-z\d+.-]*::/i.test(url)) { + throw new Error("Git import remote helpers are not supported"); + } + if (isSshSource(url)) { + throw new Error("Git import SSH sources are not supported"); + } + if ( + /^[a-z][a-z\d+.-]*:\/\//i.test(url) && + !/^(?:https?|file|git):\/\//i.test(url) + ) { + throw new Error("Git import protocol is not supported"); + } + // Only parse HTTP(S) URLs, where userinfo can be written into Git config. + if (!/^https?:\/\//i.test(url)) { + return { url }; + } + let source: URL; + try { + source = new URL(url); + } catch { + throw new Error("Git import source URL is invalid"); + } + if (!source.username && !source.password) { + return { url }; + } + if (source.protocol !== "https:") { + throw new Error("Git import credentials require HTTPS"); + } + const value = `Authorization: Basic ${Buffer.from( + `${decodeURIComponent(source.username)}:${decodeURIComponent(source.password)}` + ).toString("base64")}`; + const origin = source.origin; + source.username = ""; + source.password = ""; + // The sanitized URL is what Git records in config even if the host process + // exits mid-clone. The one-time HTTP header is passed through process env. + return { url: source.href, authorization: { origin, value } }; +} + +function isSshSource(source: string): boolean { + if (/^(?:ssh|git\+ssh|ssh\+git):\/\//i.test(source)) { + return true; + } + // Git interprets a colon before the first slash as scp-style SSH. Only + // Windows treats drive-relative and drive-absolute names as local paths. + return ( + !(process.platform === "win32" && /^[a-z]:/i.test(source)) && + /^[^/]+:/.test(source) && + !/^[a-z][a-z\d+.-]*:\/\//i.test(source) + ); +} + +async function assertImportTargetAvailable(repository: string): Promise { + try { + await lstat(repository); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + throw new Error("Git import target already exists"); +} + +function safeImportError(error: unknown): Error { + const detail = error instanceof Error ? error.message.toLowerCase() : ""; + if (detail === "git import target already exists") { + return new Error("Git import target already exists"); + } + if ( + detail.includes("authentication failed") || + detail.includes("could not read username") || + detail.includes("http 401") || + detail.includes("returned error: 401") + ) { + return new Error("Git import authentication failed. Check credentials."); + } + if (detail.includes("remote branch") && detail.includes("not found")) { + return new Error("Git import remote branch not found."); + } + if (detail.includes("does not appear to be a git repository")) { + return new Error("Git import URL does not appear to be a git repository."); + } + if (detail.includes("not found")) { + return new Error("Git import remote repository not found."); + } + return new Error( + "Git import failed. Check the source URL, credentials, TLS certificate, and network access." + ); +} + +function parseGitObjectType(value: string): GitObjectType { + switch (value) { + case "blob": + case "tree": + case "commit": + case "tag": + return value; + default: + throw new Error(`Unsupported Git object type: ${value}`); + } +} + +function commitHeader(headers: string[], name: string): string | undefined { + return headers + .find((line) => line.startsWith(`${name} `)) + ?.slice(name.length + 1); +} + +function parseCommitIdentity( + headers: string[], + name: string +): { + person: GitPerson; + time: number; +} { + const match = /^(.*) <([^<>]*)> (-?\d+) [+-]\d{4}$/.exec( + commitHeader(headers, name) ?? "" + ); + if (!match?.[1] || match[2] === undefined || !match[3]) { + throw new Error(`Invalid commit ${name}`); + } + return { + person: { name: match[1], email: match[2] }, + time: Number(match[3]), + }; +} + +function parseCommit(hash: string, value: string): CommitMetadata { + const split = value.indexOf("\n\n"); + const headers = value.slice(0, split < 0 ? value.length : split).split("\n"); + const author = parseCommitIdentity(headers, "author"); + const committer = parseCommitIdentity(headers, "committer"); + const treeHash = commitHeader(headers, "tree"); + if (!treeHash) { + throw new Error("Invalid commit tree"); + } + return { + hash, + treeHash, + message: (split < 0 ? "" : value.slice(split + 2)).replace(/\n$/, ""), + author: author.person, + committer: committer.person, + parents: headers + .filter((line) => line.startsWith("parent ")) + .map((line) => line.slice(7)), + authoredAt: author.time, + committedAt: committer.time, + }; +} + +function treeEntryType(mode: string): GitTreeEntryType { + if (mode === "40000") { + return "tree"; + } + if (mode === "120000") { + return "symlink"; + } + if (mode === "160000") { + return "gitlink"; + } + if (mode === "100755") { + return "exec"; + } + return "blob"; +} diff --git a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts new file mode 100644 index 00000000000..fd71803c756 --- /dev/null +++ b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts @@ -0,0 +1,646 @@ +import { execFile } from "node:child_process"; +import { + chmod, + mkdir, + readFile, + readdir, + stat, + writeFile, +} from "node:fs/promises"; +import { devNull } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { test } from "vitest"; +import { + assertGitAvailable, + assertSupportedGitVersion, + GitClient, + gitEnvironment, + runGit, +} from "../../../src/plugins/artifacts/git-client"; +import { useTmp } from "../../test-shared"; + +const exec = promisify(execFile); + +function git(args: string[], cwd?: string, extraEnv: NodeJS.ProcessEnv = {}) { + return exec("git", args, { + cwd, + env: { + ...gitEnvironment(), + ...extraEnv, + GIT_AUTHOR_NAME: "Miniflare", + GIT_AUTHOR_EMAIL: "miniflare@example.com", + GIT_COMMITTER_NAME: "Miniflare", + GIT_COMMITTER_EMAIL: "miniflare@example.com", + }, + }); +} + +test("Git subprocesses do not inherit Cloudflare or host credentials", ({ + expect, +}) => { + const env = gitEnvironment({ + PATH: "/usr/bin", + CLOUDFLARE_API_TOKEN: "production-secret", + GIT_CONFIG_GLOBAL: "/home/user/.gitconfig", + HOME: "/home/user", + GIT_CONFIG_PARAMETERS: "'credential.helper=unsafe'", + GIT_ASKPASS: "/home/user/askpass", + GITHUB_TOKEN: "github-secret", + }); + expect(env.PATH).toBe("/usr/bin"); + expect(env.CLOUDFLARE_API_TOKEN).toBeUndefined(); + expect(env.GIT_CONFIG_PARAMETERS).toBeUndefined(); + expect(env.GIT_ASKPASS).toBeUndefined(); + expect(env.GITHUB_TOKEN).toBeUndefined(); + expect(env.GIT_CONFIG_VALUE_0).toBe(""); + expect(env.GIT_CONFIG_COUNT).toBe("1"); + expect(env.HOME).toContain("miniflare-artifacts-home-"); + expect(env.HOME).not.toBe("/home/user"); + if (process.platform === "win32") { + expect(env.GIT_CONFIG_GLOBAL).toContain("miniflare-artifacts-empty-"); + } else { + expect(env.GIT_CONFIG_GLOBAL).toBe(devNull); + } +}); + +test("Git version check accepts 2.32 and vendor versions, but rejects older Git", ({ + expect, +}) => { + for (const output of [ + "git version 2.32.0", + "git version 2.32.0.windows.1", + "git version 2.39.5 (Apple Git-154)", + "git version 3.0.0", + ]) { + expect(() => assertSupportedGitVersion(output)).not.toThrow(); + } + for (const output of [ + "git version 2.31.99", + "git version 1.99.0", + "not a Git version", + ]) { + expect(() => assertSupportedGitVersion(output)).toThrow( + /requires Git 2\.32 or newer.*Upgrade Git/ + ); + } +}); + +test.skipIf(process.platform === "win32")( + "Git check suggests an upgrade before starting any repository operation", + async ({ expect }) => { + const root = await useTmp(); + const fakeGit = path.join(root, "git"); + await writeFile(fakeGit, "#!/bin/sh\nprintf 'git version 2.31.99\\n'\n"); + await chmod(fakeGit, 0o755); + const previousPath = process.env.PATH; + process.env.PATH = root; + try { + await expect(assertGitAvailable()).rejects.toThrow( + /requires Git 2\.32 or newer.*Found git version 2\.31\.99.*Upgrade Git.*restart/i + ); + } finally { + process.env.PATH = previousPath; + } + } +); + +test("Git client imports, reads and forks a local repository", async ({ + expect, +}) => { + const directory = await useTmp(); + const source = path.join(directory, "source"); + await git(["init", "--initial-branch=main", source]); + await writeFile(path.join(source, "hello.txt"), "hello artifacts\n"); + await git(["add", "."], source); + await git(["commit", "-m", "fixture"], source); + + // The public binding accepts HTTPS; file:// tests native Git without network access. + const imported = new GitClient(path.join(directory, "imported.git")); + await imported.importFrom(pathToFileURL(source).href, "main", 1); + await imported.configure("local-generation"); + expect(await imported.generation()).toBe("local-generation"); + expect((await imported.state()).defaultBranch).toBe("main"); + expect((await imported.readFile("main", "hello.txt"))?.data).toBe( + Buffer.from("hello artifacts\n").toString("base64") + ); + const commit = (await imported.log())[0]; + if (!commit) { + throw new Error("Imported repository is missing its commit"); + } + expect(commit.message).toBe("fixture"); + expect((await imported.readCommit(commit.hash))?.hash).toBe(commit.hash); + expect(await imported.readTree(commit.treeHash)).toMatchObject([ + { name: "hello.txt", mode: "100644", type: "blob" }, + ]); + await git(["tag", "-a", "v1", "-m", "release"], imported.repository); + const tag = ( + await git(["rev-parse", "refs/tags/v1"], imported.repository) + ).stdout.trim(); + expect(await imported.readCommit(tag)).toBeNull(); + expect(await imported.readBlob(tag)).toBeNull(); + const blob = ( + await git(["rev-parse", "HEAD:hello.txt"], source) + ).stdout.trim(); + expect((await imported.readBlob(blob))?.data).toBe( + Buffer.from("hello artifacts\n").toString("base64") + ); + expect( + (await git(["-C", imported.repository, "remote"], directory)).stdout + ).toBe(""); + + const forked = new GitClient(path.join(directory, "forked.git")); + await forked.forkFrom(imported, "main", true); + expect((await forked.state()).refs["refs/heads/main"]).toBe(commit.hash); +}); + +test("Git fork only creates an empty target for a valid unborn source branch", async ({ + expect, +}) => { + const directory = await useTmp(); + const source = new GitClient(path.join(directory, "source.git")); + const target = new GitClient(path.join(directory, "fork.git")); + await expect(target.forkFrom(source, "main", true)).rejects.toThrow( + "Git fork source is not a repository" + ); + await expect(stat(target.repository)).rejects.toMatchObject({ + code: "ENOENT", + }); + + await source.init("main"); + await expect(target.forkFrom(source, "typo", true)).rejects.toThrow( + "Git fork source branch does not exist" + ); + await expect(target.forkFrom(source, "main~1", true)).rejects.toThrow( + "Git fork source branch is invalid" + ); + await target.forkFrom(source, "main", true); + expect(await target.state()).toEqual({ defaultBranch: "main", refs: {} }); +}); + +test("Git state and forks keep the branch when a tag has the same name", async ({ + expect, +}) => { + const directory = await useTmp(); + const sourcePath = path.join(directory, "source"); + await git(["init", "--initial-branch=main", sourcePath]); + const source = new GitClient(sourcePath); + await writeFile(path.join(sourcePath, "README"), "first\n"); + await git(["add", "README"], sourcePath); + await git(["commit", "-m", "first"], sourcePath); + const commit = (await git(["rev-parse", "HEAD"], sourcePath)).stdout.trim(); + await git(["tag", "main", commit], sourcePath); + expect((await source.state()).defaultBranch).toBe("main"); + const target = new GitClient(path.join(directory, "fork.git")); + await target.forkFrom(source, (await source.state()).defaultBranch, true); + expect((await target.state()).refs["refs/heads/main"]).toBe(commit); +}); + +test("Git import accepts a native local repository path", async ({ + expect, +}) => { + const workspace = await useTmp(); + const source = path.join(workspace, "source.git"); + const target = path.join(workspace, "local.git"); + await git(["init", "--bare", source]); + await new GitClient(target).importFrom(source); + expect((await stat(target)).isDirectory()).toBe(true); + expect((await git(["remote"], target)).stdout).toBe(""); +}); + +test.skipIf(process.platform !== "win32")( + "Git import accepts a Windows drive-relative repository path", + async ({ expect }) => { + const workspace = await useTmp(); + const source = path.join(workspace, "source.git"); + const target = path.join(workspace, "target.git"); + await git(["init", "--bare", source]); + const previousDirectory = process.cwd(); + process.chdir(workspace); + try { + const driveRelative = `${path.parse(workspace).root[0]}:source.git`; + await new GitClient(target).importFrom(driveRelative); + expect((await stat(target)).isDirectory()).toBe(true); + } finally { + process.chdir(previousDirectory); + } + } +); + +test("Git import honors depth for a native local path", async ({ expect }) => { + const workspace = await useTmp(); + const source = path.join(workspace, "source"); + const target = path.join(workspace, "shallow.git"); + await git(["init", "--initial-branch=main", source]); + for (const version of [1, 2, 3]) { + await writeFile(path.join(source, "README"), `version ${version}\n`); + await git(["add", "README"], source); + await git(["commit", "-m", `version ${version}`], source); + } + await new GitClient(target).importFrom(source, "main", 1); + expect( + (await git(["rev-list", "--count", "HEAD"], target)).stdout.trim() + ).toBe("1"); + expect( + (await git(["rev-parse", "--is-shallow-repository"], target)).stdout.trim() + ).toBe("true"); +}); + +test("Git import rejects invalid depths without leaving staging files", async ({ + expect, +}) => { + const workspace = await useTmp(); + const client = new GitClient(path.join(workspace, "target.git")); + for (const depth of [0, -1, 1.5, Number.NaN]) { + await expect( + client.importFrom(path.join(workspace, "source.git"), "main", depth) + ).rejects.toThrow("Git import depth must be a positive integer"); + } + expect(await readdir(workspace)).toEqual([]); +}); + +test("Git import rejects remote-ext commands before invoking Git", async ({ + expect, +}) => { + const workspace = await useTmp(); + const marker = path.join(workspace, "must-not-run"); + const client = new GitClient(path.join(workspace, "target.git")); + await expect( + client.importFrom(`ext::sh -c 'touch ${marker}'`) + ).rejects.toThrow("Git import protocol ext is not supported"); + await expect(client.importFrom(`other::touch ${marker}`)).rejects.toThrow( + "Git import remote helpers are not supported" + ); + await expect( + client.importFrom("custom://example.invalid/repo") + ).rejects.toThrow("Git import protocol is not supported"); + await expect(stat(marker)).rejects.toMatchObject({ code: "ENOENT" }); + expect( + (await readdir(workspace)).filter((name) => name.startsWith(".artifacts-")) + ).toEqual([]); +}); + +test.skipIf(process.platform === "win32")( + "Git import staging supports a long target component", + async ({ expect }) => { + const workspace = await useTmp(); + const source = path.join(workspace, "source.git"); + const target = path.join(workspace, "r".repeat(250)); + await git(["init", "--bare", source]); + await new GitClient(target).importFrom(source); + expect((await stat(target)).isDirectory()).toBe(true); + } +); + +test("failed imports discard partial clones without exposing URL credentials", async ({ + expect, +}) => { + const repository = path.join(await useTmp(), "failed.git"); + const client = new GitClient(repository); + const url = "https://reader:example-password@127.0.0.1:1/missing.git"; + let failure: unknown; + try { + await client.importFrom(url); + } catch (error) { + failure = error; + } + expect(String(failure)).toMatch(/Git import failed.*source URL/); + expect(String(failure)).not.toContain("example-password"); + expect(String(failure)).not.toContain(url); + await expect(stat(repository)).rejects.toMatchObject({ code: "ENOENT" }); +}); + +test.skipIf(process.platform === "win32")( + "a failed partial clone removes only its staging directory", + async ({ expect }) => { + const workspace = await useTmp(); + const target = path.join(workspace, "target.git"); + const stagedMarker = path.join(workspace, "staged-clone"); + const bin = path.join(workspace, "bin"); + await mkdir(bin); + const wrapper = path.join(bin, "git"); + await writeFile( + wrapper, + `#!/bin/sh\nfor target do :; done\nmkdir -p "$target"\nprintf 'partial clone\\n' > "$target/config"\nprintf '%s' "$target" > '${stagedMarker}'\nprintf 'fatal: simulated clone failure\\n' >&2\nexit 1\n` + ); + await chmod(wrapper, 0o755); + const previousPath = process.env.PATH; + process.env.PATH = `${bin}:${previousPath}`; + try { + await expect( + new GitClient(target).importFrom("https://example.invalid/repo.git") + ).rejects.toThrow("Git import failed"); + const staged = await readFile(stagedMarker, "utf8"); + expect(staged).toContain(path.join(workspace, ".artifacts-import-")); + await expect(stat(staged)).rejects.toMatchObject({ code: "ENOENT" }); + expect((await readdir(workspace)).sort()).toEqual([ + "bin", + "staged-clone", + ]); + await expect(stat(target)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + process.env.PATH = previousPath; + } + } +); + +test("failed imports never remove a pre-existing target", async ({ + expect, +}) => { + const repository = path.join(await useTmp(), "existing.git"); + await mkdir(repository); + const marker = path.join(repository, "keep.txt"); + await writeFile(marker, "keep me\n"); + await expect( + new GitClient(repository).importFrom("https://127.0.0.1:1/missing.git") + ).rejects.toThrow("Git import target already exists"); + expect(await readFile(marker, "utf8")).toBe("keep me\n"); +}); + +test.skipIf(process.platform === "win32")( + "a concurrent repository is never removed by failed import cleanup", + async ({ expect }) => { + const workspace = await useTmp(); + const source = path.join(workspace, "source.git"); + const target = path.join(workspace, "target.git"); + const marker = path.join(target, "keep.txt"); + const ready = path.join(workspace, "clone-ready"); + const release = path.join(workspace, "clone-release"); + const bin = path.join(workspace, "bin"); + await git(["init", "--bare", source]); + await mkdir(bin); + const realGit = (await exec("which", ["git"])).stdout.trim(); + const wrapper = path.join(bin, "git"); + await writeFile( + wrapper, + `#!/bin/sh\ncase " $* " in\n *" clone "*)\n printf ready > '${ready}'\n while [ ! -f '${release}' ]; do sleep 0.02; done\n ;;\nesac\nexec '${realGit}' "$@"\n` + ); + await chmod(wrapper, 0o755); + + const previousPath = process.env.PATH; + process.env.PATH = `${bin}:${previousPath}`; + const importing = new GitClient(target).importFrom( + pathToFileURL(source).href + ); + try { + for (let attempt = 0; attempt < 250; attempt++) { + if ((await readdir(workspace)).includes("clone-ready")) { + break; + } + await delay(20); + } + expect(await readFile(ready, "utf8")).toBe("ready"); + await expect( + new GitClient(target).importFrom(pathToFileURL(source).href) + ).rejects.toThrow( + "Another local process is creating this Git repository" + ); + await mkdir(target); + await writeFile(marker, "keep me\n"); + await writeFile(release, "continue"); + await expect(importing).rejects.toThrow( + "Git import target already exists" + ); + expect(await readFile(marker, "utf8")).toBe("keep me\n"); + const remaining = await readdir(workspace); + expect( + remaining.some((name) => name.startsWith(".artifacts-import-")) + ).toBe(false); + expect( + remaining.some((name) => name.startsWith(".artifacts-create-")) + ).toBe(false); + } finally { + await writeFile(release, "continue"); + await importing.catch(() => {}); + process.env.PATH = previousPath; + } + } +); + +test.skipIf(process.platform === "win32")( + "Git clone sanitizes HTTPS credentials and rejects SSH sources", + async ({ expect }) => { + const workspace = await useTmp(); + const bin = path.join(workspace, "bin"); + const cloneArgs = path.join(workspace, "clone-args"); + const configKey = path.join(workspace, "authorization-key"); + const configValue = path.join(workspace, "authorization-value"); + await mkdir(bin); + const realGit = (await exec("which", ["git"])).stdout.trim(); + const wrapper = path.join(bin, "git"); + await writeFile( + wrapper, + `#!/bin/sh\nprintf '%s' "$*" > '${cloneArgs}'\nprintf '%s' "$GIT_CONFIG_KEY_1" > '${configKey}'\nprintf '%s' "$GIT_CONFIG_VALUE_1" > '${configValue}'\nexec '${realGit}' "$@"\n` + ); + await chmod(wrapper, 0o755); + const previousPath = process.env.PATH; + process.env.PATH = `${bin}:${previousPath}`; + try { + await expect( + new GitClient(path.join(workspace, "failed.git")).importFrom( + "https://reader:credential-canary@127.0.0.1:1/missing.git" + ) + ).rejects.toThrow("Git import failed"); + const args = await readFile(cloneArgs, "utf8"); + expect(args).toContain("https://127.0.0.1:1/missing.git"); + expect(args).not.toContain("credential-canary"); + expect(args).toContain("protocol.ssh.allow=never"); + expect(await readFile(configKey, "utf8")).toBe( + "http.https://127.0.0.1:1/.extraHeader" + ); + expect(await readFile(configValue, "utf8")).toBe( + `Authorization: Basic ${Buffer.from("reader:credential-canary").toString("base64")}` + ); + for (const url of [ + "git@example.invalid:team/repo.git", + "example.invalid:team/repo.git", + "ssh://git@example.invalid/team/repo.git", + "git+ssh://git@example.invalid/team/repo.git", + ]) { + await expect( + new GitClient(path.join(workspace, "ssh.git")).importFrom(url) + ).rejects.toThrow("Git import SSH sources are not supported"); + expect(await readFile(cloneArgs, "utf8")).toBe(args); + } + expect(await readFile(configKey, "utf8")).toBe( + "http.https://127.0.0.1:1/.extraHeader" + ); + } finally { + process.env.PATH = previousPath; + } + } +); + +test("failed Git imports clean their target and hide URL credentials", async ({ + expect, +}) => { + const repository = path.join(await useTmp(), "failed.git"); + let failure: unknown; + try { + await new GitClient(repository).importFrom( + "https://reader:credential-canary@127.0.0.1:1/missing.git" + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(String(failure)).toContain("Git import failed"); + expect(String(failure)).not.toContain("credential-canary"); + await expect(stat(repository)).rejects.toMatchObject({ code: "ENOENT" }); +}); + +test("Git readFile uses literal paths and the matched blob hash", async ({ + expect, +}) => { + const directory = await useTmp(); + const repository = path.join(directory, "files"); + await git(["init", "--initial-branch=main", repository]); + const files: [string, string][] = [ + ["README", "plain\n"], + ["[README", "bracket\n"], + ]; + // Windows cannot create a filename beginning with ':'. The bracket case + // still exercises literal pathspec matching there. + if (process.platform !== "win32") { + files.push([":README", "colon\n"]); + } + for (const [name, content] of files) { + await writeFile(path.join(repository, name), content); + } + await git(["add", "-A"], repository); + await git(["commit", "-m", "literal filenames"], repository); + const client = new GitClient(repository); + for (const [name, content] of files) { + expect((await client.readFile("HEAD", name))?.data).toBe( + Buffer.from(content).toString("base64") + ); + } +}); + +test("Git log rejects invalid pagination before starting a history read", async ({ + expect, +}) => { + const client = new GitClient(path.join(await useTmp(), "empty.git")); + await client.init("main"); + for (const limit of [-1, 0, 1001, 1.5, Number.POSITIVE_INFINITY]) { + await expect(client.log("HEAD", limit)).rejects.toThrow( + /Git log limit must be an integer between 1 and 1000/ + ); + } + for (const offset of [-1, 1.5, Number.POSITIVE_INFINITY]) { + await expect(client.log("HEAD", 1, offset)).rejects.toThrow( + /Git log offset must be a non-negative integer/ + ); + } +}); + +test("Git commands override repository-local hooksPath configuration", async ({ + expect, +}) => { + const root = await useTmp(); + const target = path.join(root, "target.git"); + const hooks = path.join(root, "host-hooks"); + await new GitClient(target).init("main"); + await git(["-C", target, "config", "core.hooksPath", hooks]); + const result = await runGit([ + "-C", + target, + "config", + "--get", + "core.hooksPath", + ]); + const effective = result.stdout.toString().trim(); + expect(effective).toContain("miniflare-artifacts-no-hooks-"); + expect(effective).not.toBe(hooks); +}); + +test("Git client reads pre-1970 commit timestamps", async ({ expect }) => { + const directory = await useTmp(); + const repository = path.join(directory, "history"); + await git(["init", "--initial-branch=main", repository]); + const tree = (await git(["write-tree"], repository)).stdout.trim(); + const commitFile = path.join(directory, "negative-date-commit"); + await writeFile( + commitFile, + `tree ${tree}\nauthor Ada -1 +0000\ncommitter Ada -1 +0000\n\nbefore epoch\n` + ); + // Git's object writer rejects negative dates by default, but readers must + // still accept existing commit objects with those header values. + const hash = ( + await git( + ["hash-object", "--literally", "-t", "commit", "-w", commitFile], + repository + ) + ).stdout.trim(); + await git(["update-ref", "refs/heads/main", hash], repository); + const client = new GitClient(repository); + expect(await client.readCommit(hash)).toMatchObject({ + authoredAt: -1, + committedAt: -1, + }); + expect(await client.log()).toMatchObject([{ hash, authoredAt: -1 }]); +}); + +test("Git reads HEAD, named and qualified refs without evaluating revision expressions", async ({ + expect, +}) => { + const root = await useTmp(); + const source = path.join(root, "source"); + await git(["init", "--initial-branch=main", source]); + await writeFile(path.join(source, "README"), "first\n"); + await git(["add", "README"], source); + await git(["commit", "-m", "first"], source); + await writeFile(path.join(source, "README"), "second\n"); + await git(["add", "README"], source); + await git(["commit", "-m", "second"], source); + await git(["tag", "release"], source); + const client = new GitClient(path.join(root, "imported.git")); + await client.importFrom(pathToFileURL(source).href); + for (const ref of [ + "main", + "HEAD", + "refs/heads/main", + "release", + "refs/tags/release", + ]) { + expect((await client.readFile(ref, "README"))?.data).toBe( + Buffer.from("second\n").toString("base64") + ); + expect((await client.log(ref))[0]?.message).toBe("second"); + } + for (const ref of ["main~1", "main^{commit}", "refs/heads/main~1"]) { + expect(await client.readFile(ref, "README")).toBeNull(); + expect(await client.log(ref)).toEqual([]); + } +}); + +test("Git log follows the first-parent chain through merges", async ({ + expect, +}) => { + const directory = await useTmp(); + const repository = path.join(directory, "merged"); + await git(["init", "--initial-branch=main", repository]); + await writeFile(path.join(repository, "base.txt"), "base\n"); + await git(["add", "."], repository); + await git(["commit", "-m", "base"], repository); + await git(["checkout", "-b", "feature"], repository); + await writeFile(path.join(repository, "feature.txt"), "feature\n"); + await git(["add", "."], repository); + await git(["commit", "-m", "feature"], repository); + const feature = (await git(["rev-parse", "HEAD"], repository)).stdout.trim(); + await git(["checkout", "main"], repository); + await writeFile(path.join(repository, "main.txt"), "main\n"); + await git(["add", "."], repository); + await git(["commit", "-m", "main"], repository); + await git(["merge", "--no-ff", "-m", "merge", "feature"], repository); + const expected = ( + await git(["rev-list", "--first-parent", "HEAD"], repository) + ).stdout + .trim() + .split("\n"); + const history = await new GitClient(repository).log(); + expect(history.map((commit) => commit.hash)).toEqual(expected); + expect(history.map((commit) => commit.hash)).not.toContain(feature); +});