From e0019abe316564d68689c9fb7438d1502d24d4a3 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 3 Nov 2026 00:18:47 +0000 Subject: [PATCH] Validate byte-preserving record fields as base64 strings --- docs/integrations/publication-records.md | 4 ++- docs/roadmap/implementation.md | 18 ++++++++-- docs/roadmap/remediation.md | 12 +++++-- internal/publish/record.go | 12 +++++-- internal/publish/record_test.go | 45 ++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 8 deletions(-) diff --git a/docs/integrations/publication-records.md b/docs/integrations/publication-records.md index 3a37751..fa66cd6 100644 --- a/docs/integrations/publication-records.md +++ b/docs/integrations/publication-records.md @@ -42,7 +42,9 @@ endpoint bytes uses version 2 with the same `evidence` object and an additional `evidence_bytes` object. Each entry in `evidence_bytes` uses an evidence field name and a standard base64 string containing its exact bytes. That entry supplies the field's value when loading; the writer omits its lossy string form -from `evidence`. Unknown field names are ignored. This representation applies +from `evidence`. Invalid known-field types or base64 encodings produce a +record-format error with the available decoding cause. Unknown field names are +ignored. This representation applies to every evidence field and preserves the same identity as typed Go evidence. Older CLIs that support only version 1 report an unsupported version for these byte-containing records; use a CLI supporting version 2 to load them. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d2be02b..2e88938 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -140,7 +140,11 @@ mismatch tests confirm that no remote tag query occurs. without relying on diagnostic output. 1. Add record encoding and storage helpers under `internal/publish`. Use a JSON - envelope with integer `format_version: 1` and an `evidence` object. Its fields + envelope with integer `format_version` and an `evidence` object. Ordinary + UTF-8 evidence uses version 1; non-UTF-8 fields use version 2 and a separate + `evidence_bytes` object of base64 strings, preserving exact supported bytes. + See the [canonical format](../integrations/publication-records.md#private-attempt-record). + The evidence fields are `repository_dir`, `module_dir`, `module_path`, `version`, `tag`, `candidate_commit`, `tag_object`, `endpoint`, `remote`, and `recorded_at`. Store the exact endpoint. Fields other than the envelope version/object can @@ -150,7 +154,10 @@ without relying on diagnostic output. malformed JSON, and structural type errors are `record_format` failures. Accept additional fields within a supported version. Semantic evidence completeness is evaluated by reconciliation, so a readable partial record - remains usable for discovery and any classification it can support. + remains usable for discovery and any classification it can support. Decode + known version-2 byte fields as strings, then decode standard base64. Invalid + structural types and malformed base64 return `record_format` with their + available causes. Additional unknown fields retain their existing handling. 3. Resolve the default directory with `git rev-parse --path-format=absolute --git-common-dir`, then append `releasekit/publication-attempts`. Use Git metadata discovery rather than @@ -192,6 +199,11 @@ ignored, and unignored override. Use injected failures at the small file/Git boundary when platform permissions cannot reliably induce a failure. Verify that private records retain synthetic credentials while diagnostics redact them. +**R17 follow-up completed:** Known byte fields validate their string representation +before base64 decoding. Tests cover valid/empty strings, numeric arrays, other +structural errors, and retained JSON/base64 causes. Existing all-field byte +round trips and original-versus-decoy Git endpoint observations remain covered. + ## Stage 3: Add the facade-owned recovery interfaces **Completed:** 2026-11-02 @@ -522,6 +534,8 @@ contracts and a short consumer recovery workflow. explicit push readiness and fresh later invocations. R16 covers setup failure classifications and human/JSON guidance without querying an invalid or substituted endpoint. + R17 adds byte-field structural and encoding failures at the codec boundary, + reusing the existing partial-record and lossless-observation fixtures. 2. Establish read-only behavior through resulting refs, worktree/branch state, saved records, and controlled observations of candidate/hosted boundaries. Reuse lower-level classification and codec tests. Tests use local remotes, diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index f1ec802..128df82 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -1,8 +1,8 @@ # Publication reconciliation remediation -Status: open. R01–R13 are addressed and individually verified. R14–R17 record -remaining findings from the subsequent stage-by-stage review of `363f7cd`. -Address the two handoff defects first, then the classification and format gaps. +Status: all corrections are implemented and individually verified; final +assembled verification is pending. R01–R13 retain their completion evidence. +R14–R17 describe the subsequent stage-by-stage review of `363f7cd` and its fixes. R01–R13 describe commits `4dff808` through `599ad81`, against planning baseline `447b42a`. Their audit evidence and defect descriptions refer to that reviewed @@ -734,6 +734,12 @@ reaching a substituted endpoint when a rewrite mismatch is detected. ## R17: Validate the documented version-2 byte-field representation — P3 +**Completed:** Known byte fields load through explicit JSON string validation and +standard base64 decoding. Tests passed for valid/empty strings, numeric arrays, +other structural types, malformed base64, and retained decoding causes, alongside +existing partial/unknown-field, version-1, and lossless endpoint regressions. +Stage 2/8 instructions and the canonical format guide are updated. + **Relevant code:** `internal/publish/record.go:48–59`; [private record format](../integrations/publication-records.md#private-attempt-record). Affects Stage 2 and its Stage 8 format coverage. diff --git a/internal/publish/record.go b/internal/publish/record.go index 30b04f2..cf714e0 100644 --- a/internal/publish/record.go +++ b/internal/publish/record.go @@ -3,6 +3,7 @@ package publish import ( "context" "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -52,8 +53,15 @@ func LoadEvidence(path string) (Evidence, error) { } for _, field := range evidenceFields(&evidence) { if raw, ok := fields[field.name]; ok { - var bytes []byte - if err := json.Unmarshal(raw, &bytes); err != nil { + var encoded *string + if err := json.Unmarshal(raw, &encoded); err != nil { + return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record field %s bytes %q: %w", field.name, path, err)} + } + if encoded == nil { + return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record field %s bytes %q must be a base64 string", field.name, path)} + } + bytes, err := base64.StdEncoding.DecodeString(*encoded) + if err != nil { return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record field %s bytes %q: %w", field.name, path, err)} } *field.value = string(bytes) diff --git a/internal/publish/record_test.go b/internal/publish/record_test.go index fd38570..44116d7 100644 --- a/internal/publish/record_test.go +++ b/internal/publish/record_test.go @@ -2,6 +2,7 @@ package publish import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -14,6 +15,50 @@ import ( "testing" ) +func TestLoadEvidenceValidatesKnownByteFieldsAsBase64Strings(t *testing.T) { + for _, test := range []struct{ name, value, want, failure string }{ + {"valid", `"djEuMi4z"`, "v1.2.3", ""}, + {"empty", `""`, "", ""}, + {"malformed base64", `"not base64!"`, "", "base64"}, + {"numeric array", `[118,49,46,50,46,51]`, "", "type"}, + {"object", `{}`, "", "type"}, + {"boolean", `true`, "", "type"}, + {"number", `123`, "", "type"}, + {"null", `null`, "", "schema"}, + } { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "record.json") + contents := `{"format_version":2,"evidence":{},"evidence_bytes":{"tag":` + test.value + `}}` + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + loaded, err := LoadEvidence(path) + if test.failure == "" { + if err != nil || loaded.Tag != test.want { + t.Fatalf("byte string = %#v, %v", loaded, err) + } + return + } + var typed *RecoveryError + if !errors.As(err, &typed) || typed.Kind != "record_format" || typed.Unwrap() == nil { + t.Fatalf("byte format failure = %v", err) + } + switch test.failure { + case "type": + var cause *json.UnmarshalTypeError + if !errors.As(err, &cause) { + t.Fatalf("lost JSON type cause: %v", err) + } + case "base64": + var cause base64.CorruptInputError + if !errors.As(err, &cause) { + t.Fatalf("lost base64 cause: %v", err) + } + } + }) + } +} + func TestRecordsRoundTripNonUTF8Evidence(t *testing.T) { repo := recordRepo(t) raw := "path-\xff"