diff --git a/registry/remote/repository.go b/registry/remote/repository.go index fa305f79..df3b2623 100644 --- a/registry/remote/repository.go +++ b/registry/remote/repository.go @@ -23,6 +23,7 @@ import ( "io" "mime" "net/http" + "regexp" "strconv" "strings" @@ -40,6 +41,10 @@ import ( "oras.land/oras-go/v2/registry/remote/internal/errutil" ) +// referrersApiRegex checks referrers API version. +// Reference: https://github.com/oras-project/artifacts-spec/blob/main/manifest-referrers-api.md#versioning +var referrersApiRegex = regexp.MustCompile(`^oras/1\.(0|[1-9]\d*)$`) + // Client is an interface for a HTTP client. type Client interface { // Do sends an HTTP request and returns an HTTP response. @@ -407,6 +412,9 @@ func (r *Repository) referrers(ctx context.Context, artifactType string, fn func if resp.StatusCode != http.StatusOK { return "", errutil.ParseErrorResponse(resp) } + if err := verifyOrasApiVersion(resp); err != nil { + return "", err + } var page struct { References []artifactspec.Descriptor `json:"references"` @@ -978,3 +986,13 @@ func verifyContentDigest(resp *http.Response, expected digest.Digest) error { } return nil } + +// verifyOrasApiVersion verifies "ORAS-Api-Version" header if present. +// Reference: https://github.com/oras-project/artifacts-spec/blob/main/manifest-referrers-api.md#versioning +func verifyOrasApiVersion(resp *http.Response) error { + versionStr := resp.Header.Get("ORAS-Api-Version") + if !referrersApiRegex.MatchString(versionStr) { + return fmt.Errorf("%w: Unsupported ORAS-Api-Version: %q", errdef.ErrUnsupportedVersion, versionStr) + } + return nil +} diff --git a/registry/remote/repository_test.go b/registry/remote/repository_test.go index 3f9e31d1..f34b3dfa 100644 --- a/registry/remote/repository_test.go +++ b/registry/remote/repository_test.go @@ -871,6 +871,7 @@ func TestRepository_Predecessors(t *testing.T) { w.WriteHeader(http.StatusBadRequest) return } + w.Header().Set("ORAS-Api-Version", "oras/1.0") var referrers []artifactspec.Descriptor switch q.Get("test") { case "foo": @@ -985,6 +986,7 @@ func TestRepository_Referrers(t *testing.T) { w.WriteHeader(http.StatusBadRequest) return } + w.Header().Set("ORAS-Api-Version", "oras/1.0") var referrers []artifactspec.Descriptor switch q.Get("test") { case "foo": @@ -1040,6 +1042,100 @@ func TestRepository_Referrers(t *testing.T) { } } +func TestRepository_Referrers_Incompatible(t *testing.T) { + manifest := []byte(`{"layers":[]}`) + manifestDesc := ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: digest.FromBytes(manifest), + Size: int64(len(manifest)), + } + var ts *httptest.Server + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := "/v2/test/_oras/artifacts/referrers" + if r.Method != http.MethodGet || r.URL.Path != path { + t.Errorf("unexpected access: %s %q", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("ORAS-Api-Version", "oras/2.0") + })) + defer ts.Close() + uri, err := url.Parse(ts.URL) + if err != nil { + t.Fatalf("invalid test http server: %v", err) + } + + repo, err := NewRepository(uri.Host + "/test") + if err != nil { + t.Fatalf("NewRepository() error = %v", err) + } + repo.PlainHTTP = true + + ctx := context.Background() + if err := repo.Referrers(ctx, manifestDesc, "", func(got []artifactspec.Descriptor) error { + return nil + }); err == nil { + t.Error("Repository.Referrers() incompatible version not rejected") + } +} + +func Test_verifyOrasApiVersion(t *testing.T) { + params := []struct { + name string + version string + compatible bool + }{ + { + name: "exact", + version: "oras/1.0", + compatible: true, + }, + { + name: "major same, minor different", + version: "oras/1.11", + compatible: true, + }, + { + name: "major different", + version: "oras/2.0", + compatible: false, + }, + { + name: "invalid prefix", + version: "*oras/1.0", + compatible: false, + }, + { + name: "invalid minor version", + version: "oras/1.01", + compatible: false, + }, + { + name: "not dot", + version: "oras/1#0", + compatible: false, + }, + { + name: "no version", + version: "", + compatible: false, + }, + } + + for _, tt := range params { + t.Run(tt.name, func(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + if tt.version != "" { + resp.Header.Set("ORAS-Api-Version", tt.version) + } + err := verifyOrasApiVersion(resp) + if (err == nil) != tt.compatible { + t.Errorf("verifyOrasApiVersion() compatible = %v, want = %v", err == nil, tt.compatible) + } + }) + } +} + func TestRepository_Referrers_Fallback(t *testing.T) { manifest := []byte(`{"layers":[]}`) manifestDesc := ocispec.Descriptor{ @@ -1099,6 +1195,7 @@ func TestRepository_Referrers_Fallback(t *testing.T) { w.WriteHeader(http.StatusBadRequest) return } + w.Header().Set("ORAS-Api-Version", "oras/1.0") var referrers []artifactspec.Descriptor switch q.Get("test") { case "foo": @@ -1147,7 +1244,6 @@ func TestRepository_Referrers_Fallback(t *testing.T) { }); err != nil { t.Errorf("Repository.Referrers() error = %v", err) } - } func TestRepository_Referrers_ServerFiltering(t *testing.T) { @@ -1210,6 +1306,7 @@ func TestRepository_Referrers_ServerFiltering(t *testing.T) { w.WriteHeader(http.StatusBadRequest) return } + w.Header().Set("ORAS-Api-Version", "oras/1.0") var referrers []artifactspec.Descriptor switch q.Get("test") { case "foo": @@ -1346,6 +1443,7 @@ func TestRepository_Referrers_ClientFiltering(t *testing.T) { w.WriteHeader(http.StatusBadRequest) return } + w.Header().Set("ORAS-Api-Version", "oras/1.0") var referrers []artifactspec.Descriptor switch q.Get("test") { case "foo":