package artifacts import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strconv" "strings" "sync" "testing" "github.com/novox/mesh-controller/internal/catalogue" ) // Every kept archive is held by a manifest (novox/hq issue 253, ADR 0189). // // Against an in-memory registry that keeps blobs and manifests per repository and refuses what a // registry refuses — a blob whose digest does not match, a manifest whose digest does not match // or whose blobs the repository does not have, a manifest asked for without an Accept naming its // type. What is asserted is this side's decisions; the live test below asserts the registry's. type memRegistry struct { mu sync.Mutex blobs map[string][]byte // repository + "@" + digest manifests map[string][]byte // repository + "@" + digest writes []string // every PUT and DELETE, as "METHOD path" } func digestOf(body []byte) string { sum := sha256.Sum256(body) return "sha256:" + hex.EncodeToString(sum[:]) } func (m *memRegistry) serve(t *testing.T) Store { t.Helper() m.blobs = map[string][]byte{} m.manifests = map[string][]byte{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { m.mu.Lock() defer m.mu.Unlock() path := strings.TrimPrefix(r.URL.Path, "/v2/") if r.Method == http.MethodPut || r.Method == http.MethodDelete { m.writes = append(m.writes, r.Method+" "+r.URL.Path) } switch { case r.Method == http.MethodPost && strings.HasSuffix(path, "/blobs/uploads/"): repository := strings.TrimSuffix(path, "/blobs/uploads/") w.Header().Set("Location", "/upload/"+repository+"?state=x") w.WriteHeader(http.StatusAccepted) case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/upload/"): repository := strings.TrimPrefix(r.URL.Path, "/upload/") body, _ := io.ReadAll(r.Body) digest := r.URL.Query().Get("digest") if digest != digestOf(body) { w.WriteHeader(http.StatusBadRequest) return } m.blobs[repository+"@"+digest] = body w.WriteHeader(http.StatusCreated) case strings.Contains(path, "/blobs/"): repository, digest, _ := strings.Cut(path, "/blobs/") key := repository + "@" + digest body, ok := m.blobs[key] if !ok { w.WriteHeader(http.StatusNotFound) return } switch r.Method { case http.MethodHead: w.Header().Set("Content-Length", strconv.Itoa(len(body))) w.WriteHeader(http.StatusOK) case http.MethodDelete: delete(m.blobs, key) w.WriteHeader(http.StatusAccepted) default: w.WriteHeader(http.StatusMethodNotAllowed) } case strings.Contains(path, "/manifests/"): repository, digest, _ := strings.Cut(path, "/manifests/") key := repository + "@" + digest switch r.Method { case http.MethodHead: if _, ok := m.manifests[key]; !ok || !strings.Contains(r.Header.Get("Accept"), mediaManifest) { w.WriteHeader(http.StatusNotFound) return } w.WriteHeader(http.StatusOK) case http.MethodPut: body, _ := io.ReadAll(r.Body) if digest != digestOf(body) { w.WriteHeader(http.StatusBadRequest) return } var named holderManifest if err := json.Unmarshal(body, &named); err != nil { w.WriteHeader(http.StatusBadRequest) return } for _, d := range append([]descriptor{named.Config}, named.Layers...) { if _, ok := m.blobs[repository+"@"+d.Digest]; !ok { w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, "MANIFEST_BLOB_UNKNOWN %s", d.Digest) return } } m.manifests[key] = body w.WriteHeader(http.StatusCreated) case http.MethodDelete: if _, ok := m.manifests[key]; !ok { w.WriteHeader(http.StatusNotFound) return } delete(m.manifests, key) w.WriteHeader(http.StatusAccepted) } default: w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(server.Close) return Store{Address: strings.TrimPrefix(server.URL, "http://")} } // bare puts an archive in the store the way the builder did before holders: a blob, nothing more. func (m *memRegistry) bare(repository string, body []byte) string { m.mu.Lock() defer m.mu.Unlock() digest := digestOf(body) m.blobs[repository+"@"+digest] = body return catalogue.ArtifactStoreScheme + repository + "/blobs/" + digest } func TestTheHolderIsComposedFromTheDigestAndSizeAlone(t *testing.T) { // The sweep must arrive at the very manifest the builder wrote, with nothing recorded between // them. Same inputs, same bytes — and a different size is a different holder, so a holder can // never be mistaken for one of a different blob. digest := "sha256:" + strings.Repeat("a", 64) one, first := Holder(digest, 42) two, second := Holder(digest, 42) if !bytes.Equal(one, two) || first != second { t.Fatalf("the same archive composed two holders:\n%s\n%s", one, two) } if _, other := Holder(digest, 43); other == first { t.Fatal("a different size composed the same holder") } want := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",` + `"config":{"mediaType":"application/vnd.oci.empty.v1+json",` + `"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},` + `"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"` + digest + `","size":42}]}` if string(one) != want { t.Fatalf("the holder is\n%s\nwant\n%s", one, want) } if digestOf(emptyConfig) != emptyDigest { t.Fatalf("the empty config's digest is %s, not %s", digestOf(emptyConfig), emptyDigest) } } func TestHoldBackfillsABareArchiveAndIsIdempotent(t *testing.T) { // The archives published before this have no holder. Hold, run over every kept archive on // every sweep, writes one the first time and nothing after. m := &memRegistry{} store := m.serve(t) ctx := context.Background() body := []byte("a theme") reference := m.bare("shell/config", body) if held, err := store.Held(ctx, reference); err != nil || held { t.Fatalf("a bare blob reads as held=%v (%v)", held, err) } wrote, err := store.Hold(ctx, reference) if err != nil { t.Fatal(err) } if !wrote { t.Fatal("holding a bare archive wrote nothing") } _, holder := Holder(digestOf(body), int64(len(body))) if _, ok := m.manifests["shell/config@"+holder]; !ok { t.Fatalf("the store holds manifests %v; want %s", m.manifests, holder) } if held, err := store.Held(ctx, reference); err != nil || !held { t.Fatalf("after holding, held=%v (%v)", held, err) } writes := len(m.writes) wrote, err = store.Hold(ctx, reference) if err != nil { t.Fatal(err) } if wrote || len(m.writes) != writes { t.Fatalf("holding again wrote %v", m.writes[writes:]) } } func TestAnImageNeedsNoHolderAndAMissingArchiveIsGone(t *testing.T) { m := &memRegistry{} store := m.serve(t) ctx := context.Background() // An image is its own manifest: nothing is asked. wrote, err := store.Hold(ctx, catalogue.ArtifactStoreScheme+"web/app@sha256:"+strings.Repeat("b", 64)) if err != nil || wrote || len(m.writes) != 0 { t.Fatalf("holding an image wrote=%v err=%v writes=%v", wrote, err, m.writes) } // An archive the store does not have is a fact to report, not something to invent a holder for. _, err = store.Hold(ctx, catalogue.ArtifactStoreScheme+"web/config/blobs/sha256:"+strings.Repeat("c", 64)) if !errors.Is(err, Gone) { t.Fatalf("holding a missing archive answered %v, want Gone", err) } // And a reference that is not the mesh's is refused as such. if _, err := store.Hold(ctx, "docker.io/library/registry@sha256:abc"); !errors.Is(err, ErrNotOurs) { t.Fatalf("holding a vendor's image answered %v, want ErrNotOurs", err) } } func TestLettingGoOfAnArchiveDeletesItsHolderFirst(t *testing.T) { // A holder left behind would keep the bytes through every collection while the record said // collected; the link deleted first and the holder failing after would be that exactly. m := &memRegistry{} store := m.serve(t) ctx := context.Background() body := []byte("an old theme") reference := m.bare("shell/config", body) if _, err := store.Hold(ctx, reference); err != nil { t.Fatal(err) } m.writes = nil if err := store.LetGo(ctx, reference); err != nil { t.Fatal(err) } _, holder := Holder(digestOf(body), int64(len(body))) want := []string{ "DELETE /v2/shell/config/manifests/" + holder, "DELETE /v2/shell/config/blobs/" + digestOf(body), } if strings.Join(m.writes, "\n") != strings.Join(want, "\n") { t.Fatalf("the store was asked\n%s\nwant\n%s", strings.Join(m.writes, "\n"), strings.Join(want, "\n")) } if len(m.manifests) != 0 { t.Fatalf("a holder survived: %v", m.manifests) } // Asked again, the archive is already gone, which is the outcome wanted. if err := store.LetGo(ctx, reference); !errors.Is(err, Gone) { t.Fatalf("letting go twice answered %v, want Gone", err) } } func TestLettingGoOfAnUnheldArchiveStillDeletesIt(t *testing.T) { // An archive published before holders and let go of before any sweep held it: the holder's // delete answers 404, which is the outcome wanted, and the blob still goes. m := &memRegistry{} store := m.serve(t) reference := m.bare("shell/config", []byte("never held")) if err := store.LetGo(context.Background(), reference); err != nil { t.Fatal(err) } if len(m.blobs) != 0 { t.Fatalf("the blob survived: %v", m.blobs) } }