diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 733d26c..64e486d 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -493,6 +493,12 @@ changes to record selection are required. ## R12: Reject trailing JSON without constructing a discarded value — P3 +**Completed:** Loading uses complete-document unmarshalling and wraps the actual +parser error. It avoids constructing a discarded trailing value while retaining +the whole-file read bound. Tests passed for single documents, whitespace, +additional fields, extra documents, malformed suffixes, and retained syntax +causes, alongside byte-preserving record regressions. + **Relevant code:** `internal/publish/record.go:24`, particularly the second `decoder.Decode(&trailing)` and its error handling at lines 34–37. diff --git a/internal/publish/record.go b/internal/publish/record.go index f1a9ec4..30b04f2 100644 --- a/internal/publish/record.go +++ b/internal/publish/record.go @@ -7,7 +7,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "os" "path/filepath" "strings" @@ -32,15 +31,10 @@ func LoadEvidence(path string) (Evidence, error) { if err != nil { return Evidence{}, &RecoveryError{Kind: "record_read", RecordPath: path, cause: fmt.Errorf("read record %q: %w", path, err)} } - decoder := json.NewDecoder(strings.NewReader(string(data))) var envelope recordEnvelope - if err := decoder.Decode(&envelope); err != nil { + if err := json.Unmarshal(data, &envelope); err != nil { return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record %q: %w", path, err)} } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q contains trailing JSON", path)} - } if envelope.FormatVersion != recordFormatVersion && envelope.FormatVersion != recordByteFormatVersion { return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q has unsupported format version %d", path, envelope.FormatVersion)} } diff --git a/internal/publish/record_test.go b/internal/publish/record_test.go index 2617fd1..0e21e23 100644 --- a/internal/publish/record_test.go +++ b/internal/publish/record_test.go @@ -138,6 +138,38 @@ func TestLoadEvidenceAcceptsPartialAndRejectsMalformedRecords(t *testing.T) { } } +func TestLoadEvidenceRequiresOneDocumentAndPreservesSyntaxCause(t *testing.T) { + const valid = `{"format_version":1,"evidence":{"tag":"v1.2.3","future":{}},"future":true}` + for _, test := range []struct { + name, data string + valid bool + }{ + {"single", valid, true}, {"whitespace", valid + "\n \t\r", true}, + {"first incomplete", "{", false}, {"first syntax", "x", false}, + {"second object", valid + ` {"unused":[1,2,3]}`, false}, + {"incomplete suffix", valid + " {", false}, {"invalid suffix", valid + " x", false}, + } { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "record.json") + if err := os.WriteFile(path, []byte(test.data), 0600); err != nil { + t.Fatal(err) + } + loaded, err := LoadEvidence(path) + if test.valid { + if err != nil || loaded.Tag != "v1.2.3" { + t.Fatalf("valid record = %#v, %v", loaded, err) + } + return + } + var recovery *RecoveryError + var syntax *json.SyntaxError + if !errors.As(err, &recovery) || recovery.Kind != "record_format" || !errors.As(err, &syntax) { + t.Fatalf("lost format/parser cause: %v", err) + } + }) + } +} + func TestLoadEvidenceReadFailureHasTypedError(t *testing.T) { _, err := LoadEvidence(filepath.Join(t.TempDir(), "missing.json")) var recovery *RecoveryError