diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index cd856a09..33afefe5 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -3,6 +3,7 @@ // gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea // does. +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js"; import { MAIN_BRANCH, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js"; @@ -21,6 +22,19 @@ function commitSegment(sha: unknown): string { if (!isCommit(v)) throw new Refused(`${JSON.stringify(sha)} is not a commit. Nothing was sent`); return v; } +/** git's hash of a tree from its entries: sha1 of "tree \0" and, for each entry in git's order (byte order of the + * name, a tree compared as its name and a slash), " \0" and the 20 raw bytes of its sha. git writes a + * tree's mode 40000 where the forge says 040000. */ +export function gitTreeHash(entries: { path: string; mode: string; type: string; sha: string }[]): string { + const ordered = entries + .map((e) => ({ e, key: Buffer.from(e.path + (e.type === "tree" ? "/" : "")) })) + .sort((a, b) => Buffer.compare(a.key, b.key)); + const body = Buffer.concat(ordered.map(({ e }) => { + if (!/^[0-9a-f]{40}$/i.test(String(e.sha))) throw new Error(`the forge named no sha for ${e.path}`); + return Buffer.concat([Buffer.from(`${String(e.mode).replace(/^0+/, "")} ${e.path}\0`), Buffer.from(e.sha, "hex")]); + })); + return createHash("sha1").update(Buffer.concat([Buffer.from(`tree ${body.length}\0`), body])).digest("hex"); +} export type { BranchProtection, ProtectionWanted } from "./protection.js"; /** A repository, trimmed to what the mesh cares about. */ @@ -522,21 +536,19 @@ export class GiteaClient { async commitTrees(owner: string, repo: string, sha: string): Promise<{ root: string; trees: Record }> { const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`; const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`); - let root = ""; const entries: any[] = []; for (let page = 1; ; page++) { if (page > 200) throw cut("more than 200 pages"); const got = await this.request(`${base}&page=${page}&per_page=1000`); - if (page === 1) { - root = String(got?.sha ?? ""); - if (!/^[0-9a-f]{40,64}$/i.test(root)) throw new Error(`the forge named no root tree for ${owner}/${repo} at ${sha}`); - } if (!Array.isArray(got?.tree)) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} has no tree`); entries.push(...got.tree); if (got.truncated !== true) break; // Truncated with nothing more to ask for: the forge itself could not give the rest. if (got.tree.length === 0 || entries.length >= Number(got.total_count ?? 0)) throw cut("truncated"); } + // **The listing's top `sha` is the commit's, not the root tree's** (seen live, novox/mesh-delivery at 7f61817), so the + // root tree is hashed from the root's own entries, as git does (gitTreeHash). + const root = gitTreeHash(entries.filter((e) => typeof e?.path === "string" && !e.path.includes("/"))); const trees: Record = {}; const dirOf = new Map(); for (const e of entries) if (e?.type === "tree" && typeof e.path === "string") dirOf.set(e.path, String(e.sha)); diff --git a/modules/gitea/test/commit-trees.test.ts b/modules/gitea/test/commit-trees.test.ts index e2a74dc8..51801d7d 100644 --- a/modules/gitea/test/commit-trees.test.ts +++ b/modules/gitea/test/commit-trees.test.ts @@ -4,7 +4,10 @@ import { createServer } from "node:http"; // Fixtures follow Gitea's GitTreeResponse as its API documents it (swagger: sha, url, tree[{path, mode, type, size, // sha, url}], truncated, page, total_count) and as the forge answers GET /repos/{o}/{r}/git/trees/{sha}; the entry -// fields are those, with sha values written out (no live capture: the answer's shape is what matters here). +// fields are those, with sha values written out. The live capture (1234/AC6): GET +// /repos/novox/mesh-delivery/git/trees/7f61817 (non-recursive) answered the COMMIT's sha at the top, not the root +// tree's; the root tree is computed from the root's own entries, as git does. +import { createHash } from "node:crypto"; const COMMIT = "c".repeat(40); const h = (c: string) => c.repeat(40); @@ -25,6 +28,14 @@ const LISTING = [ entry("modules/d/notes.md", "blob", h("b")), ]; +/** git's own hash of a tree from its entries (an independent restatement for the fixtures): mode without leading zero. */ +function gitTree(es: { path: string; type: string; sha: string; mode: string }[]): string { + const ordered = es.map((e) => ({ ...e, key: e.path + (e.type === "tree" ? "/" : "") })).sort((a, b) => Buffer.compare(Buffer.from(a.key), Buffer.from(b.key))); + const body = Buffer.concat(ordered.map((e) => Buffer.concat([Buffer.from(`${e.mode.replace(/^0+/, "")} ${e.path}\0`), Buffer.from(e.sha, "hex")]))); + return createHash("sha1").update(Buffer.concat([Buffer.from(`tree ${body.length}\0`), body])).digest("hex"); +} +const ROOT = gitTree(LISTING.filter((e) => !e.path.includes("/"))); + async function serve(pages: any[]) { const { GiteaClient } = await import("../client.ts"); const asked: string[] = []; @@ -36,7 +47,7 @@ async function serve(pages: any[]) { return res.end("{}"); } const page = Number(url.searchParams.get("page") ?? 1); - res.end(JSON.stringify(pages[page - 1] ?? { sha: h("0"), tree: [], truncated: false, page, total_count: 0 })); + res.end(JSON.stringify(pages[page - 1] ?? { sha: COMMIT, tree: [], truncated: false, page, total_count: 0 })); }); await new Promise((r) => server.listen(0, r)); const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t"); @@ -45,10 +56,10 @@ async function serve(pages: any[]) { test("a commit's trees: the root and the tree of each directory holding a module.json", async () => { // 1234/AC1 and 1234/AC4 - const { client, server } = await serve([{ sha: h("0"), url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); + const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); try { const got = await client.commitTrees("novox", "hq", COMMIT); - assert.deepEqual(got, { root: h("0"), trees: { "": h("0"), "modules/a": h("4"), "modules/b/c": h("8") } }); + assert.deepEqual(got, { root: ROOT, trees: { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") } }); } finally { server.close(); } @@ -57,13 +68,13 @@ test("a commit's trees: the root and the tree of each directory holding a module test("a listing the forge pages is read page by page", async () => { // 1234/AC1 const { client, server, asked } = await serve([ - { sha: h("0"), tree: LISTING.slice(0, 5), truncated: true, page: 1, total_count: LISTING.length }, - { sha: h("0"), tree: LISTING.slice(5, 9), truncated: true, page: 2, total_count: LISTING.length }, - { sha: h("0"), tree: LISTING.slice(9), truncated: false, page: 3, total_count: LISTING.length }, + { sha: COMMIT, tree: LISTING.slice(0, 5), truncated: true, page: 1, total_count: LISTING.length }, + { sha: COMMIT, tree: LISTING.slice(5, 9), truncated: true, page: 2, total_count: LISTING.length }, + { sha: COMMIT, tree: LISTING.slice(9), truncated: false, page: 3, total_count: LISTING.length }, ]); try { const got = await client.commitTrees("novox", "hq", COMMIT); - assert.deepEqual(got.trees, { "": h("0"), "modules/a": h("4"), "modules/b/c": h("8") }); + assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") }); assert.equal(asked.length, 3, `asked ${asked.join(" ")}`); } finally { server.close(); @@ -72,7 +83,7 @@ test("a listing the forge pages is read page by page", async () => { test("a listing the forge says is cut fails, naming the repository and the commit, and answers no trees", async () => { // 1234/AC2 - const { client, server } = await serve([{ sha: h("0"), tree: LISTING, truncated: true, page: 1, total_count: LISTING.length }]); + const { client, server } = await serve([{ sha: COMMIT, tree: LISTING, truncated: true, page: 1, total_count: LISTING.length }]); try { await assert.rejects(client.commitTrees("novox", "hq", COMMIT), (err: Error) => { assert.match(err.message, /novox\/hq/); @@ -134,3 +145,29 @@ test("gitea_open_pulls says each pull request's head branch beside its head sha" server.close(); } }); + +test("the root tree is git's hash of the root's own entries, never the listing's top sha (the commit's)", async () => { + // 1234/AC6, from the live capture named in the header: novox/mesh-delivery at 7f61817. + const commit = "7f618177d6773d8d2d2b9e0b10e13b85ec700c59"; + const listing = [ + entry("README.md", "blob", "a087d22cbcbecce42256315671ff7e4d6ca85a7c"), + entry("merge-check.sh", "blob", "c4ef9a3dd6390b0edde7e4137095553897cecfb0"), + entry("modules", "tree", "31c0abb312ee6ce4693a7aacdd8b3fba871d6cf3"), + entry("modules/x", "tree", h("d")), + entry("modules/x/module.json", "blob", h("e")), + ]; + const { GiteaClient } = await import("../client.ts"); + const server = createServer((req, res) => { + res.end(JSON.stringify(new URL(req.url ?? "", "http://x").pathname.endsWith(`/git/trees/${commit}`) + ? { sha: commit, tree: listing, truncated: false, page: 1, total_count: listing.length } : {})); + }); + await new Promise((r) => server.listen(0, r)); + const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t"); + try { + const got = await client.commitTrees("novox", "mesh-delivery", commit); + assert.equal(got.root, "659bcaaac6c16972ec380267a4eba69d15558c26"); + assert.deepEqual(got.trees, { "modules/x": h("d") }); + } finally { + server.close(); + } +});