import { isAbsolute, normalize } from "node:path"; import { CommandCancelledError, type CommandResult, type CommandRunner, runChecked, } from "./process.js"; export const MAX_BRANCH_NAME_LENGTH = 255; export const MAX_PATH_LENGTH = 4096; export const MAX_LABEL_LENGTH = 120; const WORKSPACE_ID = /^w[0-9A-Za-z]{1,16}$/u; /** Taken from the pattern above, so the schema advertises exactly what the parser accepts. */ export const MIN_WORKSPACE_ID_LENGTH = 2; export const MAX_WORKSPACE_ID_LENGTH = 17; declare const branchNameBrand: unique symbol; declare const revisionBrand: unique symbol; declare const commitIdBrand: unique symbol; declare const absolutePathBrand: unique symbol; declare const labelBrand: unique symbol; declare const workspaceIdBrand: unique symbol; /** The branch name `git check-ref-format --branch` printed, so shorthand is already expanded. */ export type ParsedBranchName = string & { readonly [branchNameBrand]: true }; /** A revision that `git rev-parse --verify` resolved to a commit. */ export type ParsedRevision = string & { readonly [revisionBrand]: true }; /** A full object id that `git rev-parse` printed. */ type ParsedCommitId = string & { readonly [commitIdBrand]: true }; export type ParsedAbsolutePath = string & { readonly [absolutePathBrand]: true }; export type ParsedLabel = string & { readonly [labelBrand]: true }; export type ParsedWorkspaceId = string & { readonly [workspaceIdBrand]: true }; /** * Every value that may become a command argument. A plain string is not a member, so unparsed * text cannot reach a spawned command. */ export type ParsedArgumentValue = | ParsedAbsolutePath | ParsedBranchName | ParsedLabel | ParsedRevision | ParsedWorkspaceId; /** A revision, together with the commit it pointed at when it was parsed. */ export interface ParsedBase { readonly revision: ParsedRevision; readonly commit: ParsedCommitId; } /** Bidirectional marks make text display as other text, and a newline forges a line of output. */ const UNSAFE_CHARACTERS = /[\p{Cc}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/u; const COMMIT_ID = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u; /** * Rejects values that could turn into a command-line option, split an argument, or forge a line * of output before Git or Herdr ever sees them. */ function hasSafeArgumentSyntax(value: unknown, maxLength: number): value is string { return ( typeof value === "string" && value.length > 0 && value.length <= maxLength && value === value.trim() && !value.startsWith("-") && !UNSAFE_CHARACTERS.test(value) ); } /** The runtime re-check of what the parsed types already promise, for the gate before a spawn. */ export function hasParsedArgumentSyntax(value: unknown): value is string { return hasSafeArgumentSyntax(value, MAX_PATH_LENGTH); } export async function parseBranchName( runner: CommandRunner, value: unknown, label: string, cwd: string, signal: AbortSignal | undefined, ): Promise { if (!hasSafeArgumentSyntax(value, MAX_BRANCH_NAME_LENGTH)) { throw new Error(`${label} is not a safe branch name: ${JSON.stringify(value)}.`); } let result: CommandResult; try { result = await runChecked( runner, "git", ["check-ref-format", "--branch", value], { cwd, signal }, `${label} is not a valid Git branch name`, ); } catch (error) { if (error instanceof CommandCancelledError) { throw error; } throw new Error(`${label} is not a valid Git branch name: ${JSON.stringify(value)}.`, { cause: error, }); } // `--branch` expands shorthand such as `@{-1}`, so only the printed name names the real branch. const expanded = result.stdout.trim(); if (!hasSafeArgumentSyntax(expanded, MAX_BRANCH_NAME_LENGTH)) { throw new Error(`${label} did not expand to a safe branch name: ${JSON.stringify(value)}.`); } return expanded as ParsedBranchName; } /** A base for a new worktree only has to resolve to a commit, so tags and SHAs are allowed. */ export async function parseBase( runner: CommandRunner, value: unknown, label: string, cwd: string, signal: AbortSignal | undefined, ): Promise { if (!hasSafeArgumentSyntax(value, MAX_BRANCH_NAME_LENGTH)) { throw new Error(`${label} is not a safe Git revision: ${JSON.stringify(value)}.`); } let result: CommandResult; try { result = await runChecked( runner, "git", ["rev-parse", "--verify", "--quiet", `${value}^{commit}`], { cwd, signal }, `${label} does not resolve to a commit`, ); } catch (error) { if (error instanceof CommandCancelledError) { throw error; } throw new Error(`${label} does not resolve to a commit: ${JSON.stringify(value)}.`, { cause: error, }); } const commit = result.stdout.trim(); if (!COMMIT_ID.test(commit)) { throw new Error(`${label} did not resolve to a commit id: ${JSON.stringify(value)}.`); } return { revision: value as ParsedRevision, commit: commit as ParsedCommitId }; } export function parseAbsolutePath(value: unknown, label: string): ParsedAbsolutePath { if (!hasSafeArgumentSyntax(value, MAX_PATH_LENGTH)) { throw new Error(`${label} is not a safe path: ${JSON.stringify(value)}.`); } if (!isAbsolute(value)) { throw new Error(`${label} must be an absolute path: ${JSON.stringify(value)}.`); } const normalized = normalize(value).replace(/\/+$/u, ""); if (!normalized || !isAbsolute(normalized)) { throw new Error(`${label} does not normalise to an absolute path: ${JSON.stringify(value)}.`); } return normalized as ParsedAbsolutePath; } /** A label is display text, so it only has to stay a single argument on one line. */ export function parseLabel(value: unknown, label: string): ParsedLabel { if (!hasSafeArgumentSyntax(value, MAX_LABEL_LENGTH)) { throw new Error(`${label} must be single-line text without leading options.`); } return value as ParsedLabel; } export function parseWorkspaceId(value: unknown, label: string): ParsedWorkspaceId { if (!hasSafeArgumentSyntax(value, MAX_WORKSPACE_ID_LENGTH) || !WORKSPACE_ID.test(value)) { throw new Error(`${label} is not a Herdr workspace id: ${JSON.stringify(value)}.`); } return value as ParsedWorkspaceId; }