Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 119 additions & 2 deletions src/git.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import * as childProcess from "node:child_process";
import { execSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { findBaseSha } from "./base-sha";
import { findAnchorAheadOfHead } from "./scan-base";
import { ConfigurationError } from "./provider";
import {
assertGitAvailable,
buildPathspecArgs,
commitExists,
verifyAncestorReachable,
ensureCommitAvailable,
extractBranchName,
extractBranchNameFromMergeMessage,
Expand All @@ -19,8 +24,14 @@ import {
isAncestor,
normalizePathspec,
resolveFirstSyncBoundary,
resolveCommitRef,
} from "./git";

// Keep the real processes while allowing individual tests to observe calls.
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
}));

describe("normalizePathspec", () => {
it("should strip leading ./", () => {
expect(normalizePathspec("./android/**")).toBe("android/**");
Expand Down Expand Up @@ -1326,3 +1337,109 @@ describe("countCommitsInRange", () => {
}
});
});

describe("Git input security", () => {
let cwd: string;
let head: string;
const git = (args: string[]) =>
childProcess.execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }).trim();

beforeAll(() => {
cwd = mkdtempSync(join(tmpdir(), "linear-release-security-"));
git(["init"]);
git([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"Initial",
]);
head = git(["rev-parse", "HEAD"]);
git(["remote", "add", "origin", "https://example.com/repo.git"]);
});

afterAll(() => rmSync(cwd, { recursive: true, force: true }));

it.each(["$(touch MARKER)", "`touch MARKER`", "; touch MARKER; #", "| touch MARKER; #", "\ntouch MARKER\n#"])(
"never executes shell syntax: %s",
(syntax) => {
const checks = [
(value: string) => commitExists(value, cwd),
(value: string) => isAncestor(value, head, cwd),
(value: string) => isAncestor(head, value, cwd),
(value: string) => getCommitParents(value, cwd),
(value: string) => getRemoteUrl(value, cwd),
];
for (const [index, check] of checks.entries()) {
const marker = `injected-${index}`;
check(head + syntax.replace("MARKER", marker));
expect(existsSync(join(cwd, marker))).toBe(false);
}
},
);

it("rejects poisoned release metadata in baseline selection and rollback checks", () => {
const marker = "release-injected";
const candidates = [{ id: "test", name: "test", createdAt: "2026-01-01", commitSha: `$(touch ${marker})${head}` }];
const deps = {
isAncestor: (a: string, b: string) => isAncestor(a, b, cwd),
verifyAncestorReachable: (a: string, b: string) => verifyAncestorReachable(a, b, cwd),
};
expect(findBaseSha(candidates, head, deps)).toEqual({ kind: "fallback" });
expect(findAnchorAheadOfHead(candidates, head, deps)).toBeUndefined();
expect(existsSync(join(cwd, marker))).toBe(false);
});

it.each(["", "HEAD", "--help", "abcdef", "a".repeat(41), "$(touch injected)", "abcdefg"])(
"rejects invalid SHAs before starting Git or fetching: %s",
(invalid) => {
const exec = vi.spyOn(childProcess, "execSync");
const execFile = vi.spyOn(childProcess, "execFileSync");
try {
expect(commitExists(invalid, cwd)).toBe(false);
expect(getCommitParents(invalid, cwd)).toEqual([]);
expect(isAncestor(invalid, head, cwd)).toBe(false);
expect(isAncestor(head, invalid, cwd)).toBe(false);
expect(verifyAncestorReachable(invalid, head, cwd)).toBe(false);
expect(verifyAncestorReachable(head, invalid, cwd)).toBe(false);
// Identical malformed inputs must not pass the equality shortcut.
expect(verifyAncestorReachable(invalid, invalid, cwd)).toBe(false);
expect(() => ensureCommitAvailable(invalid, cwd)).toThrow("Invalid commit SHA format");
expect(exec).not.toHaveBeenCalled();
expect(execFile).not.toHaveBeenCalled();
} finally {
vi.restoreAllMocks();
}
},
);

it("does not interpret a base ref as a fetch option", () => {
const marker = "ref-injected";
git(["remote", "set-url", "origin", cwd]);
try {
expect(() => resolveCommitRef(`--upload-pack=touch ${marker}; git-upload-pack`, cwd)).toThrow(
"Could not resolve",
);
expect(existsSync(join(cwd, marker))).toBe(false);
expect(resolveCommitRef("HEAD", cwd)).toBe(head);
} finally {
git(["remote", "set-url", "origin", "https://example.com/repo.git"]);
}
});

it("preserves valid full and abbreviated SHAs and normal remote lookup", () => {
for (const sha of [head, head.slice(0, 7)]) {
expect(commitExists(sha, cwd)).toBe(true);
expect(isAncestor(sha, head, cwd)).toBe(true);
expect(verifyAncestorReachable(sha, head, cwd)).toBe(true);
expect(() => ensureCommitAvailable(sha, cwd)).not.toThrow();
}
expect(getRemoteUrl("origin", cwd)).toBe("https://example.com/repo.git");
expect(commitExists("0".repeat(40), cwd)).toBe(false);
});
});
55 changes: 35 additions & 20 deletions src/git.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { execFileSync, execSync, spawn } from "node:child_process";
import { execFileSync, spawn } from "node:child_process";
import { ConfigurationError } from "./provider";
import type { CommitContext, GitInfo } from "./types";
import { error as logError, verbose, warn } from "./log";

