import { concatBytes } from "./pktline"; import { fromHex, toHex } from "../crypto"; const encoder = new TextEncoder(); export type GitObjectType = "commit" | "tree" | "blob" | "tag"; export type RawObject = { type: GitObjectType; content: Uint8Array; sha: string }; export type TreeItem = { mode: string; name: string; sha: string }; export type Signature = { name: string; email: string; timestamp: number; timezone: string }; const PACK_TYPE_CODES: Record = { commit: 1, tree: 2, blob: 3, tag: 4 }; const PACK_VERSION = 2; const SIZE_LOW_BITS = 4; const SIZE_LOW_MASK = 0x0f; const SIZE_CONTINUATION_BITS = 7; const SIZE_CONTINUATION_MASK = 0x7f; const CONTINUATION_FLAG = 0x80; export const TREE_MODE = "40000"; async function sha1(bytes: Uint8Array): Promise { return toHex(await crypto.subtle.digest("SHA-1", bytes)); } export async function makeObject(type: GitObjectType, content: Uint8Array): Promise { const header = encoder.encode(`${type} ${content.byteLength}\0`); return { type, content, sha: await sha1(concatBytes([header, content])) }; } export function makeBlob(content: Uint8Array | string): Promise { return makeObject("blob", typeof content === "string" ? encoder.encode(content) : content); } function treeSortKey(item: TreeItem): string { return item.mode === TREE_MODE ? `${item.name}/` : item.name; } function compareBytes(left: string, right: string): number { const leftBytes = encoder.encode(left); const rightBytes = encoder.encode(right); const length = Math.min(leftBytes.byteLength, rightBytes.byteLength); for (let index = 0; index < length; index++) { if (leftBytes[index] !== rightBytes[index]) return leftBytes[index] - rightBytes[index]; } return leftBytes.byteLength - rightBytes.byteLength; } export function makeTree(items: TreeItem[]): Promise { const sorted = [...items].sort((left, right) => compareBytes(treeSortKey(left), treeSortKey(right))); const parts: Uint8Array[] = []; for (const item of sorted) { if (item.name.length === 0 || item.name.includes("\0") || item.name.includes("/")) { throw new Error(`Invalid tree entry name: ${JSON.stringify(item.name)}`); } parts.push(encoder.encode(`${item.mode} ${item.name}\0`), fromHex(item.sha)); } return makeObject("tree", concatBytes(parts)); } const IDENT_CONTROL = /[\u0000-\u001f\u007f]/g; const IDENT_BRACKETS = /[<>]/g; const IDENT_CRUD_EDGES = /^[\s.,:;<>"\\']+|[\s.,:;<>"\\']+$/g; /** * Mirrors git's `strbuf_addstr_without_crud`: an ident may not contain `<`, `>`, newlines or control * characters (which would start a new header or a second mailbox), and leading/trailing punctuation is * dropped. Whitespace runs collapse so the ident stays on one line. */ export function sanitizeIdent(value: string): string { return value .replace(IDENT_CONTROL, " ") .replace(IDENT_BRACKETS, "") .replace(/\s+/g, " ") .replace(IDENT_CRUD_EDGES, ""); } const EMAIL_FORBIDDEN = /[<>\s\u0000-\u001f\u007f]/g; function sanitizeEmail(value: string): string { return value.replace(EMAIL_FORBIDDEN, ""); } const TIMEZONE_PATTERN = /^[+-](?:0\d|1[0-4])[0-5]\d$/; export function formatSignature(signature: Signature): string { const name = sanitizeIdent(signature.name) || "unknown"; const timestamp = Number.isSafeInteger(signature.timestamp) && signature.timestamp >= 0 ? signature.timestamp : 0; return `${name} <${sanitizeEmail(signature.email)}> ${timestamp} ${gitTimezone(signature.timezone)}`; } function normalizeMessage(message: string): string { return message.endsWith("\n") ? message : `${message}\n`; } export function makeCommit(input: { tree: string; parents: string[]; author: Signature; committer: Signature; message: string; }): Promise { const lines = [`tree ${input.tree}`, ...input.parents.map((parent) => `parent ${parent}`)]; lines.push(`author ${formatSignature(input.author)}`, `committer ${formatSignature(input.committer)}`); return makeObject("commit", encoder.encode(`${lines.join("\n")}\n\n${normalizeMessage(input.message)}`)); } export function makeTag(input: { object: string; objectType: GitObjectType; tag: string; tagger: Signature; message: string; }): Promise { const header = [ `object ${input.object}`, `type ${input.objectType}`, `tag ${input.tag}`, `tagger ${formatSignature(input.tagger)}`, ]; return makeObject("tag", encoder.encode(`${header.join("\n")}\n\n${normalizeMessage(input.message)}`)); } async function deflate(content: Uint8Array): Promise { const stream = new Blob([new Uint8Array(content)]).stream().pipeThrough(new CompressionStream("deflate")); return new Uint8Array(await new Response(stream).arrayBuffer()); } function packObjectHeader(type: GitObjectType, size: number): Uint8Array { const bytes: number[] = []; let remaining = Math.floor(size / (1 << SIZE_LOW_BITS)); let current = (PACK_TYPE_CODES[type] << SIZE_LOW_BITS) | (size & SIZE_LOW_MASK); while (remaining > 0) { bytes.push(current | CONTINUATION_FLAG); current = remaining & SIZE_CONTINUATION_MASK; remaining = Math.floor(remaining / (1 << SIZE_CONTINUATION_BITS)); } bytes.push(current); return new Uint8Array(bytes); } function uint32(value: number): Uint8Array { const out = new Uint8Array(4); new DataView(out.buffer).setUint32(0, value); return out; } /** Builds an undeltified version 2 packfile containing the given objects. */ export async function buildPack(objects: RawObject[]): Promise> { const unique = [...new Map(objects.map((object) => [object.sha, object])).values()]; const parts: Uint8Array[] = [encoder.encode("PACK"), uint32(PACK_VERSION), uint32(unique.length)]; for (const object of unique) { parts.push(packObjectHeader(object.type, object.content.byteLength), await deflate(object.content)); } const body = concatBytes(parts); const checksum = fromHex(await sha1(body)); return concatBytes([body, checksum]); } /** A git `+HHMM`/`-HHMM` offset, falling back to UTC for anything malformed. */ export function gitTimezone(value: string | undefined): string { return value && TIMEZONE_PATTERN.test(value) ? value : "+0000"; } export function signatureNow(name: string, email: string, timezone?: string): Signature { return { name: sanitizeIdent(name) || "unknown", email: sanitizeEmail(email), timestamp: Math.floor(Date.now() / 1000), timezone: gitTimezone(timezone), }; }