diff --git a/worker/src/archive/forward-identity.test.ts b/worker/src/archive/forward-identity.test.ts new file mode 100644 index 0000000..5d46a90 --- /dev/null +++ b/worker/src/archive/forward-identity.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { createHash } from "crypto"; +import { deriveForwardContentHash } from "./forward-identity.js"; +import type { FileEntry } from "./zip-reader.js"; + +function entry(crc32: string | null): FileEntry { + return { path: "a", fileName: "a", extension: null, compressedSize: 1n, uncompressedSize: 1n, crc32 }; +} + +describe("deriveForwardContentHash", () => { + it("hashes the sorted CRC list when all entries have a CRC32 (ZIP/RAR)", () => { + const entries = [entry("BBBB"), entry("AAAA")]; + const expectedHash = createHash("sha256").update(["aaaa", "bbbb"].join(",")).digest("hex"); + expect(deriveForwardContentHash(entries, "unique-1", "chan-1", 42n)).toBe(`fingerprint:${expectedHash}`); + }); + + it("falls back to remoteUniqueId when CRCs are incomplete (7z today)", () => { + const entries = [entry(null), entry("AAAA")]; + expect(deriveForwardContentHash(entries, "unique-42", "chan-1", 42n)).toBe("forward:unique-42"); + }); + + it("falls back to sourceChannelId+sourceMessageId when there's no CRC and no remoteUniqueId", () => { + const entries = [entry(null)]; + expect(deriveForwardContentHash(entries, null, "chan-1", 42n)).toBe("forward:chan-1:42"); + }); + + it("falls back past an empty entries list the same way", () => { + expect(deriveForwardContentHash([], null, "chan-1", 7n)).toBe("forward:chan-1:7"); + }); +}); diff --git a/worker/src/archive/forward-identity.ts b/worker/src/archive/forward-identity.ts new file mode 100644 index 0000000..da11a91 --- /dev/null +++ b/worker/src/archive/forward-identity.ts @@ -0,0 +1,33 @@ +import { createHash } from "crypto"; +import { crcFingerprint } from "./fingerprint.js"; +import type { FileEntry } from "./zip-reader.js"; + +/** + * Derive a Package.contentHash-compatible identity string for a forward-path + * package (no downloaded bytes exist to hash directly). Priority order: + * 1. A CRC32-fingerprint hash, when the ranged listing's CRCs are complete + * (ZIP/RAR today) — the strongest available signal, since it lets + * forward-path and download-path copies of the same archive still + * collide/dedupe on identical content. + * 2. TDLib's remote.unique_id, when CRCs are incomplete (7z today has none). + * 3. sourceChannelId+sourceMessageId, as a last-resort unique value so the + * required-unique Package.contentHash column is always satisfiable. + * Follows the same `:` synthetic-hash convention already used + * by `rebuild.ts`'s `rebuild:${destChannelId}:${destMessageId}` placeholder. + */ +export function deriveForwardContentHash( + entries: FileEntry[], + remoteUniqueId: string | null, + sourceChannelId: string, + sourceMessageId: bigint, +): string { + const fp = crcFingerprint(entries); + if (fp.complete && fp.crcs.length > 0) { + const hash = createHash("sha256").update(fp.crcs.join(",")).digest("hex"); + return `fingerprint:${hash}`; + } + if (remoteUniqueId) { + return `forward:${remoteUniqueId}`; + } + return `forward:${sourceChannelId}:${sourceMessageId}`; +}