Files
at-container-registry/pkg/appview/storage/notify_subject_test.go
T
Evan JarrettandClaude Opus 5 b4fccce4d1 hold: actually send subject, so the attestation scan guard can fire
The hold declines to enqueue a scan when a pushed manifest has a subject, which
is how it means to skip attestations, signatures and other referrer artifacts.
The AppView never sent one: notifyHoldAboutManifest built mediaType, config,
layers and manifests, and #manifestInfo defined only those four. So the
condition was always true, the guard never fired, and every referrer artifact
was enqueued for scanning.

The AppView already parsed subject. NewManifestRecord unmarshals it into
ManifestRecord.Subject, and Put hands that same pointer to
notifyHoldAboutManifest. The value was in scope and simply never serialized, so
this is one missing marshal step rather than a missing parse.

Adds subject to the lexicon as a #blobInfo ref, and mediaType to #blobInfo,
which config has always sent and the hold has always parsed. That only makes the
schema honest about what is already on the wire.

Hoists the hold's anonymous request struct to a named type with IsMultiArch,
IsReferrer and HasScannableContent, so the predicate is written once and
testable without standing up a HoldPDS.

Both directions degrade safely. An older hold ignores the unknown key and
behaves exactly as today, so shipping the appview alone is harmless but achieves
nothing until the hold catches up. An older appview sends no subject, leaving
the manifest enqueued as before.

Complements dfd604b rather than duplicating it. That guard lives in the scanner
after a job is created and dispatched, and catches unscannable work from any
source including the hold's proactive discovery pass. This one stops the row
being created at all, which matters because the row that froze all scanning for
nine days was exactly such an attestation. One gap neither closes: an
attestation with tar-shaped layers pushed by an old appview.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
2026-09-02 22:31:04 -05:00

128 lines
4.2 KiB
Go

package storage
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"atcr.io/pkg/atproto"
)
// captureNotify stands in for the hold's notifyManifest endpoint and returns the
// decoded request body the AppView sent.
func captureNotify(t *testing.T, record *atproto.ManifestRecord) map[string]any {
t.Helper()
var captured map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
}
if err := json.Unmarshal(body, &captured); err != nil {
t.Errorf("unmarshal body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"operation":"push","statsUpdated":true}`))
}))
defer srv.Close()
store := NewManifestStore(&RegistryContext{
Repository: "myapp",
DID: "did:plc:alice123",
Handle: "alice.test",
HoldURL: srv.URL,
ServiceToken: "service-token",
}, nil)
if err := store.notifyHoldAboutManifest(context.Background(), record, "latest", "sha256:deadbeef", "push"); err != nil {
t.Fatalf("notifyHoldAboutManifest: %v", err)
}
if captured == nil {
t.Fatal("hold received no notification")
}
return captured
}
func manifestInfoFrom(t *testing.T, payload map[string]any) map[string]any {
t.Helper()
info, ok := payload["manifest"].(map[string]any)
if !ok {
t.Fatalf("payload has no manifest object: %#v", payload)
}
return info
}
// TestNotifyHold_IncludesSubjectForReferrer verifies the notify payload carries
// the subject descriptor when the pushed manifest is a referrer artifact. Without
// it the hold's attestation guard (which tests for a nil subject) can never fire.
func TestNotifyHold_IncludesSubjectForReferrer(t *testing.T) {
// An in-toto attestation as buildx pushes one: an ordinary image config,
// non-tar layers, and a subject pointing at the image it describes.
ociManifest := []byte(`{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:cfg","size":233},
"layers": [
{"mediaType":"application/vnd.in-toto+json","digest":"sha256:att","size":1024}
],
"subject": {"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:subj","size":528}
}`)
record, err := atproto.NewManifestRecord("myapp", "sha256:deadbeef", ociManifest)
if err != nil {
t.Fatalf("NewManifestRecord: %v", err)
}
if record.Subject == nil {
t.Fatal("AppView did not parse subject off the pushed manifest")
}
info := manifestInfoFrom(t, captureNotify(t, record))
subject, ok := info["subject"].(map[string]any)
if !ok {
t.Fatalf("notify payload omitted subject: %#v", info)
}
if subject["digest"] != "sha256:subj" {
t.Errorf("subject digest = %v, want sha256:subj", subject["digest"])
}
if subject["mediaType"] != "application/vnd.oci.image.manifest.v1+json" {
t.Errorf("subject mediaType = %v", subject["mediaType"])
}
if size, ok := subject["size"].(float64); !ok || int64(size) != 528 {
t.Errorf("subject size = %v, want 528", subject["size"])
}
}
// TestNotifyHold_OmitsSubjectForPlainImage verifies an ordinary image still
// sends no subject, so the hold keeps enqueueing real images for scanning.
func TestNotifyHold_OmitsSubjectForPlainImage(t *testing.T) {
ociManifest := []byte(`{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:cfg","size":233},
"layers": [
{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:l1","size":4096}
]
}`)
record, err := atproto.NewManifestRecord("myapp", "sha256:deadbeef", ociManifest)
if err != nil {
t.Fatalf("NewManifestRecord: %v", err)
}
info := manifestInfoFrom(t, captureNotify(t, record))
if _, present := info["subject"]; present {
t.Errorf("notify payload carried a subject for a plain image: %#v", info["subject"])
}
// The rest of the payload is unchanged.
layers, ok := info["layers"].([]any)
if !ok || len(layers) != 1 {
t.Errorf("layers = %#v, want one layer", info["layers"])
}
}