From 63272fcfbc098645b7d8e10b1a8588d000c1fa7b Mon Sep 17 00:00:00 2001 From: Leo Ostera Date: Fri, 9 Oct 2026 12:37:58 +0100 Subject: [PATCH 01/12] [miniflare] Add isolated native Git client for Artifacts --- .../src/plugins/artifacts/git-client.ts | 456 ++++++++++++++++++ .../test/plugins/artifacts/git-client.spec.ts | 194 ++++++++ 2 files changed, 650 insertions(+) create mode 100644 packages/miniflare/src/plugins/artifacts/git-client.ts create mode 100644 packages/miniflare/test/plugins/artifacts/git-client.spec.ts 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..50df93ebb85 --- /dev/null +++ b/packages/miniflare/src/plugins/artifacts/git-client.ts @@ -0,0 +1,456 @@ +import { Buffer } from "node:buffer"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { devNull, tmpdir } from "node:os"; +import path from "node:path"; + +const decoder = new TextDecoder(); +type GitOptions = { allowFailure?: boolean; timeout?: number }; +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, + 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, verify that \`git --version\` reports 2.32 or newer, 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 installed on the host and available on PATH. Install Git 2.32 or newer, verify that `git --version` works, then restart your dev server or test runner.", + { cause: error } + ); + } + assertSupportedGitVersion(version); +} + +export async function runGit( + args: string[], + options: GitOptions = {} +): Promise { + return new Promise((resolve, reject) => { + const child = spawn("git", args, { + env: gitEnvironment(), + 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 CommitMetadata { + hash: string; + treeHash: string; + message: string; + author: { name: string; email: string }; + committer: { name: string; email: string }; + parents: string[]; + authoredAt: number; + committedAt: number; +} + +/** 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 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) { + // Git cannot clone a named branch that has no commits yet. + await this.init(branch); + } else { + await runGit([ + "clone", + "--bare", + "--local", + source.repository, + this.repository, + ]); + } + } + + async importFrom( + url: string, + branch?: string, + depth?: number + ): Promise { + // 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", "clone", "--bare"]; + if (branch) { + args.push("--branch", branch, "--single-branch"); + } + if (depth) { + args.push("--depth", String(depth)); + } + args.push(url, this.repository); + await runGit(args); + // A bare clone records remote.origin.url in its local Git config. It may + // include Basic credentials or query tokens used only for the import. + // Local repositories do not fetch from the upstream after creation. + await this.git(["remote", "remove", "origin"]); + } + + 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", "--short", "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 | null> { + 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(["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] !== path || match[1] === "040000") { + return null; + } + const object = await this.git(["cat-file", "blob", `${commit}:${path}`], { + allowFailure: true, + }); + return object.status === 0 + ? { data: Buffer.from(object.stdout).toString("base64") } + : null; + } + + async log(ref?: string, limit = 50, offset = 0): Promise { + const commit = await this.resolveCommit(ref ?? "HEAD", ref === undefined); + if (!commit) { + return []; + } + const effectiveLimit = Math.min(limit, 1000); + const revs = await this.git( + [ + "rev-list", + "--first-parent", + `--max-count=${effectiveLimit}`, + `--skip=${offset}`, + commit, + ], + { allowFailure: true } + ); + if (revs.status !== 0) { + return []; + } + const hashes = decoder + .decode(revs.stdout) + .trim() + .split("\n") + .filter(Boolean); + return Promise.all( + hashes.map(async (hash) => { + const commitMetadata = await this.readCommit(hash); + if (!commitMetadata) { + throw new Error(`Commit disappeared: ${hash}`); + } + return commitMetadata; + }) + ); + } + + private async objectType(hash: string): Promise { + const result = await this.git(["cat-file", "-t", hash], { + allowFailure: true, + }); + return result.status === 0 ? decoder.decode(result.stdout).trim() : null; + } + + private async resolveCommit( + ref: string, + allowHead = false + ): Promise { + const candidates = allowHead + ? ["HEAD"] + : /^[0-9a-f]{40}$/.test(ref) + ? [ref] + : [`refs/heads/${ref}`, `refs/tags/${ref}`]; + for (const candidate of candidates) { + 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 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: { name: string; email: string }; + 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): string { + 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..cfb9e323944 --- /dev/null +++ b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts @@ -0,0 +1,194 @@ +import { execFile } from "node:child_process"; +import { chmod, writeFile } from "node:fs/promises"; +import { devNull } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { test } from "vitest"; +import { + assertGitAvailable, + assertSupportedGitVersion, + GitClient, + gitEnvironment, +} 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", + 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(""); + 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" }, + ]); + 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 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 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); +}); From 19bd122a4433e63f67c943f1e2ef62442ad1a475 Mon Sep 17 00:00:00 2001 From: Leo Ostera Date: Fri, 9 Oct 2026 13:33:27 +0100 Subject: [PATCH 02/12] [miniflare] Guard native Git imports and history reads --- .../src/plugins/artifacts/git-client.ts | 116 ++++++++++++++---- .../test/plugins/artifacts/git-client.spec.ts | 105 +++++++++++++++- 2 files changed, 193 insertions(+), 28 deletions(-) diff --git a/packages/miniflare/src/plugins/artifacts/git-client.ts b/packages/miniflare/src/plugins/artifacts/git-client.ts index 50df93ebb85..ae076703f2a 100644 --- a/packages/miniflare/src/plugins/artifacts/git-client.ts +++ b/packages/miniflare/src/plugins/artifacts/git-client.ts @@ -1,8 +1,10 @@ import { Buffer } from "node:buffer"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { lstat } from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; import path from "node:path"; +import { removeDir } from "@cloudflare/workers-utils/fs-helpers"; const decoder = new TextDecoder(); type GitOptions = { allowFailure?: boolean; timeout?: number }; @@ -88,12 +90,23 @@ export async function assertGitAvailable(): Promise { 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 child = spawn("git", args, { + const child = spawn("git", gitArgumentsWithoutHooks(args), { env: gitEnvironment(), stdio: ["pipe", "pipe", "pipe"], }); @@ -202,6 +215,16 @@ export class GitClient { branch?: string, depth?: number ): Promise { + // Only clean up directories created by this import. A failed clone into + // an existing path must never remove someone else's repository. + try { + await lstat(this.repository); + throw new Error("Git import target already exists"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } // 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", "clone", "--bare"]; @@ -212,11 +235,25 @@ export class GitClient { args.push("--depth", String(depth)); } args.push(url, this.repository); - await runGit(args); - // A bare clone records remote.origin.url in its local Git config. It may - // include Basic credentials or query tokens used only for the import. - // Local repositories do not fetch from the upstream after creation. - await this.git(["remote", "remove", "origin"]); + try { + await runGit(args); + // 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 this.git(["remote", "remove", "origin"]); + } catch { + // 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(this.repository); + } catch { + throw new Error( + `Git import failed and the partial repository at "${this.repository}" could not be removed. Delete it before retrying.` + ); + } + throw new Error( + "Git import failed. Check the source URL, credentials, TLS certificate, and network access." + ); + } } async configure(generation: string): Promise { @@ -328,16 +365,23 @@ export class GitClient { } async log(ref?: string, limit = 50, offset = 0): Promise { - const commit = await this.resolveCommit(ref ?? "HEAD", ref === undefined); + 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 effectiveLimit = Math.min(limit, 1000); const revs = await this.git( [ "rev-list", "--first-parent", - `--max-count=${effectiveLimit}`, + `--max-count=${limit}`, `--skip=${offset}`, commit, ], @@ -351,15 +395,22 @@ export class GitClient { .trim() .split("\n") .filter(Boolean); - return Promise.all( - hashes.map(async (hash) => { - const commitMetadata = await this.readCommit(hash); - if (!commitMetadata) { - throw new Error(`Commit disappeared: ${hash}`); - } - return commitMetadata; - }) - ); + 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 { @@ -369,16 +420,27 @@ export class GitClient { return result.status === 0 ? decoder.decode(result.stdout).trim() : null; } - private async resolveCommit( - ref: string, - allowHead = false - ): Promise { - const candidates = allowHead - ? ["HEAD"] - : /^[0-9a-f]{40}$/.test(ref) - ? [ref] - : [`refs/heads/${ref}`, `refs/tags/${ref}`]; + 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 } diff --git a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts index cfb9e323944..6fd76d6f988 100644 --- a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts +++ b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { chmod, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { devNull } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -10,6 +10,7 @@ import { assertSupportedGitVersion, GitClient, gitEnvironment, + runGit, } from "../../../src/plugins/artifacts/git-client"; import { useTmp } from "../../test-shared"; @@ -46,6 +47,7 @@ test("Git subprocesses do not inherit Cloudflare or host credentials", ({ expect(env.GIT_ASKPASS).toBeUndefined(); expect(env.GITHUB_TOKEN).toBeUndefined(); expect(env.GIT_CONFIG_VALUE_0).toBe(""); + expect(env.GIT_CONFIG_COUNT).toBe("1"); if (process.platform === "win32") { expect(env.GIT_CONFIG_GLOBAL).toContain("miniflare-artifacts-empty-"); } else { @@ -137,6 +139,74 @@ test("Git client imports, reads and forks a local repository", async ({ expect((await forked.state()).refs["refs/heads/main"]).toBe(commit.hash); }); +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("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("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"); @@ -164,6 +234,39 @@ test("Git client reads pre-1970 commit timestamps", async ({ expect }) => { 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, }) => { From b4b36aa6669d0de9a52950927fa0ee21d1d6b071 Mon Sep 17 00:00:00 2001 From: Leo Ostera Date: Fri, 9 Oct 2026 13:41:16 +0100 Subject: [PATCH 03/12] [miniflare] Preserve safe Git import error categories --- .../src/plugins/artifacts/git-client.ts | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/miniflare/src/plugins/artifacts/git-client.ts b/packages/miniflare/src/plugins/artifacts/git-client.ts index ae076703f2a..32bb514e0bd 100644 --- a/packages/miniflare/src/plugins/artifacts/git-client.ts +++ b/packages/miniflare/src/plugins/artifacts/git-client.ts @@ -240,7 +240,7 @@ export class GitClient { // 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 this.git(["remote", "remove", "origin"]); - } catch { + } 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 { @@ -250,9 +250,7 @@ export class GitClient { `Git import failed and the partial repository at "${this.repository}" could not be removed. Delete it before retrying.` ); } - throw new Error( - "Git import failed. Check the source URL, credentials, TLS certificate, and network access." - ); + throw safeImportError(error); } } @@ -453,6 +451,30 @@ export class GitClient { } } +function safeImportError(error: unknown): Error { + const detail = error instanceof Error ? error.message.toLowerCase() : ""; + 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 commitHeader(headers: string[], name: string): string | undefined { return headers .find((line) => line.startsWith(`${name} `)) From dafeb0fd1a9208da22618360e79fb452e78a3c76 Mon Sep 17 00:00:00 2001 From: Leo Ostera Date: Fri, 9 Oct 2026 13:45:06 +0100 Subject: [PATCH 04/12] [miniflare] Verify import credential redaction on failure --- .../test/plugins/artifacts/git-client.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts index 6fd76d6f988..62b2804254b 100644 --- a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts +++ b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts @@ -170,6 +170,24 @@ test("failed imports never remove a pre-existing target", async ({ expect(await readFile(marker, "utf8")).toBe("keep me\n"); }); +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 log rejects invalid pagination before starting a history read", async ({ expect, }) => { From c6ca857d862435569983bd4ab4e2b6aa72e5ada9 Mon Sep 17 00:00:00 2001 From: Leo Ostera Date: Fri, 9 Oct 2026 14:00:29 +0100 Subject: [PATCH 05/12] [miniflare] Stage Git imports before publishing repository paths --- .../src/plugins/artifacts/git-client.ts | 42 ++++++++----- .../test/plugins/artifacts/git-client.spec.ts | 63 ++++++++++++++++++- 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/packages/miniflare/src/plugins/artifacts/git-client.ts b/packages/miniflare/src/plugins/artifacts/git-client.ts index 32bb514e0bd..10fb905d559 100644 --- a/packages/miniflare/src/plugins/artifacts/git-client.ts +++ b/packages/miniflare/src/plugins/artifacts/git-client.ts @@ -1,7 +1,7 @@ import { Buffer } from "node:buffer"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { lstat } from "node:fs/promises"; +import { lstat, mkdtemp, rename } from "node:fs/promises"; import { devNull, tmpdir } from "node:os"; import path from "node:path"; import { removeDir } from "@cloudflare/workers-utils/fs-helpers"; @@ -215,16 +215,11 @@ export class GitClient { branch?: string, depth?: number ): Promise { - // Only clean up directories created by this import. A failed clone into - // an existing path must never remove someone else's repository. - try { - await lstat(this.repository); - throw new Error("Git import target already exists"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - } + await assertImportTargetAvailable(this.repository); + // 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(`${this.repository}.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", "clone", "--bare"]; @@ -234,20 +229,22 @@ export class GitClient { if (depth) { args.push("--depth", String(depth)); } - args.push(url, this.repository); + args.push(url, staged); try { await runGit(args); // 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 this.git(["remote", "remove", "origin"]); + 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(this.repository); + await removeDir(staged); } catch { throw new Error( - `Git import failed and the partial repository at "${this.repository}" could not be removed. Delete it before retrying.` + `Git import failed and the partial repository at "${staged}" could not be removed. Delete it before retrying.` ); } throw safeImportError(error); @@ -451,8 +448,23 @@ export class GitClient { } } +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") || diff --git a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts index 62b2804254b..4ac47fc81b2 100644 --- a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts +++ b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts @@ -1,7 +1,15 @@ import { execFile } from "node:child_process"; -import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +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"; @@ -170,6 +178,59 @@ test("failed imports never remove a pre-existing target", async ({ 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 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"); + expect( + (await readdir(workspace)).some((name) => + name.startsWith("target.git.import-") + ) + ).toBe(false); + } finally { + await writeFile(release, "continue"); + await importing.catch(() => {}); + process.env.PATH = previousPath; + } + } +); + test("failed Git imports clean their target and hide URL credentials", async ({ expect, }) => { From 36b2ffbedda8b3abe8cf81223cce78df2431ad82 Mon Sep 17 00:00:00 2001 From: Leo Ostera Date: Fri, 9 Oct 2026 14:05:02 +0100 Subject: [PATCH 06/12] [miniflare] Keep import credentials out of Git repository config --- .../src/plugins/artifacts/git-client.ts | 46 +++++++++++++++++-- .../test/plugins/artifacts/git-client.spec.ts | 35 ++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/packages/miniflare/src/plugins/artifacts/git-client.ts b/packages/miniflare/src/plugins/artifacts/git-client.ts index 10fb905d559..c6160f05d92 100644 --- a/packages/miniflare/src/plugins/artifacts/git-client.ts +++ b/packages/miniflare/src/plugins/artifacts/git-client.ts @@ -7,7 +7,11 @@ import path from "node:path"; import { removeDir } from "@cloudflare/workers-utils/fs-helpers"; const decoder = new TextDecoder(); -type GitOptions = { allowFailure?: boolean; timeout?: number }; +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 @@ -106,8 +110,14 @@ export async function runGit( 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: gitEnvironment(), + env, stdio: ["pipe", "pipe", "pipe"], }); const stdout: Buffer[] = []; @@ -216,6 +226,7 @@ export class GitClient { depth?: number ): Promise { await assertImportTargetAvailable(this.repository); + const source = importSource(url); // 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. @@ -229,9 +240,9 @@ export class GitClient { if (depth) { args.push("--depth", String(depth)); } - args.push(url, staged); + args.push(source.url, staged); try { - await runGit(args); + 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"]); @@ -448,6 +459,33 @@ export class GitClient { } } +function importSource(url: string): { + url: string; + authorization?: { origin: string; value: string }; +} { + 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 } }; +} + async function assertImportTargetAvailable(repository: string): Promise { try { await lstat(repository); diff --git a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts index 4ac47fc81b2..098f1a0d3c3 100644 --- a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts +++ b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts @@ -231,6 +231,41 @@ test.skipIf(process.platform === "win32")( } ); +test.skipIf(process.platform === "win32")( + "Git clone receives a sanitized URL and ephemeral HTTP authorization", + 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"); + 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}'\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(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, }) => { From 17078510f8872d7d7625922f6e4bba72087d902c Mon Sep 17 00:00:00 2001 From: Leo Ostera Date: Fri, 9 Oct 2026 14:09:29 +0100 Subject: [PATCH 07/12] [miniflare] Model Git object and tree entry kinds explicitly --- .../src/plugins/artifacts/git-client.ts | 49 ++++++++++++++----- .../test/plugins/artifacts/git-client.spec.ts | 8 ++- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/packages/miniflare/src/plugins/artifacts/git-client.ts b/packages/miniflare/src/plugins/artifacts/git-client.ts index c6160f05d92..4f83c2ae395 100644 --- a/packages/miniflare/src/plugins/artifacts/git-client.ts +++ b/packages/miniflare/src/plugins/artifacts/git-client.ts @@ -157,17 +157,33 @@ export interface RepositoryState { refs: Record; } +export interface GitPerson { + name: string; + email: string; +} + export interface CommitMetadata { hash: string; treeHash: string; message: string; - author: { name: string; email: string }; - committer: { name: string; email: 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"; + /** Repository-scoped Git operations; clone/init are supported before it exists. */ export class GitClient { constructor(readonly repository: string) {} @@ -309,12 +325,7 @@ export class GitClient { return { data: Buffer.from(object.stdout).toString("base64") }; } - async readTree(hash: string): Promise | null> { + async readTree(hash: string): Promise { if ((await this.objectType(hash)) !== "tree") { return null; } @@ -419,11 +430,13 @@ export class GitClient { return commits; } - private async objectType(hash: string): Promise { + private async objectType(hash: string): Promise { const result = await this.git(["cat-file", "-t", hash], { allowFailure: true, }); - return result.status === 0 ? decoder.decode(result.stdout).trim() : null; + return result.status === 0 + ? parseGitObjectType(decoder.decode(result.stdout).trim()) + : null; } private async resolveCommit(ref: string): Promise { @@ -525,6 +538,18 @@ function safeImportError(error: unknown): Error { ); } +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} `)) @@ -535,7 +560,7 @@ function parseCommitIdentity( headers: string[], name: string ): { - person: { name: string; email: string }; + person: GitPerson; time: number; } { const match = /^(.*) <([^<>]*)> (-?\d+) [+-]\d{4}$/.exec( @@ -573,7 +598,7 @@ function parseCommit(hash: string, value: string): CommitMetadata { }; } -function treeEntryType(mode: string): string { +function treeEntryType(mode: string): GitTreeEntryType { if (mode === "40000") { return "tree"; } diff --git a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts index 098f1a0d3c3..bd7f8cb17b9 100644 --- a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts +++ b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts @@ -130,8 +130,14 @@ test("Git client imports, reads and forks a local repository", async ({ 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" }, + { 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(); From af8ae50f578a971df360ea94889ade8eb1f85e97 Mon Sep 17 00:00:00 2001 From: Leo Ostera Date: Fri, 9 Oct 2026 14:24:29 +0100 Subject: [PATCH 08/12] [miniflare] Preserve native Git source forms and lock repository publication --- .../src/plugins/artifacts/git-client.ts | 110 ++++++++++++------ .../test/plugins/artifacts/git-client.spec.ts | 50 +++++++- 2 files changed, 121 insertions(+), 39 deletions(-) diff --git a/packages/miniflare/src/plugins/artifacts/git-client.ts b/packages/miniflare/src/plugins/artifacts/git-client.ts index 4f83c2ae395..4427660f766 100644 --- a/packages/miniflare/src/plugins/artifacts/git-client.ts +++ b/packages/miniflare/src/plugins/artifacts/git-client.ts @@ -1,10 +1,11 @@ import { Buffer } from "node:buffer"; import { spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { lstat, mkdtemp, rename } from "node:fs/promises"; +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 = { @@ -184,6 +185,38 @@ export interface GitTreeEntry { 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) {} @@ -241,41 +274,45 @@ export class GitClient { branch?: string, depth?: number ): Promise { - await assertImportTargetAvailable(this.repository); - const source = importSource(url); - // 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(`${this.repository}.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", "clone", "--bare"]; - if (branch) { - args.push("--branch", branch, "--single-branch"); - } - if (depth) { - args.push("--depth", String(depth)); - } - 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"]); + return withRepositoryCreationLock(this.repository, async () => { 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. + const source = importSource(url); + // 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", "clone", "--bare"]; + if (branch) { + args.push("--branch", branch, "--single-branch"); + } + if (depth) { + args.push("--depth", String(depth)); + } + args.push(source.url, staged); 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.` - ); + 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); } - throw safeImportError(error); - } + }); } async configure(generation: string): Promise { @@ -476,6 +513,11 @@ function importSource(url: string): { url: string; authorization?: { origin: string; value: string }; } { + // Native Git also accepts local paths and scp-style remotes. Only parse + // HTTP(S) URLs, where userinfo can otherwise be written into Git config. + if (!/^https?:\/\//i.test(url)) { + return { url }; + } let source: URL; try { source = new URL(url); diff --git a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts index bd7f8cb17b9..58de913ebb3 100644 --- a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts +++ b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts @@ -153,6 +153,30 @@ test("Git client imports, reads and forks a local repository", async ({ expect((await forked.state()).refs["refs/heads/main"]).toBe(commit.hash); }); +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 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, }) => { @@ -217,6 +241,11 @@ test.skipIf(process.platform === "win32")( 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"); @@ -224,10 +253,12 @@ test.skipIf(process.platform === "win32")( "Git import target already exists" ); expect(await readFile(marker, "utf8")).toBe("keep me\n"); + const remaining = await readdir(workspace); expect( - (await readdir(workspace)).some((name) => - name.startsWith("target.git.import-") - ) + remaining.some((name) => name.startsWith(".artifacts-import-")) + ).toBe(false); + expect( + remaining.some((name) => name.startsWith(".artifacts-create-")) ).toBe(false); } finally { await writeFile(release, "continue"); @@ -238,7 +269,7 @@ test.skipIf(process.platform === "win32")( ); test.skipIf(process.platform === "win32")( - "Git clone receives a sanitized URL and ephemeral HTTP authorization", + "Git clone sanitizes HTTPS credentials and preserves scp-style sources", async ({ expect }) => { const workspace = await useTmp(); const bin = path.join(workspace, "bin"); @@ -249,7 +280,7 @@ test.skipIf(process.platform === "win32")( const wrapper = path.join(bin, "git"); await writeFile( wrapper, - `#!/bin/sh\nprintf '%s' "$*" > '${cloneArgs}'\nprintf '%s' "$GIT_CONFIG_KEY_1" > '${configKey}'\nexec '${realGit}' "$@"\n` + `#!/bin/sh\nprintf '%s' "$*" > '${cloneArgs}'\nprintf '%s' "$GIT_CONFIG_KEY_1" > '${configKey}'\ncase " $* " in\n *"git@example.invalid:team/repo.git"*) exit 1 ;;\nesac\nexec '${realGit}' "$@"\n` ); await chmod(wrapper, 0o755); const previousPath = process.env.PATH; @@ -266,6 +297,15 @@ test.skipIf(process.platform === "win32")( expect(await readFile(configKey, "utf8")).toBe( "http.https://127.0.0.1:1/.extraHeader" ); + await expect( + new GitClient(path.join(workspace, "scp.git")).importFrom( + "git@example.invalid:team/repo.git" + ) + ).rejects.toThrow("Git import failed"); + expect(await readFile(cloneArgs, "utf8")).toContain( + "git@example.invalid:team/repo.git" + ); + expect(await readFile(configKey, "utf8")).toBe(""); } finally { process.env.PATH = previousPath; } From 0cc04a38bbc8af3630dde5cee3551057b0bd03e6 Mon Sep 17 00:00:00 2001 From: Leo Ostera Date: Fri, 9 Oct 2026 15:34:04 +0100 Subject: [PATCH 09/12] [miniflare] Bound local import depth and disable remote-ext --- .../src/plugins/artifacts/git-client.ts | 21 ++++++-- .../test/plugins/artifacts/git-client.spec.ts | 48 +++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/miniflare/src/plugins/artifacts/git-client.ts b/packages/miniflare/src/plugins/artifacts/git-client.ts index 4427660f766..84d3308718b 100644 --- a/packages/miniflare/src/plugins/artifacts/git-client.ts +++ b/packages/miniflare/src/plugins/artifacts/git-client.ts @@ -277,6 +277,9 @@ export class GitClient { 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. @@ -285,12 +288,21 @@ export class GitClient { ); // 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", "clone", "--bare"]; + const args = [ + "-c", + "http.followRedirects=false", + "-c", + "protocol.ext.allow=never", + "clone", + "--bare", + ]; if (branch) { args.push("--branch", branch, "--single-branch"); } - if (depth) { - args.push("--depth", String(depth)); + 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 { @@ -515,6 +527,9 @@ function importSource(url: string): { } { // Native Git also accepts local paths and scp-style remotes. Only parse // HTTP(S) URLs, where userinfo can otherwise be written into Git config. + if (/^ext::/i.test(url)) { + throw new Error("Git import protocol ext is not supported"); + } if (!/^https?:\/\//i.test(url)) { return { url }; } diff --git a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts index 58de913ebb3..e458f6b2a2a 100644 --- a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts +++ b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts @@ -165,6 +165,54 @@ test("Git import accepts a native local repository path", async ({ expect((await git(["remote"], target)).stdout).toBe(""); }); +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"); + await expect( + new GitClient(path.join(workspace, "target.git")).importFrom( + `ext::sh -c 'touch ${marker}'` + ) + ).rejects.toThrow("Git import protocol ext 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 }) => { From 4116a6d5c7e3a8260b8f14f3a88fda9948e088d9 Mon Sep 17 00:00:00 2001 From: Leo Ostera Date: Fri, 9 Oct 2026 17:25:44 +0100 Subject: [PATCH 10/12] [miniflare] Address native Git security and fork review --- .../src/plugins/artifacts/git-client.ts | 85 ++++++++++++-- .../test/plugins/artifacts/git-client.spec.ts | 109 +++++++++++++++--- 2 files changed, 169 insertions(+), 25 deletions(-) diff --git a/packages/miniflare/src/plugins/artifacts/git-client.ts b/packages/miniflare/src/plugins/artifacts/git-client.ts index 84d3308718b..f2a81c0179b 100644 --- a/packages/miniflare/src/plugins/artifacts/git-client.ts +++ b/packages/miniflare/src/plugins/artifacts/git-client.ts @@ -43,6 +43,9 @@ export function gitEnvironment( } 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. @@ -75,7 +78,7 @@ export function assertSupportedGitVersion(output: string): void { ? ` 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, verify that \`git --version\` reports 2.32 or newer, then restart your dev server or test runner.` + `Local Artifacts requires Git 2.32 or newer.${found} Upgrade Git, then restart your dev server or test runner.` ); } } @@ -88,7 +91,7 @@ export async function assertGitAvailable(): Promise { version = decoder.decode(result.stdout); } catch (error) { throw new Error( - "Local Artifacts requires Git installed on the host and available on PATH. Install Git 2.32 or newer, verify that `git --version` works, then restart your dev server or test runner.", + "Local Artifacts requires Git 2.32 or newer on PATH. Install Git, then restart your dev server or test runner.", { cause: error } ); } @@ -240,6 +243,19 @@ export class GitClient { 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 } @@ -256,7 +272,17 @@ export class GitClient { this.repository, ]); } else if (defaultBranchOnly) { - // Git cannot clone a named branch that has no commits yet. + // 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([ @@ -293,6 +319,8 @@ export class GitClient { "http.followRedirects=false", "-c", "protocol.ext.allow=never", + "-c", + "protocol.ssh.allow=never", "clone", "--bare", ]; @@ -355,7 +383,7 @@ export class GitClient { refs[name] = oid; } } - const head = await this.git(["symbolic-ref", "--short", "HEAD"], { + const head = await this.git(["symbolic-ref", "HEAD"], { allowFailure: true, }); const defaultBranch = @@ -411,18 +439,25 @@ export class GitClient { if (!commit) { return null; } - const entry = await this.git(["ls-tree", "-z", commit, "--", path], { - allowFailure: true, - }); + 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] !== path || match[1] === "040000") { + 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; } - const object = await this.git(["cat-file", "blob", `${commit}:${path}`], { + // 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 @@ -525,11 +560,24 @@ function importSource(url: string): { url: string; authorization?: { origin: string; value: string }; } { - // Native Git also accepts local paths and scp-style remotes. Only parse - // HTTP(S) URLs, where userinfo can otherwise be written into Git config. + // 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 }; } @@ -556,6 +604,19 @@ function importSource(url: string): { 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, except + // for absolute Windows drive paths. Ordinary local paths remain accepted. + return ( + !path.win32.isAbsolute(source) && + /^[^/]+:/.test(source) && + !/^[a-z][a-z\d+.-]*:\/\//i.test(source) + ); +} + async function assertImportTargetAvailable(repository: string): Promise { try { await lstat(repository); diff --git a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts index e458f6b2a2a..77a6226128f 100644 --- a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts +++ b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts @@ -45,6 +45,7 @@ test("Git subprocesses do not inherit Cloudflare or host credentials", ({ 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", @@ -56,6 +57,8 @@ test("Git subprocesses do not inherit Cloudflare or host credentials", ({ 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 { @@ -153,6 +156,48 @@ test("Git client imports, reads and forks a local repository", async ({ 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, }) => { @@ -202,11 +247,16 @@ test("Git import rejects remote-ext commands before invoking Git", async ({ }) => { const workspace = await useTmp(); const marker = path.join(workspace, "must-not-run"); + const client = new GitClient(path.join(workspace, "target.git")); await expect( - new GitClient(path.join(workspace, "target.git")).importFrom( - `ext::sh -c 'touch ${marker}'` - ) + 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-")) @@ -317,7 +367,7 @@ test.skipIf(process.platform === "win32")( ); test.skipIf(process.platform === "win32")( - "Git clone sanitizes HTTPS credentials and preserves scp-style sources", + "Git clone sanitizes HTTPS credentials and rejects SSH sources", async ({ expect }) => { const workspace = await useTmp(); const bin = path.join(workspace, "bin"); @@ -328,7 +378,7 @@ test.skipIf(process.platform === "win32")( const wrapper = path.join(bin, "git"); await writeFile( wrapper, - `#!/bin/sh\nprintf '%s' "$*" > '${cloneArgs}'\nprintf '%s' "$GIT_CONFIG_KEY_1" > '${configKey}'\ncase " $* " in\n *"git@example.invalid:team/repo.git"*) exit 1 ;;\nesac\nexec '${realGit}' "$@"\n` + `#!/bin/sh\nprintf '%s' "$*" > '${cloneArgs}'\nprintf '%s' "$GIT_CONFIG_KEY_1" > '${configKey}'\nexec '${realGit}' "$@"\n` ); await chmod(wrapper, 0o755); const previousPath = process.env.PATH; @@ -342,18 +392,24 @@ test.skipIf(process.platform === "win32")( 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" ); - await expect( - new GitClient(path.join(workspace, "scp.git")).importFrom( - "git@example.invalid:team/repo.git" - ) - ).rejects.toThrow("Git import failed"); - expect(await readFile(cloneArgs, "utf8")).toContain( - "git@example.invalid:team/repo.git" + 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" ); - expect(await readFile(configKey, "utf8")).toBe(""); } finally { process.env.PATH = previousPath; } @@ -378,6 +434,33 @@ test("failed Git imports clean their target and hide URL credentials", async ({ 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]); + for (const [name, content] of [ + ["README", "plain\n"], + [":README", "colon\n"], + ["[README", "bracket\n"], + ]) { + 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 [ + ["README", "plain\n"], + [":README", "colon\n"], + ["[README", "bracket\n"], + ]) { + 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, }) => { From c1aae98064c662d2eb6b99f556630b33e63baf97 Mon Sep 17 00:00:00 2001 From: Leo Ostera Date: Fri, 9 Oct 2026 17:32:32 +0100 Subject: [PATCH 11/12] [miniflare] Prove partial-clone cleanup and authorization header --- .../test/plugins/artifacts/git-client.spec.ts | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts index 77a6226128f..98fcee186e2 100644 --- a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts +++ b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts @@ -293,6 +293,40 @@ test("failed imports discard partial clones without exposing URL credentials", a 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, }) => { @@ -373,12 +407,13 @@ test.skipIf(process.platform === "win32")( 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}'\nexec '${realGit}' "$@"\n` + `#!/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; @@ -396,6 +431,9 @@ test.skipIf(process.platform === "win32")( 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", From 9f9764e475eece66a4360dc75d7ab81899b4e86f Mon Sep 17 00:00:00 2001 From: Leo Ostera Date: Fri, 9 Oct 2026 19:49:39 +0100 Subject: [PATCH 12/12] [miniflare] Cover Windows paths in Git import tests --- .../src/plugins/artifacts/git-client.ts | 6 ++-- .../test/plugins/artifacts/git-client.spec.ts | 36 ++++++++++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/miniflare/src/plugins/artifacts/git-client.ts b/packages/miniflare/src/plugins/artifacts/git-client.ts index f2a81c0179b..84e80277047 100644 --- a/packages/miniflare/src/plugins/artifacts/git-client.ts +++ b/packages/miniflare/src/plugins/artifacts/git-client.ts @@ -608,10 +608,10 @@ 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, except - // for absolute Windows drive paths. Ordinary local paths remain accepted. + // 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 ( - !path.win32.isAbsolute(source) && + !(process.platform === "win32" && /^[a-z]:/i.test(source)) && /^[^/]+:/.test(source) && !/^[a-z][a-z\d+.-]*:\/\//i.test(source) ); diff --git a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts index 98fcee186e2..fd71803c756 100644 --- a/packages/miniflare/test/plugins/artifacts/git-client.spec.ts +++ b/packages/miniflare/test/plugins/artifacts/git-client.spec.ts @@ -210,6 +210,25 @@ test("Git import accepts a native local repository path", async ({ 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"); @@ -478,21 +497,22 @@ test("Git readFile uses literal paths and the matched blob hash", async ({ const directory = await useTmp(); const repository = path.join(directory, "files"); await git(["init", "--initial-branch=main", repository]); - for (const [name, content] of [ + const files: [string, string][] = [ ["README", "plain\n"], - [":README", "colon\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 [ - ["README", "plain\n"], - [":README", "colon\n"], - ["[README", "bracket\n"], - ]) { + for (const [name, content] of files) { expect((await client.readFile("HEAD", name))?.data).toBe( Buffer.from(content).toString("base64") );