import { checkProtections } from "../git/proxy"; import { findForcePushes } from "../git/force-push"; import { isHiddenRef } from "../git/hidden-refs"; import type { RefCommand } from "../git/ref-sync-protocol"; import type { ScreenResult } from "../git/relay"; import { scanPushBody } from "../security/push-protection"; import type { RepoRow } from "~/lib/types"; const ZERO_SHA = "0".repeat(40); const HIDDEN = "refs/pull and refs/gitfor are managed by gitfor"; const FAST_FORWARD_ONLY = "mirror is fast-forward only; an admin can enable force mirroring"; const SECRETS = "push declined due to secrets"; export type MirrorPolicy = { force: boolean; pusherId: string | null }; /** The push gates, injectable so the mirror policy can be tested without a repository store. */ export type MirrorGates = { checkProtections: typeof checkProtections; findForcePushes: typeof findForcePushes; scanPushBody: typeof scanPushBody; }; const DEFAULT_GATES: MirrorGates = { checkProtections, findForcePushes, scanPushBody }; /** Hidden refs are dropped before anything is fetched: a mirror can never write gitfor's bookkeeping refs. */ export function withoutHiddenRefs(commands: RefCommand[]): { commands: RefCommand[]; rejected: Map } { const rejected = new Map(); const kept = commands.filter((command) => { if (!isHiddenRef(command.name)) return true; rejected.set(command.name, HIDDEN); return false; }); return { commands: kept, rejected }; } /** Updates of existing refs (not creations or deletions), the ones that can be non-fast-forward. */ /** Refusals that will repeat on every run until someone changes a rule or the upstream history. */ export function isPolicyRefusal(reason: string): boolean { return ( [HIDDEN, FAST_FORWARD_ONLY, SECRETS, "protected"].some((marker) => reason.includes(marker)) && !reason.includes("could not verify") ); } export function rewritableUpdates(commands: RefCommand[]): RefCommand[] { return commands.filter((command) => command.oldSha && command.oldSha !== ZERO_SHA && command.sha !== ZERO_SHA); } /** * Runs a mirrored receive-pack through the same gates as a git push into the repo: branch protection (as a * write-permission pusher, so a protected branch only accepts a non-fast-forward when its rule allows force * pushes), fast-forward-only unless the admin opted into force mirroring, and push protection's secret scan. * Force mirroring never relaxes push protection: a push that cannot be scanned is refused. * `hasPack` is false when every object already exists, so no packfile is expected. */ export async function screenMirrorPush( repo: RepoRow, commands: RefCommand[], body: ReadableStream, policy: MirrorPolicy, gates: MirrorGates = DEFAULT_GATES, hasPack = true, ): Promise { const updates = commands.map((command) => ({ oldSha: command.oldSha ?? ZERO_SHA, newSha: command.sha, ref: command.name, })); const protection = await gates.checkProtections(repo, "write", updates, body, false); if (protection.violations.size > 0) return { body: protection.body, rejected: protection.violations }; let current = protection.body; if (!policy.force) { const candidates = rewritableUpdates(commands).map((command) => ({ oldSha: command.oldSha ?? ZERO_SHA, newSha: command.sha, ref: command.name, })); if (candidates.length > 0) { const checked = await gates.findForcePushes(repo, candidates, current, false, false); current = checked.body; if (checked.rejected.size > 0) { const reasons = [...checked.rejected].map(([ref, reason]): [string, string] => [ ref, reason.includes("could not verify") ? reason : FAST_FORWARD_ONLY, ]); return { body: current, rejected: new Map(reasons) }; } } } const expectsPack = hasPack && commands.some((command) => command.sha !== ZERO_SHA); const scan = await gates.scanPushBody(repo, policy.pusherId, current, false, expectsPack, false); if (scan.rejection) { return { body: scan.body, rejected: new Map(commands.map((command) => [command.name, SECRETS])) }; } return { body: scan.body, rejected: new Map() }; }