Reject trailing record JSON without decoding a second value
This commit is contained in:
@@ -493,6 +493,12 @@ changes to record selection are required.
|
|||||||
|
|
||||||
## R12: Reject trailing JSON without constructing a discarded value — P3
|
## 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
|
**Relevant code:** `internal/publish/record.go:24`, particularly the second
|
||||||
`decoder.Decode(&trailing)` and its error handling at lines 34–37.
|
`decoder.Decode(&trailing)` and its error handling at lines 34–37.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -32,15 +31,10 @@ func LoadEvidence(path string) (Evidence, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Evidence{}, &RecoveryError{Kind: "record_read", RecordPath: path, cause: fmt.Errorf("read record %q: %w", path, err)}
|
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
|
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)}
|
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 {
|
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)}
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q has unsupported format version %d", path, envelope.FormatVersion)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestLoadEvidenceReadFailureHasTypedError(t *testing.T) {
|
||||||
_, err := LoadEvidence(filepath.Join(t.TempDir(), "missing.json"))
|
_, err := LoadEvidence(filepath.Join(t.TempDir(), "missing.json"))
|
||||||
var recovery *RecoveryError
|
var recovery *RecoveryError
|
||||||
|
|||||||
Reference in New Issue
Block a user