Validate canonical comparison manifests

This commit is contained in:
2026-08-13 03:06:28 +00:00
parent 70cad789ea
commit 707db5394c
6 changed files with 234 additions and 10 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)