F-catalog-B: distribution removes a tagged version's tag alone when another tag names its digest

Deleting the manifest by its digest removed every tag on it, and reproducible builds share a digest
between a branch's tagged version and the trunk's numbered one, so a branch's end deleted the trunk's
image (security review F1). The fake catalog now deletes by digest as the store does; a store that
cannot delete a tag alone removes nothing and the removal is refused.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 17:04:46 +01:00
parent 3079b7f115
commit 1bdca045c2
3 changed files with 85 additions and 3 deletions
@@ -89,6 +89,7 @@ type fakeCatalog struct {
tags map[string]string // name:version -> digest
removed []string
mirrorErr error
tagErr error
indexes map[string][]string // name@digest -> the platform manifests it names
}
@@ -104,8 +105,23 @@ func (c *fakeCatalog) Versions(name string) ([]string, error) {
func (c *fakeCatalog) Digest(name, version string) (string, error) {
return c.tags[name+":"+version], nil
}
// Remove deletes by the digest, as the store does: every tag naming the digest goes with it.
func (c *fakeCatalog) Remove(name, version string) error {
c.removed = append(c.removed, name+":"+version)
d := c.tags[name+":"+version]
for k, v := range c.tags {
if n, _, _ := strings.Cut(k, ":"); n == name && v == d {
delete(c.tags, k)
}
}
return nil
}
func (c *fakeCatalog) RemoveTag(name, version string) error {
if c.tagErr != nil {
return c.tagErr
}
c.removed = append(c.removed, name+":"+version+" (tag)")
delete(c.tags, name+":"+version)
return nil
}
@@ -368,6 +384,22 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
if err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.5-feat-x.1", Reason: "branch merged"}); err != nil || b.cat.tags["m/p:0.1.5-feat-x.1"] != "" {
t.Errorf("the builder's removal of a tagged version: %v", err)
}
// Security review F1: a reproducible build gives a branch's tagged version and the trunk's numbered one one
// digest; the tagged one's removal takes its tag alone, and the numbered one stays pullable.
b.cat.tags["m/p:0.1.6-feat-y.1"], b.cat.tags["m/p:0.1.6"] = part.Digest, part.Digest
if err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.6-feat-y.1", Reason: "branch merged"}); err != nil {
t.Fatalf("the removal of a tagged version sharing its digest: %v", err)
}
if b.cat.tags["m/p:0.1.6-feat-y.1"] != "" || b.cat.tags["m/p:0.1.6"] != part.Digest {
t.Errorf("tags after the tagged version's removal: %v; want the tag gone and 0.1.6 still at %s", b.cat.tags, part.Digest)
}
// A store that cannot delete a tag alone removes nothing, and the removal is refused naming the shared digest.
b.cat.tags["m/p:0.1.7-feat-z.1"], b.cat.tags["m/p:0.1.7"] = part.Digest, part.Digest
b.cat.tagErr = errors.New("UNSUPPORTED")
if err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.7-feat-z.1", Reason: "branch merged"}); err == nil || !strings.Contains(err.Error(), "shares its digest") || b.cat.tags["m/p:0.1.7"] != part.Digest {
t.Errorf("a store that cannot delete a tag alone: %v, tags %v; want refused and 0.1.7 kept", err, b.cat.tags)
}
b.cat.tagErr = nil
})
// CAT-AS1: what the sweep did over the store's door is the holder's: a removal for retention of an index removes
// its platform manifests first, but those a tagged or held index of the repository names; a manifest such an index