bug fix vector
Build and Push Docker Image / build (push) Failing after 14s

This commit is contained in:
Worph
2026-07-14 16:02:26 +02:00
parent 0ca03ac95a
commit df6a2d78f8
15 changed files with 724 additions and 68 deletions
+86
View File
@@ -0,0 +1,86 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// The per-algorithm CID fields (`cid_sha2-256`, `cid_midhash256`, …) and the
// stored `canonical_cid` scalar were removed (METADATA_KEYS.md §14.13). They
// must be rejected at the write boundary rather than stored.
//
// Why a hard 400 and not a silent rewrite: the reverse-index hook
// (maybeAddAliasFromFieldLocked) only registers an alias for a field named
// `cids/<cid>`. A `cid_*` field was therefore written verbatim and never
// indexed — the record silently became unresolvable by that CID. A loud
// failure is strictly better than a record that looks correct and cannot be
// found.
func TestRejectDeprecatedField(t *testing.T) {
rejected := []string{
"cid_midhash256",
"cid_sha2-256",
"cid_btih_v2",
"cid_crc32",
"midhash256",
"canonical_cid",
}
for _, f := range rejected {
if !rejectDeprecatedField(f) {
t.Errorf("rejectDeprecatedField(%q) = false, want true (deprecated shape)", f)
}
}
accepted := []string{
"cids/bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4",
"filePath",
"title",
"fileinfo/duration",
"tmdb/poster",
// Near-misses that must NOT be caught: the prefix is `cid_`, with an
// underscore. A field merely starting with "cid" is fine.
"cids",
"cidrank",
}
for _, f := range accepted {
if rejectDeprecatedField(f) {
t.Errorf("rejectDeprecatedField(%q) = true, want false (legitimate field)", f)
}
}
}
func TestValidateFields_Rejects400WithSlug(t *testing.T) {
rr := httptest.NewRecorder()
blocked := validateFields(rr, map[string]string{
"title": "Inception",
"cid_midhash256": "bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4",
})
if !blocked {
t.Fatal("validateFields returned false for a payload carrying cid_midhash256; the write would have been stored unindexed")
}
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
}
var body map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
if got := body["error"]; got != ErrDeprecatedField {
t.Errorf("error slug = %v, want %q", got, ErrDeprecatedField)
}
}
func TestValidateFields_AllowsCidsKeyset(t *testing.T) {
rr := httptest.NewRecorder()
blocked := validateFields(rr, map[string]string{
"filePath": "/watch/Inception.mkv",
"cids/bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4": "true",
"cids/baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua": "true",
})
if blocked {
t.Fatalf("validateFields rejected a legitimate cids/ key-set write: %s", rr.Body.String())
}
}
+54
View File
@@ -4,6 +4,7 @@ import (
"crypto/sha256"
"encoding/base32"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
@@ -84,8 +85,45 @@ const (
ErrSchemaViolation = "schema_violation"
ErrStorageUnavailable = "storage_unavailable"
ErrInternal = "internal"
ErrDeprecatedField = "deprecated_field"
)
// rejectDeprecatedField reports whether a field name is one of the removed
// per-algorithm CID shapes (`cid_sha2-256`, `cid_midhash256`, `midhash256`, …).
//
// This is a tripwire, not a compatibility shim. Such a field used to be
// accepted and written verbatim — but the reverse-index hook
// (`maybeAddAliasFromFieldLocked`) only registers aliases for fields named
// `cids/<cid>`, so a `cid_*` field was stored and then NEVER INDEXED: the
// record silently became unresolvable by that CID. Failing the write loudly is
// strictly better than a record that looks fine and cannot be found.
//
// Writers must emit the bare-CID key-set instead: `cids/<bareCid>` = "true".
// See METADATA_KEYS.md §2/§14.13.
func rejectDeprecatedField(field string) bool {
return strings.HasPrefix(field, "cid_") ||
field == "midhash256" ||
field == "canonical_cid"
}
// validateFields rejects a write carrying any deprecated CID field. Returns
// true if the request was rejected (the handler must return immediately).
func validateFields(w http.ResponseWriter, metadata map[string]string) bool {
for field := range metadata {
if rejectDeprecatedField(field) {
writeErrorSlug(w, http.StatusBadRequest, ErrDeprecatedField,
fmt.Sprintf("field %q is a removed per-algorithm CID shape; "+
"write the bare-CID key-set instead: \"cids/<bareCid>\": \"true\" "+
"(a cid_* field would be stored but never indexed, leaving the "+
"record unresolvable by that CID). See METADATA_KEYS.md §2/§14.13.",
field),
false)
return true
}
}
return false
}
// defaultSlugFor maps an HTTP status code to the most likely error slug.
// Handlers that want to override (e.g. /api/meta/{cid} → "unknown_cid"
// instead of "unknown_root" on 404) call writeErrorSlug directly.
@@ -255,6 +293,10 @@ func (s *Server) handlePutMeta(w http.ResponseWriter, r *http.Request) {
return
}
if validateFields(w, metadata) {
return
}
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -620,6 +662,10 @@ func (s *Server) handlePatchMeta(w http.ResponseWriter, r *http.Request) {
return
}
if validateFields(w, metadata) {
return
}
updated, err := s.storage.MergeMetadataFlat(hashID, metadata)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -697,6 +743,10 @@ func (s *Server) handlePutProperty(w http.ResponseWriter, r *http.Request) {
return
}
if validateFields(w, map[string]string{key: ""}) {
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
@@ -790,6 +840,10 @@ func (s *Server) handleAddToSet(w http.ResponseWriter, r *http.Request) {
return
}
if validateFields(w, map[string]string{key: ""}) {
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
+20
View File
@@ -193,6 +193,9 @@ func (s *Server) handleGetMetadataByHashId(w http.ResponseWriter, r *http.Reques
return
}
// Same resolution as the PUT below: callers hold a CID, not the root.
hashID = s.storage.ResolveRoot(hashID)
metadata, err := s.storage.GetMetadataFlat(hashID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -227,6 +230,17 @@ func (s *Server) handleUpdateMetadataByHashId(w http.ResponseWriter, r *http.Req
return
}
// The {hashId} in the URL is whatever CID the caller knows the file by —
// the gateway's auto-store keys this PUT by the hash it just computed
// (dispatch.rs → store_metadata_only), which for a usenet→IPFS fetch is
// the dag-pb root, not the nzb-release cid the record was minted under.
// Without resolving, the write lands on file:<thatCid>/* and forks a
// second root for a file that already has one: a full duplicate record,
// cross-linked and separately indexed, which every downstream ingest then
// sees as two files. Unknown CIDs pass through unchanged, so a genuinely
// new gateway record still mints its root at the hash.
hashID = s.storage.ResolveRoot(hashID)
var metadata map[string]string
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
@@ -263,6 +277,12 @@ func (s *Server) handleDeleteMetadataByHashId(w http.ResponseWriter, r *http.Req
return
}
// meta-gateway retracts a dropped hit by whichever CID it holds
// (meta_core.rs `delete`, which documents this resolution as a
// precondition). Without it, deleting by a non-root CID is a silent
// no-op and the retracted record lingers.
hashID = s.storage.ResolveRoot(hashID)
deletedCount, err := s.storage.DeleteMetadata(hashID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
+153
View File
@@ -0,0 +1,153 @@
{
"$schema": "cid-rank-vectors/v1",
"$comment": [
"CANONICAL golden vectors for the CID utility rank. THE single source of truth.",
"The rank decides which of a record's several CIDs is elected canonical - the cid a",
"record is advertised and addressed by. It is reimplemented in Go, 2x Rust and 3x TS;",
"they cannot share code (independent submodules, no cargo path-dep). These vectors are",
"the only thing holding them in sync: every implementation's test suite loads this file.",
"",
"This file is VENDORED into each consuming submodule (submodule CI cannot read above its",
"own tree). scripts/check-cid-vectors.sh diffs every copy against this one.",
"",
"Ranking is by UTILITY for external retrieval, not cryptographic strength.",
"Ties break on the lexicographically smaller cid, so the election is deterministic",
"across peers and does not churn.",
"",
"See METADATA_KEYS.md section 2/14.13 and docs/cid-formats.md."
],
"ladder": {
"40": "dag-pb (IPFS block)",
"30": "sha2-256 / sha3-256 / sha3-384 - real content digests",
"20": "BitTorrent infohashes - swarm interop",
"10": "midhash256 - the record address",
"5": "opaque locators (nzb-release, url) - never outrank a real digest",
"0": "weak/unknown digests, unparseable input"
},
"tieBreak": "lexicographically smaller cid wins",
"vectors": [
{
"name": "ipfs-dagpb",
"cid": "bafybeiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 40,
"note": "dag-pb codec 0x70 - chunked IPFS file, broadest network reach"
},
{
"name": "sha2-256-raw-codec",
"cid": "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 30,
"note": "fullhash/gateway convention: raw codec 0x55, mh names the algo"
},
{
"name": "sha2-256-codec-eq-mh",
"cid": "baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua",
"rank": 30,
"note": "REAL meta-hash fixture; meta-hash sets codec == mh"
},
{
"name": "sha3-256",
"cid": "baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq",
"rank": 30,
"note": "REAL meta-hash fixture"
},
{
"name": "sha3-384",
"cid": "baekrkmeglipkjf33exqexdvijl4twipruay4wsbvt44akce43lq5rgccxgr7v6dwksmj3sxedakevc53g6ga",
"rank": 30,
"note": "REAL meta-hash fixture"
},
{
"name": "btih-v2-codec-eq-mh",
"cid": "bag3sdnzbeaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 20,
"note": "meta-hash encoder convention"
},
{
"name": "btih-v2-raw-codec",
"cid": "bafk3oijaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 20,
"note": "fullhash encoder convention - SAME tier, both conventions must match"
},
{
"name": "btih-v1-file",
"cid": "bagasbajacqaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 20,
"note": "gateway per-file BitTorrent v1"
},
{
"name": "btih-v2-file",
"cid": "bagbcbaraeaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 20,
"note": "gateway per-file BitTorrent v2"
},
{
"name": "midhash256-codec-eq-mh",
"cid": "bagacbabaeaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 10,
"note": "the record address; internally meaningful, externally invisible"
},
{
"name": "midhash256-raw-codec",
"cid": "bafkyaibaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 10
},
{
"name": "nzb-release-locator",
"cid": "bagcsaaazcbqxa2jonz5gez3fmvvs42lomzx55ln657fp4aab",
"rank": 5,
"note": "codec 0x1005 + IDENTITY multihash 0x00. The locator code is in the CODEC slot, not the mh slot - rank MUST match on codec. Tier 5 = below midhash: a locator never outranks a real digest, it wins only as a record's sole cid."
},
{
"name": "url-locator",
"cid": "bagdcaaaznb2hi4dthixs6zlymfwxa3dffzxxezzpmexhazdg",
"rank": 5,
"note": "codec 0x1006 + IDENTITY multihash 0x00. Same tier/reasoning as nzb-release."
},
{
"name": "sha1",
"cid": "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra",
"rank": 0,
"note": "REAL meta-hash fixture; weak digest"
},
{
"name": "md5",
"cid": "bahkqdvibcatpcfl7ine2n625eb5ml5mzkfaa",
"rank": 0,
"note": "REAL meta-hash fixture; weak digest"
},
{
"name": "crc32",
"cid": "bagzafmqcathsbnrk",
"rank": 0,
"note": "REAL meta-hash fixture; weak digest"
},
{
"name": "bt-pieces-root",
"cid": "bafkyf3qceaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 0,
"note": "torrent-internal merkle root, NOT a file-identity cid"
},
{
"name": "cidv0-rejected",
"cid": "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",
"rank": 0,
"note": "TRAP: Go rejected this (rank 0) while Rust's MsCid::from_str ACCEPTED it and ranked it 40 (dag-pb). Strict parse = CIDv1 base32-lower only. This vector pins the fix."
},
{
"name": "token-form-rejected",
"cid": "midhash256:bagacbabaeaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 0,
"note": "TRAP: the deprecated <algo>:<cid> token form. Rust used to strip the prefix and rank it 10. It must now be rejected."
},
{
"name": "garbage",
"cid": "not-a-cid",
"rank": 0
},
{
"name": "empty",
"cid": "",
"rank": 0
}
]
}
+43 -7
View File
@@ -20,6 +20,14 @@ import (
// Multicodec / multihash codes used for ranking. Source of truth for the
// digest codes is meta-hash/src/lib/hash-compute/MultiHashData.ts and
// METADATA_KEYS.md §2.
//
// The tier every code maps to is pinned by the golden fixture
// `cid-rank-vectors.json` (vendored next to this package). This is one of
// seven implementations of the same ladder across Go, Rust and TypeScript;
// the fixture is the only thing keeping them in agreement. If you add a code
// here, add a vector — an unranked code silently falls to tier 0, which is
// exactly how the nzb-release/url locators came to be ranked 20 in meta-share
// and 0 here and in meta-search.
const (
CodecRaw = 0x55 // raw — IPFS content-addressed block
CodecDagPB = 0x70 // dag-pb — IPFS content-addressed block
@@ -34,6 +42,23 @@ const (
CodeBtihV2 = 0x10B7 // custom — BitTorrent v2 info hash (BEP 52)
CodeBtihV1File = 0x1001 // custom — per-file BitTorrent v1 CID
CodeBtihV2File = 0x1002 // custom — per-file BitTorrent v2 CID
// Opaque locators. These are NOT digests: the payload rides in an identity
// multihash (0x00), so the code identifying them sits in the *content
// codec* slot, never the multihash slot. Rank must match on the codec for
// these — matching on mh would see 0x00 and rank them 0.
CodeNzbRelease = 0x1005 // custom — self-describing Newznab release locator
CodeURL = 0x1006 // custom — identity-multihash CID wrapping an http(s) URL
)
// Rank tiers, named so the ladder reads as a ladder.
const (
RankIPFS = 40 // dag-pb — broadest network reach
RankDigest = 30 // sha2-256 / sha3-* — real content digests
RankBtih = 20 // BitTorrent infohashes — swarm interop
RankMidhash = 10 // the record address — internally meaningful, externally invisible
RankLocator = 5 // opaque locators — never outrank a real digest
RankUnknown = 0 // weak/unknown digests, unparseable input
)
// Decode parses a multibase-base32 CIDv1 string ("b…") into its content
@@ -89,31 +114,42 @@ func Decode(cidStr string) (codec uint64, mhCode uint64, err error) {
// block of the file IS its sha2-256, so they share this tier).
// - BitTorrent infohashes (btih v2, per-file v1/v2) outrank midhash256 for
// swarm interop, not strength.
// - midhash256 is the floor among real options: internally meaningful,
// - midhash256 is the floor among real *digests*: internally meaningful,
// externally invisible.
// - opaque locators (nzb-release, url) sit BELOW midhash. They are not
// content-derived — two URLs to the same bytes yield two different locator
// CIDs — so a locator must never displace a real digest as the identity a
// record is advertised under. Tier 5 means a locator wins the election only
// when it is the record's sole CID, which is exactly the guarantee
// METADATA_KEYS.md §2 states.
// - md5 / crc32 / plain sha1 / pieces-root / unknown rank 0.
func Rank(cidStr string) int {
codec, mh, err := Decode(cidStr)
if err != nil {
return 0
return RankUnknown
}
if codec == CodecDagPB {
return 40
return RankIPFS
}
// BitTorrent: distinct CID codecs (per-file v1/v2) or the btih-v2
// multihash code, in either encoder convention.
if codec == CodeBtihV1File || codec == CodeBtihV2File || codec == CodeBtihV2 ||
mh == CodeBtihV1File || mh == CodeBtihV2File || mh == CodeBtihV2 {
return 20
return RankBtih
}
// Opaque locators. Codec-only: their multihash is identity (0x00), so
// there is nothing to match on the mh side.
if codec == CodeNzbRelease || codec == CodeURL {
return RankLocator
}
switch mh {
case CodeSha256, CodeSha3_256, CodeSha3_384:
return 30
return RankDigest
case CodeMidhash256:
return 10
return RankMidhash
default:
// md5, crc32, plain sha1, pieces-root, unknown.
return 0
return RankUnknown
}
}
+46
View File
@@ -3,10 +3,56 @@ package cid
import (
"encoding/base32"
"encoding/binary"
"encoding/json"
"os"
"strings"
"testing"
)
// goldenVectors is the CANONICAL cross-language fixture: the same file is
// vendored into meta-search, meta-share and meta-hash, and each of their test
// suites asserts against it. It is the only mechanism keeping the seven
// implementations of this ladder in agreement — they live in independent
// submodules and cannot share code. `scripts/check-cid-vectors.sh` at the
// meta-root fails if any vendored copy drifts from the canonical one.
const goldenVectors = "cid-rank-vectors.json"
type vector struct {
Name string `json:"name"`
CID string `json:"cid"`
Rank int `json:"rank"`
Note string `json:"note"`
}
func loadVectors(t *testing.T) []vector {
t.Helper()
raw, err := os.ReadFile(goldenVectors)
if err != nil {
t.Fatalf("read %s: %v (run ./scripts/check-cid-vectors.sh --fix from the meta-root)", goldenVectors, err)
}
var doc struct {
Vectors []vector `json:"vectors"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("parse %s: %v", goldenVectors, err)
}
if len(doc.Vectors) == 0 {
t.Fatalf("%s has no vectors", goldenVectors)
}
return doc.Vectors
}
// TestRank_GoldenVectors is the load-bearing test. Every other test in this
// file is a convenience; this one is the contract with Rust and TypeScript.
func TestRank_GoldenVectors(t *testing.T) {
for _, v := range loadVectors(t) {
if got := Rank(v.CID); got != v.Rank {
t.Errorf("Rank(%s) = %d, want %d\n cid: %s\n note: %s",
v.Name, got, v.Rank, v.CID, v.Note)
}
}
}
// buildCID assembles a multibase-base32 CIDv1 from a content codec, a
// multihash code, and a digest length (digest bytes are zero — only the
// header is under test). Mirrors the encoder in watcher/midhash.go and the
+138
View File
@@ -0,0 +1,138 @@
package storage
import (
"testing"
"github.com/alicebob/miniredis/v2"
)
// newTestClient spins an in-memory Redis and returns a connected Client with
// the given namespace prefix. The server is torn down with the test.
func newTestClient(t *testing.T, prefix string) (*Client, *miniredis.Miniredis) {
t.Helper()
s := miniredis.RunT(t)
c := NewClient(prefix)
if err := c.Connect("redis://" + s.Addr()); err != nil {
t.Fatalf("connect to miniredis: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return c, s
}
// TestClearAllMetadata_PurgesCIDReverseIndex is the regression test for the
// leak: ClearAllMetadata used to delete every file:<root>/* key and the
// file:__index__ set, but left the cid:<cid> → root reverse-index entries
// behind. Those stale aliases are not cosmetic — ResolveRoot consults them, so
// a surviving entry silently redirects a later write into a deleted root.
func TestClearAllMetadata_PurgesCIDReverseIndex(t *testing.T) {
for _, prefix := range []string{"", "mm:"} {
name := "no-prefix"
if prefix != "" {
name = "prefixed"
}
t.Run(name, func(t *testing.T) {
c, s := newTestClient(t, prefix)
// Two records, each with several CID aliases (a midhash plus
// siblings) — exactly what the watcher produces.
aliases := map[string][]string{}
for i, f := range []string{"watch/a.mkv", "watch/b.mkv"} {
uuid, err := c.Mint(f, int64(100+i), int64(1000+i))
if err != nil {
t.Fatalf("mint: %v", err)
}
cids := []string{
"bafkmid" + uuid,
"bafkfull" + uuid,
}
for _, cidStr := range cids {
if err := c.AddAlias(uuid, cidStr); err != nil {
t.Fatalf("add alias %s: %v", cidStr, err)
}
}
aliases[uuid] = cids
}
// An entry already orphaned by a previous (buggy) clear: a
// reverse-index key whose root no longer exists. The sweep is
// expected to collect it too.
if err := s.Set(c.buildCIDIndexKey("bafkorphan"), "01OLDDEADROOT"); err != nil {
t.Fatalf("seed orphan: %v", err)
}
// Sanity: the aliases resolve before the clear.
for uuid, cids := range aliases {
for _, cidStr := range cids {
if got := c.ResolveRoot(cidStr); got != uuid {
t.Fatalf("pre-clear ResolveRoot(%s) = %q, want %q", cidStr, got, uuid)
}
}
}
deleted, err := c.ClearAllMetadata()
if err != nil {
t.Fatalf("ClearAllMetadata: %v", err)
}
// deletedCount counts files, not keys.
if deleted != 2 {
t.Errorf("deletedCount = %d, want 2 (files, not keys)", deleted)
}
// No reverse-index key may survive — not for the cleared records,
// not the pre-existing orphan.
if leaked := s.Keys(); len(leaked) != 0 {
t.Errorf("keys survived the clear: %v", leaked)
}
// And the aliases must no longer resolve: ResolveRoot returns the
// hash unchanged when there is no index entry.
for _, cids := range aliases {
for _, cidStr := range cids {
if got := c.ResolveRoot(cidStr); got != cidStr {
t.Errorf("post-clear ResolveRoot(%s) = %q, want the hash unchanged (stale alias)", cidStr, got)
}
}
}
if got, err := c.GetByCID("bafkorphan"); err != nil || got != "" {
t.Errorf("post-clear GetByCID(bafkorphan) = %q (err %v), want \"\"", got, err)
}
})
}
}
// TestClearAllMetadata_LeavesForeignKeysAlone guards the sweep's blast radius:
// it must key off buildCIDIndexKey (namespace-aware), not a hardcoded "cid:",
// so a clear on a prefixed client cannot reach another namespace's index.
func TestClearAllMetadata_LeavesForeignKeysAlone(t *testing.T) {
c, s := newTestClient(t, "mm:")
uuid, err := c.Mint("watch/a.mkv", 100, 1000)
if err != nil {
t.Fatalf("mint: %v", err)
}
if err := c.AddAlias(uuid, "bafkmine"); err != nil {
t.Fatalf("add alias: %v", err)
}
// Another namespace's reverse-index entry, plus an unrelated key.
if err := s.Set("other:cid:bafktheirs", "01THEIRROOT"); err != nil {
t.Fatalf("seed foreign cid: %v", err)
}
if err := s.Set("unrelated", "keep-me"); err != nil {
t.Fatalf("seed unrelated: %v", err)
}
if _, err := c.ClearAllMetadata(); err != nil {
t.Fatalf("ClearAllMetadata: %v", err)
}
for _, k := range []string{"other:cid:bafktheirs", "unrelated"} {
if !s.Exists(k) {
t.Errorf("clear deleted foreign key %q — the cid sweep is not namespace-aware", k)
}
}
if s.Exists(c.buildCIDIndexKey("bafkmine")) {
t.Errorf("own reverse-index entry survived the clear")
}
}
+39 -3
View File
@@ -821,8 +821,16 @@ func (c *Client) GetMemoryInfo() (string, error) {
return "N/A", nil
}
// ClearAllMetadata deletes all file metadata and index
// Returns the number of files deleted
// ClearAllMetadata deletes all file metadata, the file index, and the CID
// reverse index. Returns the number of files deleted.
//
// The CID reverse index (cid:<cid> → root, see cid_resolution.go) MUST be
// purged too: ResolveRoot consults it, so a surviving cid:<cid> entry pointing
// at a root we just deleted silently redirects the next /meta/<cid> write back
// into that dead root, resurrecting a zombie record under an old identity.
// DeleteRoot already does this per-record; here we sweep the whole prefix
// rather than deriving the CID list per root, which is both simpler and
// self-healing — it also collects entries orphaned by earlier clears.
func (c *Client) ClearAllMetadata() (int64, error) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -870,12 +878,40 @@ func (c *Client) ClearAllMetadata() (int64, error) {
deletedCount++
}
// Delete every CID reverse-index entry (cid:<cid> → root). Swept by
// prefix rather than per-root so entries orphaned by an earlier clear —
// which did not purge this index at all — are collected too. Built via
// buildCIDIndexKey so the configured namespace prefix is honoured.
cidIndexPattern := c.buildCIDIndexKey("*")
var cidIndexDeleted int64
var cidCursor uint64
for {
keys, nextCursor, err := c.client.Scan(ctx, cidCursor, cidIndexPattern, 1000).Result()
if err != nil {
log.Printf("[Storage] Warning: failed to scan %s: %v", cidIndexPattern, err)
break
}
if len(keys) > 0 {
if err := c.client.Del(ctx, keys...).Err(); err != nil {
log.Printf("[Storage] Warning: failed to delete cid index keys: %v", err)
} else {
cidIndexDeleted += int64(len(keys))
}
}
cidCursor = nextCursor
if cidCursor == 0 {
break
}
}
// Delete the index set
if err := c.client.Del(ctx, indexKey).Err(); err != nil {
log.Printf("[Storage] Warning: failed to delete index: %v", err)
}
log.Printf("[Storage] Cleared %d file metadata entries", deletedCount)
log.Printf("[Storage] Cleared %d file metadata entries (%d cid index entries)", deletedCount, cidIndexDeleted)
return deletedCount, nil
}