import { isUnsafeSegment } from "./tree-path"; export { isSafeTreePath } from "./tree-path"; import { basicAuthHeader, forgetRepoToken, repoTokenSecret } from "./artifacts"; import { buildPack, makeBlob, makeCommit, makeTag, makeTree, TREE_MODE } from "./objects"; import type { RawObject, Signature, TreeItem } from "./objects"; import { concatBytes, FLUSH_PKT, packetText, parseAllPackets, pktLine } from "./pktline"; import { invalidateRefs } from "./refs"; import { ZERO_SHA } from "./store"; import type { GitStore } from "./store"; import type { RepoRow } from "~/lib/types"; const FILE_MODE = "100644"; const HTTP_UNAUTHORIZED = 401; const HTTP_FORBIDDEN = 403; export type RefUpdate = { ref: string; oldSha: string; newSha: string }; export class PushRejectedError extends Error { constructor(message: string) { super(message); this.name = "PushRejectedError"; } } type PushRepo = Pick; function parseReport(body: Uint8Array): { unpack: string | null; refs: Map } { let unpack: string | null = null; const refs = new Map(); for (const packet of parseAllPackets(body)) { const line = packetText(packet); if (line === null) continue; if (line.startsWith("unpack ")) unpack = line.slice("unpack ".length); else if (line.startsWith("ok ")) refs.set(line.slice(3), null); else if (line.startsWith("ng ")) { const rest = line.slice(3); const space = rest.indexOf(" "); refs.set(space === -1 ? rest : rest.slice(0, space), space === -1 ? "rejected" : rest.slice(space + 1)); } } return { unpack, refs }; } /** Sends ref updates (and any new objects) to the Artifacts remote over the receive-pack protocol. */ export async function pushToArtifacts(repo: PushRepo, updates: RefUpdate[], objects: RawObject[]): Promise { if (updates.length === 0) return; const commands = updates.map((update, index) => { const line = `${update.oldSha} ${update.newSha} ${update.ref}`; return pktLine(index === 0 ? `${line}\0report-status agent=gitfor\n` : `${line}\n`); }); const onlyDeletes = updates.every((update) => update.newSha === ZERO_SHA); const parts: Uint8Array[] = [...commands, FLUSH_PKT]; if (!onlyDeletes) parts.push(await buildPack(objects)); const body = concatBytes(parts); const send = async () => fetch(`${repo.artifacts_remote}/git-receive-pack`, { method: "POST", headers: { authorization: basicAuthHeader(await repoTokenSecret(repo, "write")), "content-type": "application/x-git-receive-pack-request", accept: "application/x-git-receive-pack-result", "user-agent": "git/gitfor", }, body, }); let response = await send(); if (response.status === HTTP_UNAUTHORIZED || response.status === HTTP_FORBIDDEN) { await response.body?.cancel(); await forgetRepoToken(repo.artifacts_name, "write"); response = await send(); } if (!response.ok) { throw new PushRejectedError(`Push failed with HTTP ${response.status}: ${await response.text()}`); } const report = parseReport(new Uint8Array(await response.arrayBuffer())); if (report.unpack !== null && report.unpack !== "ok") { throw new PushRejectedError(`Remote failed to unpack: ${report.unpack}`); } for (const update of updates) { const error = report.refs.get(update.ref); if (error) throw new PushRejectedError(`${update.ref}: ${error}`); } await invalidateRefs(repo.id); } export type FileChangeInput = { path: string; content: Uint8Array | null; mode?: string; }; type PendingChange = { segments: string[]; content: Uint8Array | null; mode: string }; /** Rewrites a tree with file additions/edits/deletions, returning the new tree SHA and every new object. */ async function rewriteTree( store: GitStore, treeSha: string | null, changes: PendingChange[], objects: RawObject[], ): Promise { const existing = treeSha ? ((await store.tree(treeSha)) ?? []) : []; const items = new Map( existing.map((entry) => [entry.name, { mode: entry.mode, name: entry.name, sha: entry.hash }]), ); const nested = new Map(); for (const change of changes) { const [head, ...rest] = change.segments; if (rest.length === 0) { if (change.content === null) { items.delete(head); } else { const blob = await makeBlob(change.content); objects.push(blob); items.set(head, { mode: change.mode, name: head, sha: blob.sha }); } continue; } const group = nested.get(head) ?? []; group.push({ ...change, segments: rest }); nested.set(head, group); } for (const [name, group] of nested) { const current = items.get(name); const childSha = current && current.mode === TREE_MODE ? current.sha : null; const rewritten = await rewriteTree(store, childSha, group, objects); if (rewritten === null) items.delete(name); else items.set(name, { mode: TREE_MODE, name, sha: rewritten }); } if (items.size === 0) return null; const tree = await makeTree([...items.values()]); objects.push(tree); return tree.sha; } function normalizePath(path: string): string[] { const segments = path.split("/").filter(Boolean); if (segments.length === 0 || segments.some(isUnsafeSegment)) { throw new PushRejectedError(`Invalid path: ${JSON.stringify(path)}`); } return segments; } export async function commitChanges(input: { repo: PushRepo; store: GitStore; branch: string; parentSha: string | null; /** True when `branch` doesn't exist yet and is being created on top of `parentSha`. */ createsBranch?: boolean; changes: FileChangeInput[]; message: string; author: Signature; }): Promise { const parent = input.parentSha ? await input.store.commit(input.parentSha) : null; if (input.parentSha && !parent) throw new PushRejectedError("Parent commit not found"); const objects: RawObject[] = []; const pending = input.changes.map((change) => ({ segments: normalizePath(change.path), content: change.content, mode: change.mode ?? FILE_MODE, })); const rootSha = (await rewriteTree(input.store, parent?.treeSha ?? null, pending, objects)) ?? (await emptyTree(objects)); const commit = await makeCommit({ tree: rootSha, parents: input.parentSha ? [input.parentSha] : [], author: input.author, committer: input.author, message: input.message, }); objects.push(commit); await pushToArtifacts( input.repo, [ { ref: `refs/heads/${input.branch}`, oldSha: input.createsBranch ? ZERO_SHA : (input.parentSha ?? ZERO_SHA), newSha: commit.sha, }, ], objects, ); return commit.sha; } async function emptyTree(objects: RawObject[]): Promise { const tree = await makeTree([]); objects.push(tree); return tree.sha; } export async function createBranch(repo: PushRepo, name: string, sha: string): Promise { await pushToArtifacts(repo, [{ ref: `refs/heads/${name}`, oldSha: ZERO_SHA, newSha: sha }], []); } export async function deleteRef(repo: PushRepo, ref: string, oldSha: string): Promise { await pushToArtifacts(repo, [{ ref, oldSha, newSha: ZERO_SHA }], []); } export async function createTag(input: { repo: PushRepo; name: string; targetSha: string; message: string | null; tagger: Signature; }): Promise { const ref = `refs/tags/${input.name}`; if (!input.message) { await pushToArtifacts(input.repo, [{ ref, oldSha: ZERO_SHA, newSha: input.targetSha }], []); return; } const tag = await makeTag({ object: input.targetSha, objectType: "commit", tag: input.name, tagger: input.tagger, message: input.message, }); await pushToArtifacts(input.repo, [{ ref, oldSha: ZERO_SHA, newSha: tag.sha }], [tag]); } const MAX_REF_NAME_LENGTH = 200; const REF_NAME_PATTERN = /^(?![/.])(?!.*\/\/)(?!.*\.\.)(?!.*\/\.)(?!.*[/.]$)(?!.*\.lock(?:\/|$))(?!.*@\{)[^\s~^:?*[\\\x00-\x1f\x7f]+$/; /** `git check-ref-format` rules for the part after `refs/heads/` or `refs/tags/`. */ export function isValidRefName(name: string): boolean { return ( name.length > 0 && name.length <= MAX_REF_NAME_LENGTH && name !== "@" && REF_NAME_PATTERN.test(name) && !name.startsWith("-") ); }