const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;

/** Preserves a leading "!" while cleaning the path for use as a git pathspec. */
export function normalizePathspec(pattern: string): string {
const trimmed = pattern.trim();
Expand Down Expand Up @@ -58,7 +60,7 @@ export function buildPathspecArgs(includePaths: string[] | null): string[] {
*/
export function assertGitAvailable(cwd: string = process.cwd()): void {
try {
execSync("git --version", {
execFileSync("git", ["--version"], {
cwd,
stdio: ["ignore", "ignore", "pipe"],
});
Expand All @@ -69,7 +71,7 @@ export function assertGitAvailable(cwd: string = process.cwd()): void {
}

try {
execSync("git rev-parse --is-inside-work-tree", {
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
cwd,
stdio: ["ignore", "ignore", "pipe"],
});
Expand All @@ -84,7 +86,7 @@ export function getCurrentGitInfo(cwd: string = process.cwd()): GitInfo {
let message: string | null = null;

try {
branch = execSync("git rev-parse --abbrev-ref HEAD", {
branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
Expand All @@ -94,15 +96,15 @@ export function getCurrentGitInfo(cwd: string = process.cwd()): GitInfo {
} catch {}

try {
commit = execSync("git rev-parse HEAD", {
commit = execFileSync("git", ["rev-parse", "HEAD"], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
}).trim();
} catch {}

try {
message = execSync("git log -1 --pretty=%B", {
message = execFileSync("git", ["log", "-1", "--pretty=%B"], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
Expand Down Expand Up @@ -165,8 +167,11 @@ export function resolveFirstSyncBoundary(currentSha: string, cwd: string = proce
* parents aren't in the local repo. Merges have 2+ entries.
*/
export function getCommitParents(sha: string, cwd: string = process.cwd()): string[] {
if (!SHA_PATTERN.test(sha)) {
return [];
}
try {
const out = execSync(`git log -1 --format=%P ${sha}`, {
const out = execFileSync("git", ["log", "-1", "--format=%P", sha], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
Expand Down Expand Up @@ -196,8 +201,11 @@ export function countCommitsInRange(fromSha: string, toSha: string, cwd: string
}

export function commitExists(sha: string, cwd: string = process.cwd()): boolean {
if (!SHA_PATTERN.test(sha)) {
return false;
}
try {
execSync(`git cat-file -e ${sha}^{commit}`, {
execFileSync("git", ["cat-file", "-e", `${sha}^{commit}`], {
cwd,
stdio: ["ignore", "ignore", "ignore"],
});
Expand All @@ -220,8 +228,11 @@ export function commitExists(sha: string, cwd: string = process.cwd()): boolean
* `verifyAncestorReachable`, which deepens and retries on shallow cutoffs.
*/
export function isAncestor(sha: string, headSha: string, cwd: string = process.cwd()): boolean {
if (!SHA_PATTERN.test(sha) || !SHA_PATTERN.test(headSha)) {
return false;
}
try {
execSync(`git merge-base --is-ancestor ${sha} ${headSha}`, {
execFileSync("git", ["merge-base", "--is-ancestor", sha, headSha], {
cwd,
stdio: ["ignore", "ignore", "ignore"],
});
Expand All @@ -234,7 +245,7 @@ export function isAncestor(sha: string, headSha: string, cwd: string = process.c
/** Returns true if the repository at `cwd` is a shallow clone, false otherwise. */
export function isShallowRepository(cwd: string = process.cwd()): boolean {
try {
const out = execSync("git rev-parse --is-shallow-repository", {
const out = execFileSync("git", ["rev-parse", "--is-shallow-repository"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
Expand All @@ -246,16 +257,16 @@ export function isShallowRepository(cwd: string = process.cwd()): boolean {
}

const DEEPEN_STRATEGIES = [
{ command: "git fetch --deepen=200 origin", label: "Deepening by 200 commits" },
{ command: "git fetch --deepen=500 origin", label: "Deepening by 500 commits" },
{ command: "git fetch --unshallow origin", label: "Fetching full history" },
{ args: ["fetch", "--deepen=200", "origin"], label: "Deepening by 200 commits" },
{ args: ["fetch", "--deepen=500", "origin"], label: "Deepening by 500 commits" },
{ args: ["fetch", "--unshallow", "origin"], label: "Fetching full history" },
];

function deepenUntil(cwd: string, check: () => boolean): boolean {
for (const { command, label } of DEEPEN_STRATEGIES) {
for (const { args, label } of DEEPEN_STRATEGIES) {
verbose(label);
try {
execSync(command, { cwd, stdio: ["ignore", "ignore", "pipe"], timeout: 30_000 });
execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "pipe"], timeout: 30_000 });
} catch (e) {
const reason = e instanceof Error ? e.message : String(e);
verbose(`Strategy "${label}" failed: ${reason}`);
Expand All @@ -280,6 +291,9 @@ function deepenUntil(cwd: string, check: () => boolean): boolean {
* by deepening and retrying.
*/
export function verifyAncestorReachable(sha: string, headSha: string, cwd: string = process.cwd()): boolean {
if (!SHA_PATTERN.test(sha) || !SHA_PATTERN.test(headSha)) {
return false;
}
if (sha === headSha) {
return true;
}
Expand All @@ -303,8 +317,6 @@ export function verifyAncestorReachable(sha: string, headSha: string, cwd: strin
return false;
}

const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;

/**
* Resolves a git ref, tag, or SHA to a full commit SHA.
*
Expand All @@ -315,7 +327,7 @@ const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
*/
export function resolveCommitRef(ref: string, cwd: string = process.cwd()): string {
const resolve = (target: string = ref) =>
execFileSync("git", ["rev-parse", "--verify", `${target}^{commit}`], {
execFileSync("git", ["rev-parse", "--verify", "--end-of-options", `${target}^{commit}`], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
Expand All @@ -330,7 +342,7 @@ export function resolveCommitRef(ref: string, cwd: string = process.cwd()): stri
}
try {
verbose(`Ref "${ref}" not in local history; fetching from origin`);
execFileSync("git", ["fetch", "origin", ref], {
execFileSync("git", ["fetch", "--", "origin", ref], {
cwd,
stdio: ["ignore", "ignore", "ignore"],
timeout: 30_000,
Expand Down Expand Up @@ -421,6 +433,9 @@ export async function getCommitContext(sha: string, cwd: string = process.cwd())
* Throws if the commit cannot be made available (e.g., not on the current branch).
*/
export function ensureCommitAvailable(sha: string, cwd: string = process.cwd()): void {
if (!SHA_PATTERN.test(sha)) {
throw new Error(`Invalid commit SHA format "${sha}"`);
}
if (commitExists(sha, cwd)) {
return;
}
Expand Down Expand Up @@ -604,7 +619,7 @@ export async function getCommitContextsBetweenShas(

export function getRemoteUrl(remote: string = "origin", cwd: string = process.cwd()): string | null {
try {
return execSync(`git remote get-url ${remote}`, {
return execFileSync("git", ["remote", "get-url", "--", remote], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
Expand Down
Loading