Reject trailing record JSON without decoding a second value

This commit is contained in:
2026-11-02 22:37:12 +00:00
parent 3b8bacf114
commit cd274512e7
3 changed files with 39 additions and 7 deletions

View File

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

View File

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

View File

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