mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
io.atcr.manifest rkeys are the digest alone (digestToRKey), so a single
record backs every repository of a user holding identical content. Both
paths that cascade-delete that record checked for remaining tags scoped to
one repository, which asks the wrong question: a tag in another repo keeps
the shared record alive just as much as a tag in this one.
With me/a:v1 and me/b:v1 at the same digest, deleting me/a:v1 saw no
remaining tags in repo a, deleted the shared PDS record, and purged the
layers on the hold. me/b:v1 was left pointing at content that no longer
exists, and the firehose delete handler then cleared the rows for every
repo (DeleteManifest with an empty repository argument).
The collision predates this, but it was reachable only behind the opt-in
AutoRemoveUntagged profile flag. 1b91768 enabled OCI manifest DELETE and
made TagStore.Untag cascade unconditionally, which turned a latent metadata
collision into blob-level data loss on an ordinary skopeo/crane delete.
- cleanupUntaggedManifest no longer filters candidate tags to
rctx.Repository. The surrounding comment already noted that the tag
collection is account-wide; the filter contradicted it.
- ShouldCascadeDeleteManifest takes the tag question DID-wide via a new
IsManifestTaggedAnyRepo, and drops its now-meaningless repository
parameter.
- IsManifestTagged stays repository-scoped and keeps its caller: the
delete-manifest confirmation prompt is genuinely asking about the one
repo whose tags the user is about to remove.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
6.9 KiB
Go
251 lines
6.9 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// seedCascadeFixture inserts a user and a single manifest. Returns the
|
|
// manifest's row id so callers can attach references (for the multi-arch case).
|
|
func seedCascadeFixture(t *testing.T, db *sql.DB, didStr, repo, digest string) int64 {
|
|
t.Helper()
|
|
|
|
user := &User{
|
|
DID: didStr,
|
|
Handle: "tester.example.com",
|
|
PDSEndpoint: "https://test.pds.example.com",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := UpsertUser(db, user); err != nil {
|
|
t.Fatalf("UpsertUser: %v", err)
|
|
}
|
|
|
|
id, err := InsertManifest(db, &Manifest{
|
|
DID: didStr,
|
|
Repository: repo,
|
|
Digest: digest,
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InsertManifest: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestGetTagDigest_ReturnsDigestForKnownTag(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:tagdigest"
|
|
const repo = "myapp"
|
|
const digest = "sha256:aaa"
|
|
|
|
seedCascadeFixture(t, db, did, repo, digest)
|
|
|
|
if err := UpsertTag(db, &Tag{
|
|
DID: did,
|
|
Repository: repo,
|
|
Tag: "latest",
|
|
Digest: digest,
|
|
CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("UpsertTag: %v", err)
|
|
}
|
|
|
|
got, err := GetTagDigest(db, did, repo, "latest")
|
|
if err != nil {
|
|
t.Fatalf("GetTagDigest: %v", err)
|
|
}
|
|
if got != digest {
|
|
t.Errorf("digest mismatch: got %q want %q", got, digest)
|
|
}
|
|
}
|
|
|
|
func TestGetTagDigest_UnknownTagReturnsErrNoRows(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
seedCascadeFixture(t, db, "did:plc:tagdigest2", "myapp", "sha256:bbb")
|
|
|
|
_, err = GetTagDigest(db, "did:plc:tagdigest2", "myapp", "does-not-exist")
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
t.Errorf("expected sql.ErrNoRows for unknown tag, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestShouldCascadeDeleteManifest_LastTagAndNoParent: the common case —
|
|
// digest has no remaining tags and is not referenced by any manifest list.
|
|
// Cascade should fire.
|
|
func TestShouldCascadeDeleteManifest_LastTagAndNoParent(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:cascade1"
|
|
const repo = "myapp"
|
|
const digest = "sha256:lonely"
|
|
|
|
seedCascadeFixture(t, db, did, repo, digest)
|
|
|
|
// No tags pointing to this digest, no manifest_references entries.
|
|
ok, err := ShouldCascadeDeleteManifest(db, did, digest)
|
|
if err != nil {
|
|
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("expected cascade=true when manifest is untagged and unreferenced")
|
|
}
|
|
}
|
|
|
|
// TestShouldCascadeDeleteManifest_RemainingTagBlocks: another tag still
|
|
// points to this digest → keep the manifest alive.
|
|
func TestShouldCascadeDeleteManifest_RemainingTagBlocks(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:cascade2"
|
|
const repo = "myapp"
|
|
const digest = "sha256:shared"
|
|
|
|
seedCascadeFixture(t, db, did, repo, digest)
|
|
|
|
if err := UpsertTag(db, &Tag{
|
|
DID: did,
|
|
Repository: repo,
|
|
Tag: "v1",
|
|
Digest: digest,
|
|
CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("UpsertTag: %v", err)
|
|
}
|
|
|
|
ok, err := ShouldCascadeDeleteManifest(db, did, digest)
|
|
if err != nil {
|
|
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("expected cascade=false when another tag still points to the digest")
|
|
}
|
|
}
|
|
|
|
// TestShouldCascadeDeleteManifest_TagInOtherRepoBlocks: the same digest is
|
|
// tagged in a *different* repository of the same user. The io.atcr.manifest
|
|
// record is keyed by digest alone, so one record backs both repos — cascading
|
|
// here would delete the record out from under the other repo and purge the
|
|
// shared layers on the hold, breaking an image the user never touched.
|
|
func TestShouldCascadeDeleteManifest_TagInOtherRepoBlocks(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:cascade4"
|
|
const deletingRepo = "myapp"
|
|
const otherRepo = "myapp-mirror"
|
|
const digest = "sha256:sharedacrossrepos"
|
|
|
|
// Same content pushed to two repositories.
|
|
seedCascadeFixture(t, db, did, deletingRepo, digest)
|
|
if _, err := InsertManifest(db, &Manifest{
|
|
DID: did,
|
|
Repository: otherRepo,
|
|
Digest: digest,
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("InsertManifest(otherRepo): %v", err)
|
|
}
|
|
|
|
// Only the other repo still carries a tag. The deleting repo has none.
|
|
if err := UpsertTag(db, &Tag{
|
|
DID: did,
|
|
Repository: otherRepo,
|
|
Tag: "v1",
|
|
Digest: digest,
|
|
CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("UpsertTag: %v", err)
|
|
}
|
|
|
|
ok, err := ShouldCascadeDeleteManifest(db, did, digest)
|
|
if err != nil {
|
|
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("expected cascade=false when the digest is still tagged in another repository")
|
|
}
|
|
}
|
|
|
|
// TestShouldCascadeDeleteManifest_MultiArchChildBlocks: the digest is a child
|
|
// of a manifest list (multi-arch parent). Even with no tags, deleting it
|
|
// would orphan the parent's reference, so we must NOT cascade.
|
|
func TestShouldCascadeDeleteManifest_MultiArchChildBlocks(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:cascade3"
|
|
const repo = "myapp"
|
|
const childDigest = "sha256:amd64child"
|
|
const parentDigest = "sha256:multiarchparent"
|
|
|
|
// Insert the child manifest fixture.
|
|
seedCascadeFixture(t, db, did, repo, childDigest)
|
|
|
|
// Insert a separate parent (manifest list) and attach a manifest_reference
|
|
// from parent → child.
|
|
parentID, err := InsertManifest(db, &Manifest{
|
|
DID: did,
|
|
Repository: repo,
|
|
Digest: parentDigest,
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.index.v1+json",
|
|
CreatedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InsertManifest(parent): %v", err)
|
|
}
|
|
|
|
if err := InsertManifestReference(db, &ManifestReference{
|
|
ManifestID: parentID,
|
|
Digest: childDigest,
|
|
Size: 1234,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
PlatformArchitecture: "amd64",
|
|
PlatformOS: "linux",
|
|
ReferenceIndex: 0,
|
|
}); err != nil {
|
|
t.Fatalf("InsertManifestReference: %v", err)
|
|
}
|
|
|
|
ok, err := ShouldCascadeDeleteManifest(db, did, childDigest)
|
|
if err != nil {
|
|
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("expected cascade=false when digest is a child of a manifest list")
|
|
}
|
|
}
|