From a25aacfd8543db2fbb1b6bf44d80dd0930d868da Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 29 Jul 2026 00:54:39 +0800 Subject: [PATCH 1/2] oci: type the manifest 404 as snapshot.ErrManifestNotFound GetManifest callers that need fail-closed semantics (vk-cocoon's hibernate-evidence check) currently pay a HasManifest HEAD before every GET purely to distinguish clean absence from transport failure. The 404 now maps to a contract-level sentinel on the Downloader interface, so one GET answers both questions; ignoreNotFound shares the detection. --- oci/oci.go | 13 +++++++++++-- oci/oci_test.go | 11 +++++++++++ snapshot/snapshot.go | 5 +++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/oci/oci.go b/oci/oci.go index 8425453..925d0b4 100644 --- a/oci/oci.go +++ b/oci/oci.go @@ -15,6 +15,8 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/remote/transport" "github.com/google/go-containerregistry/pkg/v1/types" + + "github.com/cocoonstack/cocoon-common/snapshot" ) const maxRegistryConnsPerHost = 32 @@ -59,6 +61,9 @@ func (r *OCIRegistry) GetManifest(ctx context.Context, repo, tag string) ([]byte } desc, err := remote.Get(ref, r.callOpts(ctx)...) if err != nil { + if isNotFound(err) { + return nil, "", fmt.Errorf("get manifest %s:%s: %w", repo, tag, snapshot.ErrManifestNotFound) + } return nil, "", fmt.Errorf("get manifest %s:%s: %w", repo, tag, err) } return desc.Manifest, string(desc.MediaType), nil @@ -176,13 +181,17 @@ func bulkTransport() *http.Transport { // ignoreNotFound maps a registry 404 to a nil error (absent, not failed) and // wraps anything else. func ignoreNotFound(err error, action string) error { - var terr *transport.Error - if errors.As(err, &terr) && terr.StatusCode == http.StatusNotFound { + if isNotFound(err) { return nil } return fmt.Errorf("%s: %w", action, err) } +func isNotFound(err error) bool { + var terr *transport.Error + return errors.As(err, &terr) && terr.StatusCode == http.StatusNotFound +} + // streamLayer is a v1.Layer over a body with a known digest and size, so PutBlob // streams a raw blob without buffering it (WriteLayer reads only Compressed()). // body is single-use: a retried upload fails the digest check, not corrupts. diff --git a/oci/oci_test.go b/oci/oci_test.go index 4375065..1b7dbbd 100644 --- a/oci/oci_test.go +++ b/oci/oci_test.go @@ -201,6 +201,17 @@ func TestStreamResolvesIndexChildByDigest(t *testing.T) { } } +func TestGetManifestNotFoundIsTyped(t *testing.T) { + srv := httptest.NewServer(registry.New()) + t.Cleanup(srv.Close) + r := NewOCIRegistry(strings.TrimPrefix(srv.URL, "http://")+"/cocoon", authn.DefaultKeychain) + + _, _, err := r.GetManifest(t.Context(), "ghost/repo", "missing") + if !errors.Is(err, snapshot.ErrManifestNotFound) { + t.Fatalf("err = %v, want errors.Is ErrManifestNotFound", err) + } +} + func digestOf(b []byte) string { sum := sha256.Sum256(b) return "sha256:" + hex.EncodeToString(sum[:]) diff --git a/snapshot/snapshot.go b/snapshot/snapshot.go index 33936ad..909040e 100644 --- a/snapshot/snapshot.go +++ b/snapshot/snapshot.go @@ -21,6 +21,11 @@ const ( ) var ( + // ErrManifestNotFound is a Downloader's authoritative "no such manifest" + // (registry 404), as opposed to a transport failure; unwrap with errors.Is. + // Callers use it to distinguish clean absence from unverifiable state. + ErrManifestNotFound = errors.New("manifest not found") + errMissingSnapshotJSON = errors.New("snapshot.json not found in export stream") nowFunc = time.Now // tests override From d28f5ee183b3a8153805ea506a559c7bc551c9f4 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 29 Jul 2026 01:02:56 +0800 Subject: [PATCH 2/2] Update snapshot.go --- snapshot/snapshot.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/snapshot/snapshot.go b/snapshot/snapshot.go index 909040e..4d5a54e 100644 --- a/snapshot/snapshot.go +++ b/snapshot/snapshot.go @@ -21,9 +21,6 @@ const ( ) var ( - // ErrManifestNotFound is a Downloader's authoritative "no such manifest" - // (registry 404), as opposed to a transport failure; unwrap with errors.Is. - // Callers use it to distinguish clean absence from unverifiable state. ErrManifestNotFound = errors.New("manifest not found") errMissingSnapshotJSON = errors.New("snapshot.json not found in export stream")