← back to Cli Printing Press
fix(browsersniff): accept v2-shape traffic-analysis.json on load (#474) (#478)
63c9618cfc9d61745189c84b96df96bfe188601d · 2026-05-01 20:53:09 -0700 · Trevin Chow
Hand-authored or v2-binary-generated traffic-analysis.json files failed to
load on v3.x with five sequential schema errors, blocking generate until
the JSON was rewritten by hand. The food52 reprint hit all five in a row
and had to migrate them manually.
Add UnmarshalJSON to bridge the v2/v3 shape changes:
- ProtectionObservation.Notes: accept "..." (v2) or ["..."] (v3)
- RequestSequence.Notes: same shape compat as ProtectionObservation
- AnalysisWarning: accept "string" (v2, materialized as scope_note) or
object (v3)
- AuthAnalysis: accept candidate_types: [...] (v2, each materialized as
{type: <s>, confidence: 1.0}) alongside candidates: [...] (v3); v3
wins when both are present
- TrafficAnalysis.GenerationHints: accept {key: bool} map (v2,
flattened to a sorted slice of true keys) or []string (v3)
- TrafficAnalysis.Version: normalize "1.0" (v2) to "1" (v3) so the
ReadTrafficAnalysis version gate accepts both without relaxing
Mirrors the EvidenceRef sentinel pattern already established in this file.
Version-rejection still fires for genuinely unknown versions ("2", "9",
"draft") — only the "1.0" alias is normalized.
Coverage:
- Per-type table-driven tests for each shape change with v2 input,
v3 input, missing-field, and round-trip cases.
- TestReadTrafficAnalysis_V2ShapeFile end-to-end test that writes a
fully v2-shape file (matching the food52 prior reprint) to disk and
asserts the loader presents the v3 in-memory shape.
- TestReadTrafficAnalysis_VersionRejection confirms unknown versions
still fail.
Closes #474.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M internal/browsersniff/analysis.goA internal/browsersniff/analysis_v2_compat_test.go
Diff
commit 63c9618cfc9d61745189c84b96df96bfe188601d
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri May 1 20:53:09 2026 -0700
fix(browsersniff): accept v2-shape traffic-analysis.json on load (#474) (#478)
Hand-authored or v2-binary-generated traffic-analysis.json files failed to
load on v3.x with five sequential schema errors, blocking generate until
the JSON was rewritten by hand. The food52 reprint hit all five in a row
and had to migrate them manually.
Add UnmarshalJSON to bridge the v2/v3 shape changes:
- ProtectionObservation.Notes: accept "..." (v2) or ["..."] (v3)
- RequestSequence.Notes: same shape compat as ProtectionObservation
- AnalysisWarning: accept "string" (v2, materialized as scope_note) or
object (v3)
- AuthAnalysis: accept candidate_types: [...] (v2, each materialized as
{type: <s>, confidence: 1.0}) alongside candidates: [...] (v3); v3
wins when both are present
- TrafficAnalysis.GenerationHints: accept {key: bool} map (v2,
flattened to a sorted slice of true keys) or []string (v3)
- TrafficAnalysis.Version: normalize "1.0" (v2) to "1" (v3) so the
ReadTrafficAnalysis version gate accepts both without relaxing
Mirrors the EvidenceRef sentinel pattern already established in this file.
Version-rejection still fires for genuinely unknown versions ("2", "9",
"draft") — only the "1.0" alias is normalized.
Coverage:
- Per-type table-driven tests for each shape change with v2 input,
v3 input, missing-field, and round-trip cases.
- TestReadTrafficAnalysis_V2ShapeFile end-to-end test that writes a
fully v2-shape file (matching the food52 prior reprint) to disk and
asserts the loader presents the v3 in-memory shape.
- TestReadTrafficAnalysis_VersionRejection confirms unknown versions
still fail.
Closes #474.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
internal/browsersniff/analysis.go | 207 ++++++++++++++++++
internal/browsersniff/analysis_v2_compat_test.go | 254 +++++++++++++++++++++++
2 files changed, 461 insertions(+)
diff --git a/internal/browsersniff/analysis.go b/internal/browsersniff/analysis.go
index 7cde424a..22c3bed2 100644
--- a/internal/browsersniff/analysis.go
+++ b/internal/browsersniff/analysis.go
@@ -31,6 +31,106 @@ type TrafficAnalysis struct {
Warnings []AnalysisWarning `json:"warnings,omitempty"`
}
+// UnmarshalJSON normalizes two v2 shapes that v3 no longer emits but that
+// hand-authored or v2-binary-generated traffic analyses still carry:
+//
+// - `version: "1.0"` → normalized to "1" so the version check in
+// ReadTrafficAnalysis matches without rejecting otherwise-loadable input.
+// - `generation_hints: {key: bool}` (object) → flattened to a sorted slice
+// of true keys, matching the v3 derivation.
+//
+// See issue #474 for the broader compat story.
+func (t *TrafficAnalysis) UnmarshalJSON(data []byte) error {
+ type alias TrafficAnalysis
+ var legacy struct {
+ *alias
+ GenerationHints json.RawMessage `json:"generation_hints,omitempty"`
+ }
+ legacy.alias = (*alias)(t)
+ if err := json.Unmarshal(data, &legacy); err != nil {
+ return err
+ }
+ t.Version = normalizeTrafficAnalysisVersion(t.Version)
+ if len(legacy.GenerationHints) == 0 {
+ t.GenerationHints = nil
+ return nil
+ }
+ hints, err := unmarshalGenerationHints(legacy.GenerationHints)
+ if err != nil {
+ return fmt.Errorf("generation hints: %w", err)
+ }
+ t.GenerationHints = hints
+ return nil
+}
+
+// normalizeTrafficAnalysisVersion accepts the legacy "1.0" form (v2 binaries
+// emitted "1.0"; v3 emits "1") so the consumer-side version check doesn't
+// have to know about minor-version trivia.
+func normalizeTrafficAnalysisVersion(v string) string {
+ switch strings.TrimSpace(v) {
+ case "1.0":
+ return "1"
+ default:
+ return v
+ }
+}
+
+// unmarshalStringOrStringSlice accepts a JSON value that is either a string
+// (returned as a single-element slice) or a string slice. Used by Notes
+// fields to bridge the v2 "string" / v3 "[]string" shape change.
+func unmarshalStringOrStringSlice(data []byte) ([]string, error) {
+ if len(data) == 0 {
+ return nil, nil
+ }
+ if data[0] == '"' {
+ var s string
+ if err := json.Unmarshal(data, &s); err != nil {
+ return nil, fmt.Errorf("string form: %w", err)
+ }
+ if s == "" {
+ return nil, nil
+ }
+ return []string{s}, nil
+ }
+ var slice []string
+ if err := json.Unmarshal(data, &slice); err != nil {
+ return nil, fmt.Errorf("string-slice form: %w", err)
+ }
+ return slice, nil
+}
+
+// unmarshalGenerationHints accepts both the v3 `[]string` form and the v2
+// `map[string]bool` form. The map form (where each true entry was a derived
+// hint key) flattens to a sorted slice — matching what deriveGenerationHints
+// produces today.
+func unmarshalGenerationHints(data []byte) ([]string, error) {
+ if len(data) == 0 {
+ return nil, nil
+ }
+ if data[0] == '[' {
+ var slice []string
+ if err := json.Unmarshal(data, &slice); err != nil {
+ return nil, fmt.Errorf("slice form: %w", err)
+ }
+ return slice, nil
+ }
+ var legacyMap map[string]bool
+ if err := json.Unmarshal(data, &legacyMap); err != nil {
+ return nil, fmt.Errorf("legacy map form: %w", err)
+ }
+ if len(legacyMap) == 0 {
+ return nil, nil
+ }
+ out := make([]string, 0, len(legacyMap))
+ for k, v := range legacyMap {
+ if v {
+ out = append(out, k)
+ }
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
type TrafficAnalysisSummary struct {
TargetURL string `json:"target_url,omitempty"`
CapturedAt string `json:"captured_at,omitempty"`
@@ -124,6 +224,31 @@ type AuthAnalysis struct {
Candidates []AuthCandidate `json:"candidates,omitempty"`
}
+// UnmarshalJSON accepts v2-shape `auth.candidate_types: ["api_key", "none"]`
+// (a flat list of type strings) alongside v3-shape `auth.candidates: [{...}]`
+// (objects with type/confidence/evidence). Each legacy string is materialized
+// as `{type: <s>, confidence: 1.0}`. See issue #474.
+func (a *AuthAnalysis) UnmarshalJSON(data []byte) error {
+ var legacy struct {
+ Candidates []AuthCandidate `json:"candidates,omitempty"`
+ CandidateTypes []string `json:"candidate_types,omitempty"`
+ }
+ if err := json.Unmarshal(data, &legacy); err != nil {
+ return err
+ }
+ a.Candidates = legacy.Candidates
+ if len(a.Candidates) == 0 && len(legacy.CandidateTypes) > 0 {
+ a.Candidates = make([]AuthCandidate, 0, len(legacy.CandidateTypes))
+ for _, t := range legacy.CandidateTypes {
+ a.Candidates = append(a.Candidates, AuthCandidate{
+ Type: t,
+ Confidence: 1.0,
+ })
+ }
+ }
+ return nil
+}
+
type AuthCandidate struct {
Type string `json:"type"`
Confidence float64 `json:"confidence"`
@@ -148,6 +273,32 @@ type ProtectionObservation struct {
Notes []string `json:"notes,omitempty"`
}
+// UnmarshalJSON accepts v2-shape `notes: "..."` (single string) alongside
+// v3-shape `notes: ["...", ...]`. Hand-authored or v2-generated traffic
+// analyses with string notes were a real shape; rejecting them outright
+// forced manual conversion (see issue #474).
+func (p *ProtectionObservation) UnmarshalJSON(data []byte) error {
+ type alias ProtectionObservation
+ var legacy struct {
+ *alias
+ Notes json.RawMessage `json:"notes,omitempty"`
+ }
+ legacy.alias = (*alias)(p)
+ if err := json.Unmarshal(data, &legacy); err != nil {
+ return err
+ }
+ if len(legacy.Notes) > 0 {
+ notes, err := unmarshalStringOrStringSlice(legacy.Notes)
+ if err != nil {
+ return fmt.Errorf("protection notes: %w", err)
+ }
+ p.Notes = notes
+ } else {
+ p.Notes = nil
+ }
+ return nil
+}
+
type EndpointCluster struct {
Host string `json:"host,omitempty"`
Method string `json:"method"`
@@ -180,6 +331,31 @@ type RequestSequence struct {
Notes []string `json:"notes,omitempty"`
}
+// UnmarshalJSON accepts v2-shape `notes: "..."` (single string) alongside
+// v3-shape `notes: ["...", ...]`. See ProtectionObservation.UnmarshalJSON
+// and issue #474 for the broader compat rationale.
+func (r *RequestSequence) UnmarshalJSON(data []byte) error {
+ type alias RequestSequence
+ var legacy struct {
+ *alias
+ Notes json.RawMessage `json:"notes,omitempty"`
+ }
+ legacy.alias = (*alias)(r)
+ if err := json.Unmarshal(data, &legacy); err != nil {
+ return err
+ }
+ if len(legacy.Notes) > 0 {
+ notes, err := unmarshalStringOrStringSlice(legacy.Notes)
+ if err != nil {
+ return fmt.Errorf("request sequence notes: %w", err)
+ }
+ r.Notes = notes
+ } else {
+ r.Notes = nil
+ }
+ return nil
+}
+
type PaginationSignal struct {
Location string `json:"location"`
Name string `json:"name"`
@@ -202,6 +378,37 @@ type AnalysisWarning struct {
Evidence []EvidenceRef `json:"evidence,omitempty"`
}
+// UnmarshalJSON accepts v2-shape `warnings: ["...", "..."]` (flat strings)
+// alongside v3-shape `warnings: [{type, message, confidence, evidence}]`
+// (objects). Legacy strings are materialized as
+// `{type: "scope_note", message: <s>, confidence: 1.0, evidence: [<s>]}` —
+// the same shape the v2-to-v3 migration script used. See issue #474.
+func (w *AnalysisWarning) UnmarshalJSON(data []byte) error {
+ if len(data) > 0 && data[0] == '"' {
+ var s string
+ if err := json.Unmarshal(data, &s); err != nil {
+ return fmt.Errorf("analysis warning string: %w", err)
+ }
+ *w = AnalysisWarning{
+ Type: "scope_note",
+ Message: s,
+ Confidence: 1.0,
+ Evidence: []EvidenceRef{{
+ EntryIndex: EvidenceRefStringSentinel,
+ Reason: s,
+ }},
+ }
+ return nil
+ }
+ type alias AnalysisWarning
+ var a alias
+ if err := json.Unmarshal(data, &a); err != nil {
+ return fmt.Errorf("analysis warning object: %w", err)
+ }
+ *w = AnalysisWarning(a)
+ return nil
+}
+
func AnalyzeTraffic(capture *EnrichedCapture) (*TrafficAnalysis, error) {
if capture == nil {
return nil, fmt.Errorf("capture is required")
diff --git a/internal/browsersniff/analysis_v2_compat_test.go b/internal/browsersniff/analysis_v2_compat_test.go
new file mode 100644
index 00000000..00068085
--- /dev/null
+++ b/internal/browsersniff/analysis_v2_compat_test.go
@@ -0,0 +1,254 @@
+package browsersniff
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestProtectionObservation_NotesV2Compat covers the food52-reprint repro
+// from issue #474: prior traffic-analysis files emit `notes: "..."` (a single
+// string) where v3 expects `notes: ["..."]` (a slice).
+func TestProtectionObservation_NotesV2Compat(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ json string
+ want []string
+ }{
+ {
+ name: "v2 single string",
+ json: `{"label":"vercel_bot_mitigation","confidence":1.0,"notes":"x-vercel-mitigated: challenge"}`,
+ want: []string{"x-vercel-mitigated: challenge"},
+ },
+ {
+ name: "v3 slice",
+ json: `{"label":"vercel_bot_mitigation","confidence":1.0,"notes":["a","b"]}`,
+ want: []string{"a", "b"},
+ },
+ {
+ name: "missing notes",
+ json: `{"label":"x","confidence":0.5}`,
+ want: nil,
+ },
+ {
+ name: "empty string notes — treated as no notes",
+ json: `{"label":"x","confidence":0.5,"notes":""}`,
+ want: nil,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var p ProtectionObservation
+ require.NoError(t, json.Unmarshal([]byte(tc.json), &p))
+ assert.Equal(t, tc.want, p.Notes)
+
+ // Round-trip MarshalJSON → UnmarshalJSON: should remain the v3 shape.
+ rt, err := json.Marshal(&p)
+ require.NoError(t, err)
+ var p2 ProtectionObservation
+ require.NoError(t, json.Unmarshal(rt, &p2))
+ assert.Equal(t, p.Notes, p2.Notes)
+ })
+ }
+}
+
+// TestRequestSequence_NotesV2Compat mirrors the protection-observation case
+// for the second Notes []string site.
+func TestRequestSequence_NotesV2Compat(t *testing.T) {
+ t.Parallel()
+
+ var rs RequestSequence
+ require.NoError(t, json.Unmarshal([]byte(`{"label":"x","confidence":1.0,"notes":"single legacy note"}`), &rs))
+ assert.Equal(t, []string{"single legacy note"}, rs.Notes)
+
+ var rs2 RequestSequence
+ require.NoError(t, json.Unmarshal([]byte(`{"label":"x","confidence":1.0,"notes":["a","b","c"]}`), &rs2))
+ assert.Equal(t, []string{"a", "b", "c"}, rs2.Notes)
+}
+
+// TestAnalysisWarning_StringV2Compat covers the v2-shape `warnings: [<str>]`
+// where v3 expects `warnings: [{type, message, confidence, evidence}]`.
+func TestAnalysisWarning_StringV2Compat(t *testing.T) {
+ t.Parallel()
+
+ t.Run("v2 string materializes to scope_note", func(t *testing.T) {
+ var w AnalysisWarning
+ require.NoError(t, json.Unmarshal([]byte(`"Hotline /hotline returns only siteSettings"`), &w))
+ assert.Equal(t, "scope_note", w.Type)
+ assert.Equal(t, "Hotline /hotline returns only siteSettings", w.Message)
+ assert.Equal(t, 1.0, w.Confidence)
+ require.Len(t, w.Evidence, 1)
+ assert.Equal(t, EvidenceRefStringSentinel, w.Evidence[0].EntryIndex)
+ assert.Equal(t, "Hotline /hotline returns only siteSettings", w.Evidence[0].Reason)
+ })
+
+ t.Run("v3 object preserved as-is", func(t *testing.T) {
+ var w AnalysisWarning
+ require.NoError(t, json.Unmarshal([]byte(`{"type":"weak_schema_evidence","message":"only one fixture","confidence":0.6}`), &w))
+ assert.Equal(t, "weak_schema_evidence", w.Type)
+ assert.Equal(t, "only one fixture", w.Message)
+ assert.Equal(t, 0.6, w.Confidence)
+ assert.Empty(t, w.Evidence)
+ })
+
+ t.Run("slice of mixed shapes round-trips", func(t *testing.T) {
+ input := `[{"type":"a","message":"obj","confidence":0.5},"legacy string"]`
+ var ws []AnalysisWarning
+ require.NoError(t, json.Unmarshal([]byte(input), &ws))
+ require.Len(t, ws, 2)
+ assert.Equal(t, "a", ws[0].Type)
+ assert.Equal(t, "scope_note", ws[1].Type)
+ assert.Equal(t, "legacy string", ws[1].Message)
+ })
+}
+
+// TestAuthAnalysis_CandidateTypesV2Compat covers the v2-shape
+// `auth.candidate_types: ["api_key", "none"]` materializing into v3's
+// `auth.candidates: [{type:..., confidence:1.0}]`.
+func TestAuthAnalysis_CandidateTypesV2Compat(t *testing.T) {
+ t.Parallel()
+
+ t.Run("v2 candidate_types materializes to candidates", func(t *testing.T) {
+ var a AuthAnalysis
+ require.NoError(t, json.Unmarshal([]byte(`{"candidate_types":["none"]}`), &a))
+ require.Len(t, a.Candidates, 1)
+ assert.Equal(t, "none", a.Candidates[0].Type)
+ assert.Equal(t, 1.0, a.Candidates[0].Confidence)
+ })
+
+ t.Run("v3 candidates wins when both shapes present", func(t *testing.T) {
+ input := `{"candidates":[{"type":"api_key","confidence":0.9}],"candidate_types":["fallback_should_not_show"]}`
+ var a AuthAnalysis
+ require.NoError(t, json.Unmarshal([]byte(input), &a))
+ require.Len(t, a.Candidates, 1)
+ assert.Equal(t, "api_key", a.Candidates[0].Type)
+ assert.Equal(t, 0.9, a.Candidates[0].Confidence)
+ })
+
+ t.Run("missing both fields is empty", func(t *testing.T) {
+ var a AuthAnalysis
+ require.NoError(t, json.Unmarshal([]byte(`{}`), &a))
+ assert.Empty(t, a.Candidates)
+ })
+}
+
+// TestTrafficAnalysis_GenerationHintsMapCompat covers `generation_hints` shape
+// drift: v2 emitted an object map (key→bool); v3 emits a flat sorted slice
+// derived from a fixed vocabulary (deriveGenerationHints).
+func TestTrafficAnalysis_GenerationHintsMapCompat(t *testing.T) {
+ t.Parallel()
+
+ t.Run("v2 map flattens to sorted slice of true keys", func(t *testing.T) {
+ input := `{"version":"1","summary":{},"protocols":[],"auth":{},"endpoint_clusters":[],"generation_hints":{"requires_browser_compatible_http":true,"browser_http_transport":true,"client_pattern":false}}`
+ var ta TrafficAnalysis
+ require.NoError(t, json.Unmarshal([]byte(input), &ta))
+ assert.Equal(t, []string{"browser_http_transport", "requires_browser_compatible_http"}, ta.GenerationHints)
+ })
+
+ t.Run("v3 slice preserved as-is", func(t *testing.T) {
+ input := `{"version":"1","summary":{},"protocols":[],"auth":{},"endpoint_clusters":[],"generation_hints":["browser_http_transport"]}`
+ var ta TrafficAnalysis
+ require.NoError(t, json.Unmarshal([]byte(input), &ta))
+ assert.Equal(t, []string{"browser_http_transport"}, ta.GenerationHints)
+ })
+}
+
+// TestTrafficAnalysis_VersionNormalization confirms that v2's "1.0" loads
+// alongside v3's "1" without forcing the consumer to relax the version check.
+func TestTrafficAnalysis_VersionNormalization(t *testing.T) {
+ t.Parallel()
+
+ cases := map[string]string{
+ "1": "1",
+ "1.0": "1", // v2 binaries emitted "1.0"; normalize on load
+ }
+ for in, want := range cases {
+ t.Run("version "+in, func(t *testing.T) {
+ input := `{"version":"` + in + `","summary":{},"protocols":[],"auth":{},"endpoint_clusters":[]}`
+ var ta TrafficAnalysis
+ require.NoError(t, json.Unmarshal([]byte(input), &ta))
+ assert.Equal(t, want, ta.Version)
+ })
+ }
+}
+
+// TestReadTrafficAnalysis_V2ShapeFile is the end-to-end test: write a fully
+// v2-shape file to disk, ReadTrafficAnalysis loads it without error and
+// presents the v3 in-memory shape. This is the failure mode that made the
+// food52 reprint patch its prior traffic-analysis.json by hand five times.
+func TestReadTrafficAnalysis_V2ShapeFile(t *testing.T) {
+ t.Parallel()
+
+ v2Body := `{
+ "version": "1.0",
+ "summary": {"target_url": "https://example.com"},
+ "protocols": [
+ {"label": "ssr_embedded_data", "confidence": 1.0, "notes": "legacy notes string on protocol — should be ignored"}
+ ],
+ "auth": {
+ "candidate_types": ["none"]
+ },
+ "protections": [
+ {"label": "vercel_bot_mitigation", "confidence": 1.0, "notes": "x-vercel-mitigated: challenge"}
+ ],
+ "endpoint_clusters": [],
+ "generation_hints": {
+ "browser_http_transport": true,
+ "requires_runtime_key_discovery": true,
+ "deprecated_hint": false
+ },
+ "warnings": [
+ "Hotline returns only siteSettings",
+ "Shop deferred to v2"
+ ]
+ }`
+
+ dir := t.TempDir()
+ fp := filepath.Join(dir, "traffic-analysis.json")
+ require.NoError(t, os.WriteFile(fp, []byte(v2Body), 0o600))
+
+ analysis, err := ReadTrafficAnalysis(fp)
+ require.NoError(t, err)
+ require.NotNil(t, analysis)
+
+ assert.Equal(t, "1", analysis.Version, "v2 1.0 should normalize to 1")
+ require.Len(t, analysis.Protections, 1)
+ assert.Equal(t, []string{"x-vercel-mitigated: challenge"}, analysis.Protections[0].Notes)
+ require.Len(t, analysis.Auth.Candidates, 1)
+ assert.Equal(t, "none", analysis.Auth.Candidates[0].Type)
+ assert.Equal(t, []string{"browser_http_transport", "requires_runtime_key_discovery"}, analysis.GenerationHints)
+ require.Len(t, analysis.Warnings, 2)
+ for _, w := range analysis.Warnings {
+ assert.Equal(t, "scope_note", w.Type)
+ assert.NotEmpty(t, w.Message)
+ }
+}
+
+// TestReadTrafficAnalysis_VersionRejection confirms the version gate still
+// rejects unknown versions — the compat layer normalizes "1.0" but doesn't
+// silently accept "2" or "9".
+func TestReadTrafficAnalysis_VersionRejection(t *testing.T) {
+ t.Parallel()
+
+ cases := []string{"2", "9", "0.5", "draft"}
+ for _, v := range cases {
+ t.Run("version "+v, func(t *testing.T) {
+ body := `{"version":"` + v + `","summary":{},"protocols":[],"auth":{},"endpoint_clusters":[]}`
+ dir := t.TempDir()
+ fp := filepath.Join(dir, "ta.json")
+ require.NoError(t, os.WriteFile(fp, []byte(body), 0o600))
+
+ _, err := ReadTrafficAnalysis(fp)
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), "unsupported traffic analysis version"),
+ "want version-rejection error, got: %v", err)
+ })
+ }
+}
← 6d45d228 fix(regenmerge): correct owner rewrite + skip injection on p
·
back to Cli Printing Press
·
chore(main): release 3.3.0 (#469) 691ee25a →