catalogd: HTML-safe escape fix + decisions tracker entry
Per 2026-05-03 step_7_8_retention_and_parity scrum (opus WARN on
parity_subject_audit.rs:canonical_json):
Go's json.Marshal HTML-escapes < > & to < > & by
default. Rust's serde_json::to_vec keeps them literal. Any audit
row with these chars in any string field would silently produce
different canonical bytes across runtimes → broken HMAC chain.
Latent because no production audit field has carried <>& yet, but
realistic for purpose strings ("error & retry") or trace_id values
("<HTTP-Request-Id>").
Fix: marshalNoEscapeHTML helper wraps json.Encoder.SetEscapeHTML(false)
+ trims trailing newline. Routed through writeCanonical for both
keys and scalar values.
Regression test: TestVerifyChain_HtmlChars_NotEscaped (purpose has &,
trace_id has <>) asserts the canonical bytes contain literal chars,
not escape sequences.
11 unit tests pass including the new one; parity probe still 6/6
byte-identical against live production audit logs.
Decisions tracker: added 2026-05-03 entry for SUBJECT_MANIFESTS_ON_CATALOGD
Steps 1-8 closure + 6th cross-runtime parity probe (was 5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
package catalogd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -203,9 +204,36 @@ func canonicalRowBytesFromStruct(row *SubjectAuditRow) ([]byte, error) {
|
||||
return canonicalRowBytesFromRaw(raw)
|
||||
}
|
||||
|
||||
// marshalNoEscapeHTML wraps json.Encoder with HTML escaping disabled.
|
||||
//
|
||||
// Why: Go's json.Marshal escapes `<`, `>`, `&` to `<`, `>`,
|
||||
// `&` by default. Rust's serde_json::to_vec keeps them literal.
|
||||
// Any string field containing one of those characters would produce
|
||||
// different canonical bytes across runtimes → broken HMAC chain.
|
||||
// (Caught 2026-05-03 by opus scrum WARN on parity_subject_audit.rs:
|
||||
// canonical_json — initially undetected because no production audit
|
||||
// field contained `<>&`, but realistic for purpose strings like
|
||||
// "error & retry" or trace_id "<HTTP-Request-Id>".)
|
||||
//
|
||||
// Also strips the trailing newline json.Encoder appends — that newline
|
||||
// is meaningful to JSONL consumers but is junk for hash input.
|
||||
func marshalNoEscapeHTML(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := buf.Bytes()
|
||||
return bytes.TrimRight(out, "\n"), nil
|
||||
}
|
||||
|
||||
// writeCanonical recursively writes v as canonical JSON: object keys
|
||||
// sorted alphabetically, no insignificant whitespace. Arrays preserve
|
||||
// element order (semantically significant per spec §3).
|
||||
//
|
||||
// All scalar emission goes through marshalNoEscapeHTML so the byte
|
||||
// sequence matches Rust's serde_json output character-for-character.
|
||||
func writeCanonical(buf *strings.Builder, v any) error {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
@@ -219,7 +247,7 @@ func writeCanonical(buf *strings.Builder, v any) error {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
ks, err := json.Marshal(k)
|
||||
ks, err := marshalNoEscapeHTML(k)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
@@ -242,9 +270,7 @@ func writeCanonical(buf *strings.Builder, v any) error {
|
||||
}
|
||||
buf.WriteByte(']')
|
||||
default:
|
||||
// json.Number, string, bool, nil — encoding/json renders these
|
||||
// the same way Rust's serde_json does (compact, RFC-8259-conformant).
|
||||
bs, err := json.Marshal(v)
|
||||
bs, err := marshalNoEscapeHTML(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal scalar: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user