Compare commits

...
1 Commits
Author SHA1 Message Date
Worph ebc894fa42 deleting a cids/ member also drops its reverse alias
Build and Push Docker Image / build (push) Failing after 14s
2026-09-16 17:10:00 +00:00
3 changed files with 205 additions and 0 deletions
+36
View File
@@ -157,6 +157,42 @@ func (c *Client) addAliasLocked(ctx context.Context, uuid, cidStr string) error
return nil return nil
} }
// removeAliasLocked is the inverse of addAliasLocked: it drops the reverse-index
// entry cid:<cid> → <uuid>. The caller must already hold c.mu, and must delete
// the cids/<cid> key-set member itself (DeleteProperty does both).
//
// ⚠ Why this exists. A cids/ member write touches THREE keys — the flat
// file:<uuid>/cids/<cid>, the __fields__ index entry, and this reverse alias —
// while the delete path only ever removed the first two. A "deleted" CID
// therefore still resolved: GET /meta/<cid>, ResolveRoot and GetByCID all kept
// answering with the record. The asymmetry is not theoretical. meta-share
// published dag-pb roots computed over RAR volumes and par2 files onto six
// records on watch.nsl.sh; removing the member was expressible, removing the
// claim was not, so the false identity survived its own retraction.
//
// Guarded, not blind: the alias is removed only when it currently points at
// THIS uuid. Two records can legitimately race to claim a cid (see the
// dual-root guard in addAliasLocked, which leaves the first writer's alias in
// place); deleting a member from the loser must not unpick the winner's index.
func (c *Client) removeAliasLocked(ctx context.Context, uuid, cidStr string) error {
if uuid == "" || cidStr == "" {
return nil
}
indexKey := c.buildCIDIndexKey(cidStr)
existing, err := c.client.Get(ctx, indexKey).Result()
if err == redis.Nil {
return nil
}
if err != nil {
return fmt.Errorf("read existing alias: %w", err)
}
if existing != uuid {
// Somebody else owns this alias. Leave it.
return nil
}
return c.client.Del(ctx, indexKey).Err()
}
// ResolveRoot maps a hash supplied by a caller (a meta-sort write, an editor // ResolveRoot maps a hash supplied by a caller (a meta-sort write, an editor
// lookup, an SSE consumer) to the actual storage root key. // lookup, an SSE consumer) to the actual storage root key.
// //
+158
View File
@@ -0,0 +1,158 @@
package storage
import (
"context"
"testing"
)
// Tests for retracting a `cids/<cid>` key-set member (cid_resolution.go's
// removeAliasLocked, reached through DeleteProperty).
//
// Why this is worth its own file. Adding a member writes THREE keys — the flat
// `file:<uuid>/cids/<cid>`, the `__fields__` index entry, and the reverse alias
// `cid:<cid>` → `<uuid>`. Until removeAliasLocked existed, deleting removed only
// the first two, so a retracted CID kept resolving to the record: the member was
// gone from the document and the claim was not.
//
// That asymmetry had a live consequence. meta-share minted dag-pb roots and
// midhash256s over whatever `archive::pick_media` elected, which on an
// obfuscated posting was an arbitrary RAR volume or a par2 file. Six records on
// watch.nsl.sh carry such members. `METADATA_KEYS.md` §2 defines a `cids/`
// member as a digest of *this record's file* and notes that every member is a
// reverse index back to the record — so those entries assert that an archive
// volume is the episode. Retracting one has to remove the assertion, not just
// the row.
// aliasTarget returns the uuid that cid:<cidStr> currently resolves to, or "".
func aliasTarget(t *testing.T, c *Client, cidStr string) string {
t.Helper()
got, err := c.client.Get(context.Background(), c.buildCIDIndexKey(cidStr)).Result()
if err != nil {
return ""
}
return got
}
func hasField(t *testing.T, c *Client, hashID, field string) bool {
t.Helper()
ok, err := c.client.SIsMember(context.Background(), c.buildFieldsKey(hashID), field).Result()
if err != nil {
t.Fatalf("sismember: %v", err)
}
return ok
}
// The real shape: `…glnrqztt…` is the nzb-release locator whose record published
// a dag-pb root computed over volume 3 of a 35-volume RAR5 set.
const (
poisonedRoot = "bafybeicicuzdvuztca2hp2g2k7scgr4nnibilzgoppljeprphybqot7v6i"
goodMidhash = "bagacbabaed6uiulnv7tscpxne22eztkjvnxg5almn43dpfecppixarfe7xhlu"
)
func TestRetractingACidMemberRemovesTheClaimNotJustTheRow(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
const uuid = "01KSBHMTESTRECORD"
if err := c.SetMetadataFlat(uuid, map[string]string{
"cids/" + poisonedRoot: "true",
"cids/" + goodMidhash: "true",
"fileType": "video",
}); err != nil {
t.Fatalf("seed: %v", err)
}
// All three writes landed, and the CID resolves to the record.
if got := aliasTarget(t, c, poisonedRoot); got != uuid {
t.Fatalf("alias should point at the record before retraction, got %q", got)
}
if !hasField(t, c, uuid, "cids/"+poisonedRoot) {
t.Fatal("field index should list the member before retraction")
}
if got := c.ResolveRoot(poisonedRoot); got != uuid {
t.Fatalf("ResolveRoot should find the record before retraction, got %q", got)
}
if err := c.DeleteProperty(uuid, "cids/"+poisonedRoot); err != nil {
t.Fatalf("DeleteProperty: %v", err)
}
// 1. the flat key
if v, err := c.client.Get(context.Background(),
c.buildKeyPrefix(uuid)+"cids/"+poisonedRoot).Result(); err == nil {
t.Fatalf("flat key should be gone, still %q", v)
}
// 2. the field index
if hasField(t, c, uuid, "cids/"+poisonedRoot) {
t.Fatal("field index still lists the retracted member")
}
// 3. the reverse alias — the one that used to survive
if got := aliasTarget(t, c, poisonedRoot); got != "" {
t.Fatalf("reverse alias survived retraction, still points at %q", got)
}
if got := c.ResolveRoot(poisonedRoot); got == uuid {
t.Fatal("a retracted cid must not resolve to the record any more")
}
// The siblings are untouched: retraction is surgical, not a purge.
if got := aliasTarget(t, c, goodMidhash); got != uuid {
t.Fatalf("an unrelated sibling lost its alias, got %q", got)
}
if !hasField(t, c, uuid, "cids/"+goodMidhash) {
t.Fatal("an unrelated sibling lost its field-index entry")
}
if v, err := c.GetProperty(uuid, "fileType"); err != nil || v != "video" {
t.Fatalf("an unrelated field changed: %q %v", v, err)
}
})
}
// Two records can race to claim one cid; addAliasLocked leaves the first
// writer's alias in place. Retracting the loser's member must not unpick the
// winner's index — which is why removeAliasLocked compares before deleting.
func TestRetractingDoesNotStealAnotherRecordsAlias(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
const winner = "01KSBHMWINNER"
const loser = "01KSBHMLOSER"
const shared = "bafybeiaeb2lqi4z3np3q4tarldtyjp2t5lvnoodmtpvpi2tz73azn5td2m"
if err := c.SetMetadataFlat(winner, map[string]string{"cids/" + shared: "true"}); err != nil {
t.Fatalf("seed winner: %v", err)
}
if err := c.SetMetadataFlat(loser, map[string]string{"cids/" + shared: "true"}); err != nil {
t.Fatalf("seed loser: %v", err)
}
if got := aliasTarget(t, c, shared); got != winner {
t.Fatalf("the first writer should own the alias, got %q", got)
}
if err := c.DeleteProperty(loser, "cids/"+shared); err != nil {
t.Fatalf("DeleteProperty: %v", err)
}
if got := aliasTarget(t, c, shared); got != winner {
t.Fatalf("retracting the loser's member stole the winner's alias, now %q", got)
}
if hasField(t, c, loser, "cids/"+shared) {
t.Fatal("the loser's own member should still be gone")
}
})
}
// A non-cids field has no alias to clean up, and the delete must not invent one.
func TestRetractingAnOrdinaryFieldTouchesNoAlias(t *testing.T) {
forEachPrefix(t, func(t *testing.T, c *Client) {
const uuid = "01KSBHMORDINARY"
if err := c.SetMetadataFlat(uuid, map[string]string{
"cids/" + goodMidhash: "true",
"quality": "1080p",
}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := c.DeleteProperty(uuid, "quality"); err != nil {
t.Fatalf("DeleteProperty: %v", err)
}
if got := aliasTarget(t, c, goodMidhash); got != uuid {
t.Fatalf("deleting an ordinary field disturbed a cid alias, got %q", got)
}
})
}
+11
View File
@@ -460,6 +460,10 @@ func (c *Client) MergeMetadataFlat(hashID string, metadata map[string]string) (i
// DeleteProperty deletes a single property // DeleteProperty deletes a single property
// Uses flat key: DEL file:{hashId}/{property} // Uses flat key: DEL file:{hashId}/{property}
//
// For a cids/<cid> member this is the full inverse of the write: the flat key,
// the __fields__ entry AND the cid:<cid> reverse alias all go. Anything less
// leaves the CID still resolving to this record — see removeAliasLocked.
func (c *Client) DeleteProperty(hashID, property string) error { func (c *Client) DeleteProperty(hashID, property string) error {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -475,6 +479,13 @@ func (c *Client) DeleteProperty(hashID, property string) error {
if err := c.client.Del(ctx, key).Err(); err != nil { if err := c.client.Del(ctx, key).Err(); err != nil {
return err return err
} }
// Before the field index forgets the name, use it: a cids/ member owns a
// reverse-index entry that nothing else will ever clean up.
if cidStr := cidFromKeysetField(property); cidStr != "" {
if err := c.removeAliasLocked(ctx, hashID, cidStr); err != nil {
return fmt.Errorf("remove alias for %s: %w", cidStr, err)
}
}
c.removeFieldLocked(ctx, hashID, property) c.removeFieldLocked(ctx, hashID, property)
c.markDirtyLocked(ctx, hashID) c.markDirtyLocked(ctx, hashID)
return nil return nil