diff --git a/go.mod b/go.mod index 26a40cf3c..3399451bc 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,5 @@ go 1.18 require ( github.com/opencontainers/go-digest v1.0.0 github.com/russross/blackfriday v1.6.0 - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 - github.com/xeipuuv/gojsonschema v1.2.0 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/stretchr/testify v1.7.0 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 ) diff --git a/go.sum b/go.sum index b78325856..dcb5a93b0 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,6 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= diff --git a/schema/defs-descriptor.json b/schema/defs-descriptor.json index dad2b0a3f..056a7999f 100644 --- a/schema/defs-descriptor.json +++ b/schema/defs-descriptor.json @@ -4,7 +4,7 @@ "mediaType": { "id": "https://opencontainers.org/schema/image/descriptor/mediaType", "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9!#$&-^_.+]{0,126}/[A-Za-z0-9][A-Za-z0-9!#$&-^_.+]{0,126}$" + "pattern": "^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$" }, "digest": { "description": "the cryptographic checksum digest of the object, in the pattern ':'", diff --git a/schema/error.go b/schema/error.go index c667c23e2..2f52fc84d 100644 --- a/schema/error.go +++ b/schema/error.go @@ -23,6 +23,8 @@ import ( // A SyntaxError is a description of a JSON syntax error // including line, column and offset in the JSON file. +// +// Deprecated: SyntaxError is no longer returned from Validator. type SyntaxError struct { msg string Line, Col int @@ -34,6 +36,8 @@ func (e *SyntaxError) Error() string { return e.msg } // WrapSyntaxError checks whether the given error is a *json.SyntaxError // and converts it into a *schema.SyntaxError containing line/col information using the given reader. // If the given error is not a *json.SyntaxError it is returned unchanged. +// +// Deprecated: WrapSyntaxError is no longer returned by Validator. func WrapSyntaxError(r io.Reader, err error) error { var serr *json.SyntaxError if errors.As(err, &serr) { diff --git a/schema/loader.go b/schema/loader.go deleted file mode 100644 index d7737582c..000000000 --- a/schema/loader.go +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright 2018 The Linux Foundation -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package schema - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/xeipuuv/gojsonreference" - "github.com/xeipuuv/gojsonschema" -) - -// fsLoaderFactory implements gojsonschema.JSONLoaderFactory by reading files under the specified namespaces from the root of fs. -type fsLoaderFactory struct { - namespaces []string - fs http.FileSystem -} - -// newFSLoaderFactory returns a fsLoaderFactory reading files under the specified namespaces from the root of fs. -func newFSLoaderFactory(namespaces []string, fs http.FileSystem) *fsLoaderFactory { - return &fsLoaderFactory{ - namespaces: namespaces, - fs: fs, - } -} - -func (factory *fsLoaderFactory) New(source string) gojsonschema.JSONLoader { - return &fsLoader{ - factory: factory, - source: source, - } -} - -// refContents returns the contents of ref, if available in fsLoaderFactory. -func (factory *fsLoaderFactory) refContents(ref gojsonreference.JsonReference) ([]byte, error) { - refStr := ref.String() - path := "" - for _, ns := range factory.namespaces { - if strings.HasPrefix(refStr, ns) { - path = "/" + strings.TrimPrefix(refStr, ns) - break - } - } - if path == "" { - return nil, fmt.Errorf("schema reference %#v unexpectedly not available in fsLoaderFactory with namespaces %#v", path, factory.namespaces) - } - - f, err := factory.fs.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - - return io.ReadAll(f) -} - -// fsLoader implements gojsonschema.JSONLoader by reading the document named by source from a fsLoaderFactory. -type fsLoader struct { - factory *fsLoaderFactory - source string -} - -// JsonSource implements gojsonschema.JSONLoader.JsonSource. The "Json" capitalization needs to be maintained to conform to the interface. -func (l *fsLoader) JsonSource() interface{} { // revive:disable-line:var-naming - return l.source -} - -func (l *fsLoader) LoadJSON() (interface{}, error) { - // Based on gojsonschema.jsonReferenceLoader.LoadJSON. - reference, err := gojsonreference.NewJsonReference(l.source) - if err != nil { - return nil, err - } - - refToURL := reference - refToURL.GetUrl().Fragment = "" - - body, err := l.factory.refContents(refToURL) - if err != nil { - return nil, err - } - - return decodeJSONUsingNumber(bytes.NewReader(body)) -} - -// decodeJSONUsingNumber returns JSON parsed from an io.Reader -func decodeJSONUsingNumber(r io.Reader) (interface{}, error) { - // Copied from gojsonschema. - var document interface{} - - decoder := json.NewDecoder(r) - decoder.UseNumber() - - err := decoder.Decode(&document) - if err != nil { - return nil, err - } - - return document, nil -} - -// JsonReference implements gojsonschema.JSONLoader.JsonReference. The "Json" capitalization needs to be maintained to conform to the interface. -func (l *fsLoader) JsonReference() (gojsonreference.JsonReference, error) { // revive:disable-line:var-naming - return gojsonreference.NewJsonReference(l.JsonSource().(string)) -} - -func (l *fsLoader) LoaderFactory() gojsonschema.JSONLoaderFactory { - return l.factory -} diff --git a/schema/schema.go b/schema/schema.go index 7a338d8ee..40400fce4 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -23,56 +23,62 @@ import ( // Media types for the OCI image formats const ( - ValidatorMediaTypeDescriptor Validator = v1.MediaTypeDescriptor - ValidatorMediaTypeLayoutHeader Validator = v1.MediaTypeLayoutHeader - ValidatorMediaTypeManifest Validator = v1.MediaTypeImageManifest - ValidatorMediaTypeImageIndex Validator = v1.MediaTypeImageIndex - ValidatorMediaTypeImageConfig Validator = v1.MediaTypeImageConfig - ValidatorMediaTypeImageLayer unimplemented = v1.MediaTypeImageLayer + ValidatorMediaTypeDescriptor Validator = v1.MediaTypeDescriptor + ValidatorMediaTypeLayoutHeader Validator = v1.MediaTypeLayoutHeader + ValidatorMediaTypeManifest Validator = v1.MediaTypeImageManifest + ValidatorMediaTypeImageIndex Validator = v1.MediaTypeImageIndex + ValidatorMediaTypeImageConfig Validator = v1.MediaTypeImageConfig + ValidatorMediaTypeImageLayer Validator = v1.MediaTypeImageLayer ) var ( - // fs stores the embedded http.FileSystem - // having the OCI JSON schema files in root "/". + // specFS stores the embedded http.FileSystem having the OCI JSON schema files in root "/". //go:embed *.json - fs embed.FS + specFS embed.FS - // schemaNamespaces is a set of URI prefixes which are treated as containing the schema files of fs. - // This is necessary because *.json schema files in this directory use "id" and "$ref" attributes which evaluate to such URIs, e.g. - // ./image-manifest-schema.json URI contains - // "id": "https://opencontainers.org/schema/image/manifest", - // and - // "$ref": "content-descriptor.json" - // which evaluates as a link to https://opencontainers.org/schema/image/content-descriptor.json . - // - // To support such links without accessing the network (and trying to load content which is not hosted at these URIs), - // fsLoaderFactory accepts any URI starting with one of the schemaNamespaces below, - // and uses _escFS to load them from the root of its in-memory filesystem tree. - // - // (Note that this must contain subdirectories before its parent directories for fsLoaderFactory.refContents to work.) - schemaNamespaces = []string{ - "https://opencontainers.org/schema/image/descriptor/", - "https://opencontainers.org/schema/image/index/", - "https://opencontainers.org/schema/image/manifest/", - "https://opencontainers.org/schema/image/", - "https://opencontainers.org/schema/descriptor/", - "https://opencontainers.org/schema/", + // specsOrig maps OCI schema media types to schema files. + specs = map[Validator]string{ + ValidatorMediaTypeDescriptor: "content-descriptor.json", + ValidatorMediaTypeLayoutHeader: "image-layout-schema.json", + ValidatorMediaTypeManifest: "image-manifest-schema.json", + ValidatorMediaTypeImageIndex: "image-index-schema.json", + ValidatorMediaTypeImageConfig: "config-schema.json", } - // specs maps OCI schema media types to schema URIs. - // These URIs are expected to be used only by fsLoaderFactory (which trims schemaNamespaces defined above) - // and should never cause a network access. - specs = map[Validator]string{ - ValidatorMediaTypeDescriptor: "https://opencontainers.org/schema/content-descriptor.json", - ValidatorMediaTypeLayoutHeader: "https://opencontainers.org/schema/image/image-layout-schema.json", - ValidatorMediaTypeManifest: "https://opencontainers.org/schema/image/image-manifest-schema.json", - ValidatorMediaTypeImageIndex: "https://opencontainers.org/schema/image/image-index-schema.json", - ValidatorMediaTypeImageConfig: "https://opencontainers.org/schema/image/config-schema.json", + // specURLs lists the various URLs a given spec may be known by. + // This is generated from the "id" value in each spec and relative ref values they contain. + specURLs = map[string][]string{ + "config-schema.json": { + "https://opencontainers.org/schema/image/config", + }, + "content-descriptor.json": { + "https://opencontainers.org/schema/descriptor", + "https://opencontainers.org/schema/image/content-descriptor.json", + }, + "defs-descriptor.json": { + "https://opencontainers.org/schema/image/descriptor/mediaType", + "https://opencontainers.org/schema/defs-descriptor.json", + "https://opencontainers.org/schema/image/defs-descriptor.json", + }, + "defs.json": { + "https://opencontainers.org/schema/defs.json", + "https://opencontainers.org/schema/image/defs.json", + "https://opencontainers.org/schema/image/descriptor/defs.json", + }, + "image-index-schema.json": { + "https://opencontainers.org/schema/image/index", + }, + "image-layout-schema.json": { + "https://opencontainers.org/schema/image/layout", + }, + "image-manifest-schema.json": { + "https://opencontainers.org/schema/image/manifest", + }, } ) // FileSystem returns an in-memory filesystem including the schema files. // The schema files are located at the root directory. func FileSystem() http.FileSystem { - return http.FS(fs) + return http.FS(specFS) } diff --git a/schema/spec_test.go b/schema/spec_test.go index df5c9dc1d..5cf849005 100644 --- a/schema/spec_test.go +++ b/schema/spec_test.go @@ -106,27 +106,11 @@ func validate(t *testing.T, name string) { err = schema.Validator(example.Mediatype).Validate(strings.NewReader(example.Body)) if err == nil { printFields(t, "ok", example.Mediatype, example.Title) - t.Log(example.Body, "---") - continue - } - - var errs []error - var verr schema.ValidationError - if errors.As(err, &verr) { - errs = verr.Errs } else { printFields(t, "error", example.Mediatype, example.Title, err) t.Error(err) - t.Log(example.Body, "---") - continue - } - - for _, err := range errs { - printFields(t, "invalid", example.Mediatype, example.Title) - t.Error(err) - fmt.Println(example.Body, "---") - continue } + t.Log(example.Body, "---") } } diff --git a/schema/validator.go b/schema/validator.go index b7a0acaa8..7f87dc32c 100644 --- a/schema/validator.go +++ b/schema/validator.go @@ -20,90 +20,121 @@ import ( "errors" "fmt" "io" + "os" "regexp" digest "github.com/opencontainers/go-digest" v1 "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/xeipuuv/gojsonschema" + "github.com/santhosh-tekuri/jsonschema/v5" ) -// Validator wraps a media type string identifier -// and implements validation against a JSON schema. +// Validator wraps a media type string identifier and implements validation against a JSON schema. type Validator string -type validateFunc func(r io.Reader) error - -var mapValidate = map[Validator]validateFunc{ - ValidatorMediaTypeImageConfig: validateConfig, - ValidatorMediaTypeDescriptor: validateDescriptor, - ValidatorMediaTypeImageIndex: validateIndex, - ValidatorMediaTypeManifest: validateManifest, -} - // ValidationError contains all the errors that happened during validation. +// +// Deprecated: this is no longer used by [Validator]. type ValidationError struct { Errs []error } +// Error returns the error message. +// +// Deprecated: this is no longer used by [Validator]. func (e ValidationError) Error() string { return fmt.Sprintf("%v", e.Errs) } // Validate validates the given reader against the schema of the wrapped media type. func (v Validator) Validate(src io.Reader) error { - buf, err := io.ReadAll(src) - if err != nil { - return fmt.Errorf("unable to read the document file: %w", err) - } - - if f, ok := mapValidate[v]; ok { - if f == nil { - return fmt.Errorf("internal error: mapValidate[%q] is nil", v) + // run the media type specific validation + if fn, ok := validateByMediaType[v]; ok { + if fn == nil { + return fmt.Errorf("internal error: mapValidate is nil for %s", string(v)) } - err = f(bytes.NewReader(buf)) + // buffer the src so the media type validation and the schema validation can both read it + buf, err := io.ReadAll(src) + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + src = bytes.NewReader(buf) + err = fn(buf) if err != nil { return err } } - sl := newFSLoaderFactory(schemaNamespaces, FileSystem()).New(specs[v]) - ml := gojsonschema.NewStringLoader(string(buf)) + // json schema validation + return v.validateSchema(src) +} - result, err := gojsonschema.Validate(sl, ml) - if err != nil { - return fmt.Errorf("schema %s: unable to validate: %w", v, - WrapSyntaxError(bytes.NewReader(buf), err)) +func (v Validator) validateSchema(src io.Reader) error { + if _, ok := specs[v]; !ok { + return fmt.Errorf("no validator available for %s", string(v)) } - if result.Valid() { - return nil + c := jsonschema.NewCompiler() + + // load the schema files from the embedded FS + dir, err := specFS.ReadDir(".") + if err != nil { + return fmt.Errorf("spec embedded directory could not be loaded: %w", err) + } + for _, file := range dir { + if file.IsDir() { + continue + } + specBuf, err := specFS.ReadFile(file.Name()) + if err != nil { + return fmt.Errorf("could not read spec file %s: %w", file.Name(), err) + } + err = c.AddResource(file.Name(), bytes.NewReader(specBuf)) + if err != nil { + return fmt.Errorf("failed to add spec file %s: %w", file.Name(), err) + } + if len(specURLs[file.Name()]) == 0 { + fmt.Fprintf(os.Stderr, "warning: spec file has no aliases: %s", file.Name()) + } + for _, specURL := range specURLs[file.Name()] { + err = c.AddResource(specURL, bytes.NewReader(specBuf)) + if err != nil { + return fmt.Errorf("failed to add spec file %s as url %s: %w", file.Name(), specURL, err) + } + } } - errs := make([]error, 0, len(result.Errors())) - for _, desc := range result.Errors() { - errs = append(errs, fmt.Errorf("%s", desc)) + // compile based on the type of validator + schema, err := c.Compile(specs[v]) + if err != nil { + return fmt.Errorf("failed to compile schema %s: %w", string(v), err) } - return ValidationError{ - Errs: errs, + // read in the user input and validate + var input interface{} + err = json.NewDecoder(src).Decode(&input) + if err != nil { + return fmt.Errorf("unable to parse json to validate: %w", err) + } + err = schema.Validate(input) + if err != nil { + return fmt.Errorf("validation failed: %w", err) } + return nil } -type unimplemented string +type validateFunc func([]byte) error -func (v unimplemented) Validate(_ io.Reader) error { - return fmt.Errorf("%s: unimplemented", v) +var validateByMediaType = map[Validator]validateFunc{ + ValidatorMediaTypeImageConfig: validateConfig, + ValidatorMediaTypeDescriptor: validateDescriptor, + ValidatorMediaTypeImageIndex: validateIndex, + ValidatorMediaTypeManifest: validateManifest, } -func validateManifest(r io.Reader) error { +func validateManifest(buf []byte) error { header := v1.Manifest{} - buf, err := io.ReadAll(r) - if err != nil { - return fmt.Errorf("error reading the io stream: %w", err) - } - - err = json.Unmarshal(buf, &header) + err := json.Unmarshal(buf, &header) if err != nil { return fmt.Errorf("manifest format mismatch: %w", err) } @@ -125,15 +156,10 @@ func validateManifest(r io.Reader) error { return nil } -func validateDescriptor(r io.Reader) error { +func validateDescriptor(buf []byte) error { header := v1.Descriptor{} - buf, err := io.ReadAll(r) - if err != nil { - return fmt.Errorf("error reading the io stream: %w", err) - } - - err = json.Unmarshal(buf, &header) + err := json.Unmarshal(buf, &header) if err != nil { return fmt.Errorf("descriptor format mismatch: %w", err) } @@ -147,15 +173,10 @@ func validateDescriptor(r io.Reader) error { return err } -func validateIndex(r io.Reader) error { +func validateIndex(buf []byte) error { header := v1.Index{} - buf, err := io.ReadAll(r) - if err != nil { - return fmt.Errorf("error reading the io stream: %w", err) - } - - err = json.Unmarshal(buf, &header) + err := json.Unmarshal(buf, &header) if err != nil { return fmt.Errorf("index format mismatch: %w", err) } @@ -174,15 +195,10 @@ func validateIndex(r io.Reader) error { return nil } -func validateConfig(r io.Reader) error { +func validateConfig(buf []byte) error { header := v1.Image{} - buf, err := io.ReadAll(r) - if err != nil { - return fmt.Errorf("error reading the io stream: %w", err) - } - - err = json.Unmarshal(buf, &header) + err := json.Unmarshal(buf, &header) if err != nil { return fmt.Errorf("config format mismatch: %w", err) }