clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

dependabot/github_actions/actions-9b61906d8b
bindws / test / object / git-backup.test.ts
git-backup.test.ts 7.3K
1import { env, runInDurableObject } from "cloudflare:test";2import { describe, expect, it } from "vitest";3import { encodePack, type GitObject } from "ntig";4import { generateSecretKey } from "nostr-tools/pure";5import { sha256 } from "@noble/hashes/sha2.js";6import { bytesToHex } from "@noble/hashes/utils.js";7import { BACKUP_MAX_BYTES, createBackup, restoreBackup } from "../../src/backups.ts";8import { gitRepository } from "../../src/grasp.ts";9import type { RepositoryAnnouncement } from "../../src/grasp-policy.ts";10import type { Relay } from "../../src/relay.ts";11import { pk, rpc } from "../helpers/relay.ts";1213const text = new TextEncoder();14const digest = (bytes: Uint8Array) => bytesToHex(sha256(bytes));15const hex = (value: string) => { const output = new Uint8Array(value.length / 2); for (let i = 0; i < output.length; i++) output[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16); return output; };16async function oid(type: string, data: Uint8Array) {17 const header = text.encode(`${type} ${data.length}\0`); const input = new Uint8Array(header.length + data.length); input.set(header); input.set(data, header.length);18 return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-1", input)));19}20async function gitCommit(message: string, parent: string | null = null) {21 const blobData = text.encode(`${message}\n`); const blob = await oid("blob", blobData);22 const treeData = new Uint8Array(32); treeData.set(text.encode("100644 file\0")); treeData.set(hex(blob), 12); const tree = await oid("tree", treeData);23 const commitData = text.encode(`tree ${tree}\n${parent ? `parent ${parent}\n` : ""}author Backup <backup@example.com> 1 +0000\ncommitter Backup <backup@example.com> 1 +0000\n\n${message}\n`); const commit = await oid("commit", commitData);24 const objects: GitObject[] = [{ oid: blob, type: "blob", data: blobData }, { oid: tree, type: "tree", data: treeData }, { oid: commit, type: "commit", data: commitData }];25 return { commit, pack: await encodePack(objects) };26}27const repository = (owner: string, identifier: string): RepositoryAnnouncement => ({ id: `repo:${owner}:${identifier}`, owner, identifier, clone: [], relays: [], maintainers: [], private: false });2829async function archiveBytes(relay: Relay, key: string) {30 const object = await relay.media.get(`${relay.slug}/backups/${key}.json`); return new Uint8Array(await object!.arrayBuffer());31}32function updateArchiveHash(archive: Record<string, any>) {33 const copy = structuredClone(archive); copy.manifest.archiveSha256 = ""; archive.manifest.archiveSha256 = digest(new TextEncoder().encode(JSON.stringify(copy)));34}3536describe("SQLite Git backups", () => {37 it("round trips a cataloged member-owned repository and permits further push and replay", async () => {38 const ownerSecret = generateSecretKey(); const owner = pk(ownerSecret); const member = pk(generateSecretKey()); const source = `sql-git-backup-source-${owner.slice(0, 8)}`; const target = `sql-git-backup-target-${owner.slice(0, 8)}`;39 await rpc(`${source}.bind.ws`, ownerSecret, "claim");40 const archive = await runInDurableObject(env.RELAY.getByName(source), async (relay: Relay) => {41 const announcement = repository(member, "member-repo"); const git = await gitRepository(relay, announcement);42 const first = await gitCommit("first"); await git.commit({ id: "first", updates: [{ name: "refs/heads/main", old: null, new: first.commit }], pack: first.pack });43 const second = await gitCommit("second", first.commit); await git.commit({ id: "second", updates: [{ name: "refs/heads/main", old: first.commit, new: second.commit }], pack: second.pack });44 const catalog = relay.sql.exec<{ owner: string; identifier: string }>("SELECT owner,identifier FROM git_sqlite_catalog WHERE repository=?", git.prefix).one();45 expect(catalog).toEqual({ owner: member, identifier: "member-repo" });46 expect(typeof await createBackup(relay, "sql-roundtrip")).not.toBe("string");47 return archiveBytes(relay, "sql-roundtrip");48 });49 const restored = await runInDurableObject(env.RELAY.getByName(target), async (relay: Relay) => {50 relay.slug = `${target}.bind.ws`;51 const result = await restoreBackup(relay, archive, owner);52 expect(typeof result).not.toBe("string");53 const row = relay.sql.exec<{ repository: string; owner: string; identifier: string }>("SELECT c.repository,c.owner,c.identifier FROM git_sqlite_catalog c").one();54 expect(row.owner).toBe(member); expect(row.identifier).toBe("member-repo");55 const git = await gitRepository(relay, repository(member, "member-repo"));56 expect((await git.loadRefs()).refs["refs/heads/main"]).toBeTruthy();57 const replay = await git.commit({ id: "second", updates: [{ name: "refs/heads/main", old: "0".repeat(40), new: "f".repeat(40) }] }).catch((error) => error);58 expect(replay).toBeInstanceOf(Error);59 const oldTip = (await git.loadRefs()).refs["refs/heads/main"];60 const third = await gitCommit("third", oldTip);61 const thirdRequest = { id: "third", updates: [{ name: "refs/heads/main", old: oldTip, new: third.commit }], pack: third.pack };62 await git.commit(thirdRequest);63 expect((await git.commit(thirdRequest)).replayed).toBe(true);64 });65 expect(restored).toBeUndefined();66 });6768 it("rejects rehashed structural tampering and namespace mismatch before target writes", async () => {69 const owner = pk(generateSecretKey()); const source = `sql-git-backup-tamper-source-${owner.slice(0, 8)}`; const target = `sql-git-backup-tamper-target-${owner.slice(0, 8)}`;70 const original = await runInDurableObject(env.RELAY.getByName(source), async (relay: Relay) => {71 relay.settings.update({ owner });72 const git = await gitRepository(relay, repository(owner, "tamper")); const first = await gitCommit("tamper"); await git.commit({ id: "tamper", updates: [{ name: "refs/heads/main", old: null, new: first.commit }], pack: first.pack }); await createBackup(relay, "tamper"); return archiveBytes(relay, "tamper");73 });74 const tampered = JSON.parse(new TextDecoder().decode(original)) as Record<string, any>;75 tampered.sqlGit.repositories[0].key = "f".repeat(64); updateArchiveHash(tampered);76 const damaged = JSON.parse(new TextDecoder().decode(original)) as Record<string, any>;77 const chunk = damaged.sqlGit.repositories[0].objects[0].chunks[0]; const middle = Math.floor(chunk.length / 2);78 damaged.sqlGit.repositories[0].objects[0].chunks[0] = `${chunk.slice(0, middle)}${chunk[middle] === "A" ? "B" : "A"}${chunk.slice(middle + 1)}`; updateArchiveHash(damaged);79 await runInDurableObject(env.RELAY.getByName(target), async (relay: Relay) => {80 relay.slug = `${target}.bind.ws`;81 for (const candidate of [tampered, damaged]) {82 const result = await restoreBackup(relay, text.encode(JSON.stringify(candidate)), owner);83 expect(String(result)).toMatch(/^invalid:/);84 expect(relay.settings.policy.owner).toBe("");85 expect(relay.sql.exec("SELECT 1 FROM git_sqlite_objects LIMIT 1").toArray()).toHaveLength(0);86 }87 });88 });8990 it("rejects a snapshot over the portable size bound before touching a target", async () => {91 const owner = pk(generateSecretKey());92 const result = await runInDurableObject(env.RELAY.getByName("sql-git-backup-size"), async (relay: Relay) => restoreBackup(relay, new Uint8Array(BACKUP_MAX_BYTES + 1), owner));93 expect(result).toMatch(/^restricted: backup exceeds/);94 });95});