mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
buildR2Key fell back to a static "uploads" namespace (and "video" slug) when
no torrent info-hash was available, so two distinct URL-first uploads sharing a
filename produced the SAME R2/S3 key — the second silently overwrote the first
object while the first video note still pointed at that URL (data loss).
WebTorrent uploads were already safe because the info-hash namespaces the key.
Add computeStorageContentHash(file) in js/r2.js: when no info-hash is present it
derives a deterministic content-based namespace — full SHA-256 of the bytes for
files ≤512 MB (idempotent: re-uploading the same file reuses the key), and a
SHA-256 fingerprint over name/size/mtime + sampled head/tail bytes above that,
so we never buffer a multi-GB video in memory. Wired into BOTH upload paths
(r2Service + s3UploadService) via a new storageIdentifier kept separate from the
info-hash, so magnet generation is unchanged.
Test: tests/storage-key-collision.test.mjs (distinct same-named uploads get
distinct keys; same content is idempotent; large-file fingerprint path samples
edges without buffering and stays deterministic). Full unit suite green.
test_integrity_note:
change_type: ["new_tests"]
scenarios:
- id: SCN-upload-key-no-collision
given: "a URL-first upload with no torrent info-hash"
when: "the storage key is derived (computeStorageContentHash -> buildR2Key)"
then: "distinct same-named files get distinct keys, same content is idempotent, and no key uses the colliding 'uploads' namespace"
observable_outcomes:
- "returned key strings for real Blob/File content and a fake >512MB file"
determinism_controls:
- "fixed byte content; fake large file with stubbed slice() and fixed lastModified"
anti_cheat_rationale:
prevents:
- "hard-coded return value"
- "over-mocking internal logic"
relaxation:
did_relax_any_assertion: false
Co-Authored-By: Claude Opus 4.8 <[email protected]>
86 lines
3.3 KiB
JavaScript
86 lines
3.3 KiB
JavaScript
// Scenario (SCN-upload-key-no-collision):
|
|
// Given a URL-first upload with NO torrent info-hash available,
|
|
// When the storage key is derived (computeStorageContentHash -> buildR2Key),
|
|
// Then two DISTINCT files that happen to share a filename must produce
|
|
// DIFFERENT keys (so neither overwrites the other — the old `buildR2Key`
|
|
// "uploads" fallback was a silent data-loss path, KNOWN_BUGS upload audit
|
|
// #1), the SAME file must produce a STABLE key (idempotent re-upload), and
|
|
// no key may fall back to the colliding "uploads" namespace.
|
|
//
|
|
// Anti-cheat: asserts externally observable key strings for real Blob/File
|
|
// content (small path) AND a fake >512MB file (sampled-fingerprint path, which
|
|
// must NOT buffer the whole file). No internal call-sequence assertions.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { buildR2Key, computeStorageContentHash } from "../js/r2.js";
|
|
|
|
const NPUB =
|
|
"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsx5h2j";
|
|
|
|
async function keyFor(file) {
|
|
const identifier = await computeStorageContentHash(file);
|
|
return buildR2Key(NPUB, file, identifier);
|
|
}
|
|
|
|
test("distinct same-named URL-first uploads get distinct, non-colliding keys", async () => {
|
|
const a = new File([new Uint8Array([1, 2, 3, 4, 5])], "video.mp4", {
|
|
type: "video/mp4",
|
|
});
|
|
const b = new File([new Uint8Array([5, 4, 3, 2, 1])], "video.mp4", {
|
|
type: "video/mp4",
|
|
});
|
|
|
|
const ka = await keyFor(a);
|
|
const kb = await keyFor(b);
|
|
|
|
assert.notEqual(ka, kb, "distinct content must not share a storage key");
|
|
assert.ok(
|
|
!ka.includes("/uploads/") && !kb.includes("/uploads/"),
|
|
"must not fall back to the colliding 'uploads' namespace",
|
|
);
|
|
});
|
|
|
|
test("same content yields a stable key (idempotent re-upload, no churn)", async () => {
|
|
const bytes = new Uint8Array([7, 7, 7, 9, 9, 9, 1, 2]);
|
|
const a1 = new File([bytes], "clip.mp4", { type: "video/mp4" });
|
|
const a2 = new File([bytes], "clip.mp4", { type: "video/mp4" });
|
|
|
|
assert.equal(
|
|
await keyFor(a1),
|
|
await keyFor(a2),
|
|
"identical content (even as separate File objects) must map to one key",
|
|
);
|
|
});
|
|
|
|
test("large files use a deterministic sampled fingerprint without buffering the whole file", async () => {
|
|
// Fake a >512MB file: slice() returns only small edge blobs, so nothing close
|
|
// to the reported size is ever allocated. Proves the fingerprint branch is
|
|
// both bounded-memory and deterministic.
|
|
const makeBig = (headByte, tailByte) => {
|
|
let bytesRead = 0;
|
|
return {
|
|
name: "big.mp4",
|
|
size: 600 * 1024 * 1024,
|
|
lastModified: 111,
|
|
slice(start) {
|
|
const byte = start === 0 ? headByte : tailByte;
|
|
const chunk = new Uint8Array([byte, byte, byte]);
|
|
bytesRead += chunk.length;
|
|
// Guard against accidental whole-file reads in the implementation.
|
|
assert.ok(bytesRead < 1024 * 1024, "must only sample edges, not buffer the file");
|
|
return new Blob([chunk]);
|
|
},
|
|
};
|
|
};
|
|
|
|
const k1 = await keyFor(makeBig(1, 2));
|
|
const k1dup = await keyFor(makeBig(1, 2));
|
|
const k2 = await keyFor(makeBig(1, 9)); // differing tail sample
|
|
|
|
assert.equal(k1, k1dup, "same metadata + edge samples => stable fingerprint");
|
|
assert.notEqual(k1, k2, "differing content edges => distinct key");
|
|
assert.ok(!k1.includes("/uploads/"), "fingerprint path must not collide on 'uploads'");
|
|
});
|