mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-09-21 13:31:42 +00:00
feat(worker): cross-channel CRC-fingerprint repost check for the forward path
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
const candidate = {
|
||||||
|
id: "pkg-1", archiveType: "ZIP", fileName: "a.zip", fileCount: 3, fileSize: 100n,
|
||||||
|
destMessageId: 1n, destMessageIds: [1n], destChannel: { telegramId: 999n },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../db/queries.js", () => ({
|
||||||
|
findFingerprintDedupCandidates: vi.fn(async () => [candidate]),
|
||||||
|
}));
|
||||||
|
const resolveMock = vi.fn(async (..._args: unknown[]) => [{ path: "x", fileName: "x", extension: null, compressedSize: 1n, uncompressedSize: 1n, crc32: "AAAA" }]);
|
||||||
|
const compareMock = vi.fn();
|
||||||
|
vi.mock("../provenance-backfill.js", () => ({
|
||||||
|
resolveCandidateFingerprintEntries: (...args: unknown[]) => resolveMock(...args),
|
||||||
|
compareFingerprints: (...args: unknown[]) => compareMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { checkFingerprintRepost } from "./forward-repost-check.js";
|
||||||
|
import type { FileEntry } from "./zip-reader.js";
|
||||||
|
|
||||||
|
const newEntries: FileEntry[] = [{ path: "x", fileName: "x", extension: null, compressedSize: 1n, uncompressedSize: 1n, crc32: "AAAA" }];
|
||||||
|
|
||||||
|
describe("checkFingerprintRepost", () => {
|
||||||
|
it("reports a duplicate when a candidate's fingerprint matches", async () => {
|
||||||
|
compareMock.mockReturnValueOnce("match");
|
||||||
|
const result = await checkFingerprintRepost({} as never, newEntries, "a.zip", 100n);
|
||||||
|
expect(result).toEqual({ isDuplicate: true, matchedPackageId: "pkg-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no duplicate when no candidate matches", async () => {
|
||||||
|
compareMock.mockReturnValueOnce("mismatch");
|
||||||
|
const result = await checkFingerprintRepost({} as never, newEntries, "a.zip", 100n);
|
||||||
|
expect(result).toEqual({ isDuplicate: false, matchedPackageId: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Client } from "tdl";
|
||||||
|
import type { FileEntry } from "./zip-reader.js";
|
||||||
|
import { compareFingerprints, resolveCandidateFingerprintEntries } from "../provenance-backfill.js";
|
||||||
|
import { findFingerprintDedupCandidates } from "../db/queries.js";
|
||||||
|
|
||||||
|
export interface FingerprintRepostResult {
|
||||||
|
isDuplicate: boolean;
|
||||||
|
matchedPackageId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-channel duplicate check for the forward-priority path: compare the
|
||||||
|
* new archive's CRC fingerprint against every existing Package sharing its
|
||||||
|
* name+size, regardless of which channel or ingestion path produced them.
|
||||||
|
* This is what lets a forwarded copy dedupe against a previously
|
||||||
|
* fully-downloaded copy of the same archive, despite never sharing a
|
||||||
|
* byte-hash-derived contentHash.
|
||||||
|
*/
|
||||||
|
export async function checkFingerprintRepost(
|
||||||
|
client: Client,
|
||||||
|
entries: FileEntry[],
|
||||||
|
fileName: string,
|
||||||
|
fileSize: bigint,
|
||||||
|
): Promise<FingerprintRepostResult> {
|
||||||
|
const candidates = await findFingerprintDedupCandidates(fileName, fileSize);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const candidateEntries = await resolveCandidateFingerprintEntries(client, candidate);
|
||||||
|
if (compareFingerprints(entries, candidateEntries) === "match") {
|
||||||
|
return { isDuplicate: true, matchedPackageId: candidate.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { isDuplicate: false, matchedPackageId: null };
|
||||||
|
}
|
||||||
+47
-22
@@ -1030,6 +1030,30 @@ export interface PlaceholderCandidate {
|
|||||||
destChannel: { telegramId: bigint } | null;
|
destChannel: { telegramId: bigint } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlaceholderRow = {
|
||||||
|
id: string; archiveType: string; fileName: string; fileCount: number; fileSize: bigint;
|
||||||
|
destMessageId: bigint | null; destMessageIds: bigint[]; destChannelId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function enrichWithDestChannel(rows: PlaceholderRow[]): Promise<PlaceholderCandidate[]> {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
const destChannelIds = [...new Set(rows.map((r) => r.destChannelId).filter((id): id is string => !!id))];
|
||||||
|
const channels = destChannelIds.length
|
||||||
|
? await db.telegramChannel.findMany({
|
||||||
|
where: { id: { in: destChannelIds } },
|
||||||
|
select: { id: true, telegramId: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const telegramIdById = new Map(channels.map((c) => [c.id, c.telegramId]));
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id, archiveType: row.archiveType, fileName: row.fileName, fileCount: row.fileCount, fileSize: row.fileSize,
|
||||||
|
destMessageId: row.destMessageId, destMessageIds: row.destMessageIds,
|
||||||
|
destChannel: row.destChannelId && telegramIdById.has(row.destChannelId)
|
||||||
|
? { telegramId: telegramIdById.get(row.destChannelId)! }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find every placeholder Package matching name+size (oldest first). Package
|
* Find every placeholder Package matching name+size (oldest first). Package
|
||||||
* has no direct `destChannel` relation (only the scalar `destChannelId`), so
|
* has no direct `destChannel` relation (only the scalar `destChannelId`), so
|
||||||
@@ -1059,29 +1083,30 @@ export async function findPlaceholderCandidates(
|
|||||||
},
|
},
|
||||||
orderBy: { indexedAt: "asc" },
|
orderBy: { indexedAt: "asc" },
|
||||||
});
|
});
|
||||||
if (rows.length === 0) return [];
|
return enrichWithDestChannel(rows);
|
||||||
|
}
|
||||||
|
|
||||||
const destChannelIds = [...new Set(rows.map((r) => r.destChannelId).filter((id): id is string => !!id))];
|
/**
|
||||||
const channels = destChannelIds.length
|
* Find every uploaded Package (any provenance, any channel) matching
|
||||||
? await db.telegramChannel.findMany({
|
* name+size, for the forward-priority path's cross-channel CRC-fingerprint
|
||||||
where: { id: { in: destChannelIds } },
|
* dedup check. Unlike findPlaceholderCandidates, this is NOT restricted to
|
||||||
select: { id: true, telegramId: true },
|
* placeholder rows — it exists to catch the case where the exact same
|
||||||
})
|
* archive was independently uploaded (not reposted/forwarded) to two
|
||||||
: [];
|
* different source channels.
|
||||||
const telegramIdById = new Map(channels.map((c) => [c.id, c.telegramId]));
|
*/
|
||||||
|
export async function findFingerprintDedupCandidates(
|
||||||
return rows.map((row) => ({
|
fileName: string,
|
||||||
id: row.id,
|
fileSize: bigint,
|
||||||
archiveType: row.archiveType,
|
): Promise<PlaceholderCandidate[]> {
|
||||||
fileName: row.fileName,
|
const rows = await db.package.findMany({
|
||||||
fileCount: row.fileCount,
|
where: { fileName, fileSize, destMessageId: { not: null } },
|
||||||
fileSize: row.fileSize,
|
select: {
|
||||||
destMessageId: row.destMessageId,
|
id: true, archiveType: true, fileName: true, fileCount: true, fileSize: true,
|
||||||
destMessageIds: row.destMessageIds,
|
destMessageId: true, destMessageIds: true, destChannelId: true,
|
||||||
destChannel: row.destChannelId && telegramIdById.has(row.destChannelId)
|
},
|
||||||
? { telegramId: telegramIdById.get(row.destChannelId)! }
|
orderBy: { indexedAt: "asc" },
|
||||||
: null,
|
});
|
||||||
}));
|
return enrichWithDestChannel(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findPlaceholderCandidate(
|
export async function findPlaceholderCandidate(
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ async function resolveDestParts(
|
|||||||
* candidate with fileCount === 0), fall back to a fresh ranged read of the
|
* candidate with fileCount === 0), fall back to a fresh ranged read of the
|
||||||
* candidate's own copy in the destination channel (Task 9).
|
* candidate's own copy in the destination channel (Task 9).
|
||||||
*/
|
*/
|
||||||
async function resolveCandidateFingerprintEntries(
|
export async function resolveCandidateFingerprintEntries(
|
||||||
client: Client,
|
client: Client,
|
||||||
candidate: PlaceholderCandidate,
|
candidate: PlaceholderCandidate,
|
||||||
): Promise<FileEntry[]> {
|
): Promise<FileEntry[]> {
|
||||||
@@ -119,7 +119,7 @@ async function resolveCandidateFingerprintEntries(
|
|||||||
* refute a match — callers must fall back to name+size confidence rather
|
* refute a match — callers must fall back to name+size confidence rather
|
||||||
* than treating this as a mismatch.
|
* than treating this as a mismatch.
|
||||||
*/
|
*/
|
||||||
function compareFingerprints(a: FileEntry[], b: FileEntry[]): "match" | "mismatch" | "incomplete" {
|
export function compareFingerprints(a: FileEntry[], b: FileEntry[]): "match" | "mismatch" | "incomplete" {
|
||||||
const fa = crcFingerprint(a);
|
const fa = crcFingerprint(a);
|
||||||
const fb = crcFingerprint(b);
|
const fb = crcFingerprint(b);
|
||||||
if (!fa.complete || !fb.complete) return "incomplete";
|
if (!fa.complete || !fb.complete) return "incomplete";
|
||||||
|
|||||||
Reference in New Issue
Block a user