Validate canonical comparison manifests
This commit is contained in:
@@ -66,7 +66,8 @@ contiguous from one, profile IDs are distinct and nonblank, and
|
|||||||
`succeeded + failed == total`.
|
`succeeded + failed == total`.
|
||||||
|
|
||||||
A successful result has `status: "succeeded"`, `validationStatus: "passed"`,
|
A successful result has `status: "succeeded"`, `validationStatus: "passed"`,
|
||||||
a unique Markdown `reportPath`, and no `error`. A failed result has
|
a `reportPath` exactly equal to the canonical `NN-profile-slug.md` filename for
|
||||||
|
its position, total, and logical profile ID, and no `error`. A failed result has
|
||||||
`status: "failed"`, no `reportPath`, and an `error` object with nonblank
|
`status: "failed"`, no `reportPath`, and an `error` object with nonblank
|
||||||
`category` and `message`. Its validation status is absent, `failed`, or
|
`category` and `message`. Its validation status is absent, `failed`, or
|
||||||
`skipped`. Error messages are valid UTF-8 and no longer than 1,024 bytes.
|
`skipped`. Error messages are valid UTF-8 and no longer than 1,024 bytes.
|
||||||
@@ -81,10 +82,11 @@ larger workspace.
|
|||||||
|
|
||||||
Weatherreporter recognizes a replaceable bundle only when it exactly satisfies
|
Weatherreporter recognizes a replaceable bundle only when it exactly satisfies
|
||||||
the current version, schema, file set, file types, relative-path rules, and
|
the current version, schema, file set, file types, relative-path rules, and
|
||||||
data-package digest. It rejects unknown manifest fields, multiple JSON values,
|
data-package digest. JSON field names are case-sensitive canonical names and a
|
||||||
extra entries, symlinks, and future or otherwise unsupported versions. Treat a
|
field may appear only once in each manifest object. It rejects unknown,
|
||||||
bundle that fails recognition as an ordinary directory, not as a compatible
|
case-variant, or duplicate fields; multiple JSON values; extra entries;
|
||||||
bundle.
|
symlinks; and future or otherwise unsupported versions. Treat a bundle that
|
||||||
|
fails recognition as an ordinary directory, not as a compatible bundle.
|
||||||
|
|
||||||
The manifest contains safe operational provenance, but `data-package.yml` and
|
The manifest contains safe operational provenance, but `data-package.yml` and
|
||||||
the generated Markdown can contain sensitive weather or location context. Do
|
the generated Markdown can contain sensitive weather or location context. Do
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ and only the Markdown files for successful profiles. The durable layout,
|
|||||||
schema, and compatibility rules are owned by the [comparison bundle
|
schema, and compatibility rules are owned by the [comparison bundle
|
||||||
contract](../integrations/comparison-bundle.md).
|
contract](../integrations/comparison-bundle.md).
|
||||||
|
|
||||||
|
Recognition first token-validates the manifest's object fields, rejecting
|
||||||
|
unknown, case-variant, and duplicate names before decoding its typed schema.
|
||||||
|
Manifest validation derives each successful report filename from its ordered
|
||||||
|
position, total profile count, and logical profile ID; logical-bundle and
|
||||||
|
filesystem validation then require that exact path and file set.
|
||||||
|
|
||||||
Destination planning is read-only. It requires an exact absolute target that
|
Destination planning is read-only. It requires an exact absolute target that
|
||||||
is neither the filesystem root nor the working directory, rejects unsafe
|
is neither the filesystem root nor the working directory, rejects unsafe
|
||||||
symlinks and non-directories, accepts a missing or empty directory, and permits
|
symlinks and non-directories, accepts a missing or empty directory, and permits
|
||||||
|
|||||||
@@ -283,7 +283,11 @@ func (manifest Manifest) Validate() error {
|
|||||||
if result.ValidationStatus != "passed" {
|
if result.ValidationStatus != "passed" {
|
||||||
return fmt.Errorf("successful result %d did not pass validation", result.Position)
|
return fmt.Errorf("successful result %d did not pass validation", result.Position)
|
||||||
}
|
}
|
||||||
if result.Error != nil || !isReportPath(result.ReportPath) {
|
expectedPath, err := ReportFilename(result.Position, manifest.Total, result.ProfileID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("successful result %d has invalid report identity: %w", result.Position, err)
|
||||||
|
}
|
||||||
|
if result.Error != nil || result.ReportPath != expectedPath {
|
||||||
return fmt.Errorf("successful result %d has invalid report details", result.Position)
|
return fmt.Errorf("successful result %d has invalid report details", result.Position)
|
||||||
}
|
}
|
||||||
if _, duplicate := reportPaths[result.ReportPath]; duplicate {
|
if _, duplicate := reportPaths[result.ReportPath]; duplicate {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package comparison
|
package comparison
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -165,9 +164,9 @@ func TestManifestEncodingAndRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("EncodeManifest() =\n%s\nwant\n%s", encoded, want)
|
t.Fatalf("EncodeManifest() =\n%s\nwant\n%s", encoded, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
var decoded Manifest
|
decoded, err := decodeManifest(encoded)
|
||||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
if err != nil {
|
||||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
t.Fatalf("decodeManifest() error = %v", err)
|
||||||
}
|
}
|
||||||
if err := decoded.Validate(); err != nil {
|
if err := decoded.Validate(); err != nil {
|
||||||
t.Fatalf("decoded manifest validation error = %v", err)
|
t.Fatalf("decoded manifest validation error = %v", err)
|
||||||
@@ -192,6 +191,10 @@ func TestManifestValidateRejectsInvariants(t *testing.T) {
|
|||||||
{name: "duplicate profile", mutate: func(manifest *Manifest) { manifest.Results[1].ProfileID = manifest.Results[0].ProfileID }},
|
{name: "duplicate profile", mutate: func(manifest *Manifest) { manifest.Results[1].ProfileID = manifest.Results[0].ProfileID }},
|
||||||
{name: "unsupported status", mutate: func(manifest *Manifest) { manifest.Results[1].Status = "skipped" }},
|
{name: "unsupported status", mutate: func(manifest *Manifest) { manifest.Results[1].Status = "skipped" }},
|
||||||
{name: "successful result without report", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "" }},
|
{name: "successful result without report", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "" }},
|
||||||
|
{name: "arbitrary report name", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "arbitrary.md" }},
|
||||||
|
{name: "wrong report position", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "02-weather-light.md" }},
|
||||||
|
{name: "wrong report ordinal width", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "1-weather-light.md" }},
|
||||||
|
{name: "wrong report profile slug", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "01-weather.md" }},
|
||||||
{name: "successful result with error", mutate: func(manifest *Manifest) {
|
{name: "successful result with error", mutate: func(manifest *Manifest) {
|
||||||
manifest.Results[0].Error = &SafeError{Category: "application", Message: "bad"}
|
manifest.Results[0].Error = &SafeError{Category: "application", Message: "bad"}
|
||||||
}},
|
}},
|
||||||
@@ -247,6 +250,12 @@ func TestLogicalBundleValidate(t *testing.T) {
|
|||||||
if err := bundle.Validate(); err == nil {
|
if err := bundle.Validate(); err == nil {
|
||||||
t.Fatal("LogicalBundle.Validate() accepted unordered report position")
|
t.Fatal("LogicalBundle.Validate() accepted unordered report position")
|
||||||
}
|
}
|
||||||
|
bundle.Reports[0].Position = 1
|
||||||
|
bundle.Reports[0].Path = "arbitrary.md"
|
||||||
|
bundle.Manifest.Results[0].ReportPath = "arbitrary.md"
|
||||||
|
if err := bundle.Validate(); err == nil {
|
||||||
|
t.Fatal("LogicalBundle.Validate() accepted a noncanonical report path")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSHA256AndTruncateErrorMessage(t *testing.T) {
|
func TestSHA256AndTruncateErrorMessage(t *testing.T) {
|
||||||
|
|||||||
@@ -239,6 +239,9 @@ func readBundleFile(directory, name string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decodeManifest(data []byte) (Manifest, error) {
|
func decodeManifest(data []byte) (Manifest, error) {
|
||||||
|
if err := validateManifestJSON(data); err != nil {
|
||||||
|
return Manifest{}, unrecognizedBundleError("decode manifest", err)
|
||||||
|
}
|
||||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
var manifest Manifest
|
var manifest Manifest
|
||||||
@@ -255,6 +258,162 @@ func decodeManifest(data []byte) (Manifest, error) {
|
|||||||
return manifest, nil
|
return manifest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var manifestFields = map[string]jsonValueValidator{
|
||||||
|
"schemaVersion": nil,
|
||||||
|
"comparisonId": nil,
|
||||||
|
"startedAt": nil,
|
||||||
|
"finishedAt": nil,
|
||||||
|
"reportId": nil,
|
||||||
|
"validPeriod": validateValidPeriodJSON,
|
||||||
|
"timezone": nil,
|
||||||
|
"promptId": nil,
|
||||||
|
"promptVersion": nil,
|
||||||
|
"promptHash": nil,
|
||||||
|
"dataPackage": validateDataPackageJSON,
|
||||||
|
"total": nil,
|
||||||
|
"succeeded": nil,
|
||||||
|
"failed": nil,
|
||||||
|
"results": validateResultsJSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
var validPeriodFields = map[string]jsonValueValidator{
|
||||||
|
"start": nil,
|
||||||
|
"end": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataPackageFields = map[string]jsonValueValidator{
|
||||||
|
"path": nil,
|
||||||
|
"sha256": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultFields = map[string]jsonValueValidator{
|
||||||
|
"position": nil,
|
||||||
|
"profileId": nil,
|
||||||
|
"backendId": nil,
|
||||||
|
"modelName": nil,
|
||||||
|
"status": nil,
|
||||||
|
"validationStatus": nil,
|
||||||
|
"reportPath": nil,
|
||||||
|
"error": validateSafeErrorJSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeErrorFields = map[string]jsonValueValidator{
|
||||||
|
"category": nil,
|
||||||
|
"message": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonValueValidator func(*json.Decoder) error
|
||||||
|
|
||||||
|
func validateManifestJSON(data []byte) error {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
if err := validateJSONObject(decoder, manifestFields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := decoder.Token(); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return fmt.Errorf("manifest has multiple JSON values")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateValidPeriodJSON(decoder *json.Decoder) error {
|
||||||
|
return validateJSONObject(decoder, validPeriodFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDataPackageJSON(decoder *json.Decoder) error {
|
||||||
|
return validateJSONObject(decoder, dataPackageFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateResultsJSON(decoder *json.Decoder) error {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
|
||||||
|
return fmt.Errorf("results must be an array")
|
||||||
|
}
|
||||||
|
for decoder.More() {
|
||||||
|
if err := validateJSONObject(decoder, resultFields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token, err = decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if delimiter, ok := token.(json.Delim); !ok || delimiter != ']' {
|
||||||
|
return fmt.Errorf("results has an invalid array terminator")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSafeErrorJSON(decoder *json.Decoder) error {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if token == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
|
||||||
|
return fmt.Errorf("error must be an object")
|
||||||
|
}
|
||||||
|
return validateJSONObjectBody(decoder, safeErrorFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateJSONObject(decoder *json.Decoder, fields map[string]jsonValueValidator) error {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
|
||||||
|
return fmt.Errorf("manifest value must be an object")
|
||||||
|
}
|
||||||
|
return validateJSONObjectBody(decoder, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateJSONObjectBody(decoder *json.Decoder, fields map[string]jsonValueValidator) error {
|
||||||
|
seen := make(map[string]struct{}, len(fields))
|
||||||
|
for decoder.More() {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name, ok := token.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("manifest field name is invalid")
|
||||||
|
}
|
||||||
|
validator, known := fields[name]
|
||||||
|
if !known {
|
||||||
|
return fmt.Errorf("manifest field %q is not canonical", name)
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[name]; duplicate {
|
||||||
|
return fmt.Errorf("manifest field %q is duplicated", name)
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
if validator == nil {
|
||||||
|
var value json.RawMessage
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validator(decoder); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if delimiter, ok := token.(json.Delim); !ok || delimiter != '}' {
|
||||||
|
return fmt.Errorf("manifest object has an invalid terminator")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func unrecognizedBundleError(action string, err error) error {
|
func unrecognizedBundleError(action string, err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return fmt.Errorf("%w: %s", ErrUnrecognizedBundle, action)
|
return fmt.Errorf("%w: %s", ErrUnrecognizedBundle, action)
|
||||||
|
|||||||
@@ -138,6 +138,14 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}},
|
}},
|
||||||
|
{name: "duplicate manifest field", mutate: func(t *testing.T, directory string) {
|
||||||
|
t.Helper()
|
||||||
|
appendManifestField(t, directory, `"schemaVersion": "weatherreporter.comparison.v1"`)
|
||||||
|
}},
|
||||||
|
{name: "case variant manifest field", mutate: func(t *testing.T, directory string) {
|
||||||
|
t.Helper()
|
||||||
|
appendManifestField(t, directory, `"SchemaVersion": "weatherreporter.comparison.v1"`)
|
||||||
|
}},
|
||||||
{name: "traversal report path", mutate: func(t *testing.T, directory string) {
|
{name: "traversal report path", mutate: func(t *testing.T, directory string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(directory, ManifestFilename)
|
path := filepath.Join(directory, ManifestFilename)
|
||||||
@@ -158,6 +166,29 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}},
|
}},
|
||||||
|
{name: "noncanonical report path", mutate: func(t *testing.T, directory string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(directory, ManifestFilename)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var manifest Manifest
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(filepath.Join(directory, manifest.Results[0].ReportPath), filepath.Join(directory, "arbitrary.md")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manifest.Results[0].ReportPath = "arbitrary.md"
|
||||||
|
data, err = json.Marshal(manifest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -171,6 +202,19 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendManifestField(t *testing.T, directory, field string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(directory, ManifestFilename)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data = append(data[:len(data)-2], []byte(",\n "+field+"\n}\n")...)
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPlanDestinationAcceptsRecognizedReplacement(t *testing.T) {
|
func TestPlanDestinationAcceptsRecognizedReplacement(t *testing.T) {
|
||||||
directory := publishTestBundle(t, testBundle())
|
directory := publishTestBundle(t, testBundle())
|
||||||
plan, err := PlanDestination(filepath.Dir(directory), directory, true)
|
plan, err := PlanDestination(filepath.Dir(directory), directory, true)
|
||||||
|
|||||||
Reference in New Issue
Block a user