diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 49baff8..e2b6fc5 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -351,7 +351,7 @@ feature and its per-user and system-wide deployment guidance. ## Stage 9: Redact invalid cache-record diagnostics -**Status:** Pending +**Status:** Complete ### Objective diff --git a/internal/cli/chunk_cache_test.go b/internal/cli/chunk_cache_test.go index 2547d27..a2b9dcf 100644 --- a/internal/cli/chunk_cache_test.go +++ b/internal/cli/chunk_cache_test.go @@ -255,6 +255,22 @@ func TestRunRecordsExplicitChunkCacheOverrideAndEffectiveMode(t *testing.T) { } } +func TestRunChunkPlanDiagnosticsRedactStoreDecisionReason(t *testing.T) { + diagnosticsDir := t.TempDir() + configPath := writeTestConfig(t, cacheTestConfig("auto", t.TempDir(), diagnosticsDir)) + factory := &recordingChunkPlanFactory{store: &recordingChunkPlanStore{ + decision: pipeline.ChunkPlanDecision{Status: pipeline.ChunkPlanInvalid, Reason: "SENTINEL_INVALID_RECORD_CONTENT"}, + }} + code, stderr := runCacheCommand(t, cacheRunArgs(t, configPath), cacheTestOptions(t, nil, factory)) + if code != 0 || stderr != "" { + t.Fatalf("code=%d stderr=%q", code, stderr) + } + summary := string(readFile(t, filepath.Join(onlyChildDir(t, diagnosticsDir), diagnostics.ArtifactChunkPlan))) + if strings.Contains(summary, "SENTINEL_INVALID_RECORD_CONTENT") || !strings.Contains(summary, `"lookup_reason": "stored chunk plan is invalid"`) { + t.Fatalf("chunk plan diagnostic = %s", summary) + } +} + func TestDefaultAutoReusesPlanAcrossIndependentInvocations(t *testing.T) { cacheBase := filepath.Join(t.TempDir(), "cache") workspaceDir := filepath.Join(t.TempDir(), "workspace") diff --git a/internal/framework/chunkplan/store.go b/internal/framework/chunkplan/store.go index 97a9697..bc027ba 100644 --- a/internal/framework/chunkplan/store.go +++ b/internal/framework/chunkplan/store.go @@ -40,7 +40,7 @@ func (s *filesystemStore) Load(sourceDigest string) (pipeline.ChunkPlanRecord, p data, err := os.ReadFile(target) if err != nil { if os.IsNotExist(err) { - return pipeline.ChunkPlanRecord{}, pipeline.ChunkPlanDecision{Status: pipeline.ChunkPlanMissing, Reason: "chunk plan not found"}, nil + return pipeline.ChunkPlanRecord{}, pipeline.ChunkPlanDecision{Status: pipeline.ChunkPlanMissing, Reason: lookupReason(pipeline.ChunkPlanMissing)}, nil } return pipeline.ChunkPlanRecord{}, pipeline.ChunkPlanDecision{}, fmt.Errorf("read chunk plan: %w", err) } @@ -50,19 +50,16 @@ func (s *filesystemStore) Load(sourceDigest string) (pipeline.ChunkPlanRecord, p decoder.DisallowUnknownFields() decoder.UseNumber() if err := decoder.Decode(&record); err != nil { - return invalidDecision(fmt.Sprintf("decode stored chunk plan: %v", err)) + return invalidDecision() } var trailing any if err := decoder.Decode(&trailing); err != io.EOF { - if err == nil { - return invalidDecision("stored chunk plan contains trailing JSON") - } - return invalidDecision(fmt.Sprintf("decode stored chunk plan trailer: %v", err)) + return invalidDecision() } if err := validateRecord(record, sourceDigest); err != nil { - return invalidDecision(err.Error()) + return invalidDecision() } - return record, pipeline.ChunkPlanDecision{Status: pipeline.ChunkPlanHit, Reason: "stored chunk plan is valid"}, nil + return record, pipeline.ChunkPlanDecision{Status: pipeline.ChunkPlanHit, Reason: lookupReason(pipeline.ChunkPlanHit)}, nil } func (s *filesystemStore) Save(record pipeline.ChunkPlanRecord) error { @@ -165,8 +162,21 @@ func validateRecord(record pipeline.ChunkPlanRecord, requestedDigest string) err return nil } -func invalidDecision(reason string) (pipeline.ChunkPlanRecord, pipeline.ChunkPlanDecision, error) { - return pipeline.ChunkPlanRecord{}, pipeline.ChunkPlanDecision{Status: pipeline.ChunkPlanInvalid, Reason: reason}, nil +func invalidDecision() (pipeline.ChunkPlanRecord, pipeline.ChunkPlanDecision, error) { + return pipeline.ChunkPlanRecord{}, pipeline.ChunkPlanDecision{Status: pipeline.ChunkPlanInvalid, Reason: lookupReason(pipeline.ChunkPlanInvalid)}, nil +} + +func lookupReason(status pipeline.ChunkPlanStatus) string { + switch status { + case pipeline.ChunkPlanHit: + return "stored chunk plan is valid" + case pipeline.ChunkPlanMissing: + return "chunk plan not found" + case pipeline.ChunkPlanInvalid: + return "stored chunk plan is invalid" + default: + return "chunk plan lookup skipped" + } } type atomicWriteHooks struct { diff --git a/internal/framework/chunkplan/store_test.go b/internal/framework/chunkplan/store_test.go index 6634215..9c4973f 100644 --- a/internal/framework/chunkplan/store_test.go +++ b/internal/framework/chunkplan/store_test.go @@ -122,14 +122,13 @@ func TestFilesystemStoreReportsInvalidRecordsAsRecoverable(t *testing.T) { tests := []struct { name string mutate func([]byte) []byte - want string }{ {name: "unknown field", mutate: func(data []byte) []byte { - return bytes.Replace(data, []byte(`{"schema_version"`), []byte(`{"unknown":true,"schema_version"`), 1) - }, want: "unknown"}, - {name: "truncated JSON", mutate: func(data []byte) []byte { return data[:len(data)/2] }, want: "decode"}, - {name: "schema mismatch", mutate: replaceJSON(`notarius.chunk-plan.v1`, `notarius.chunk-plan.v2`), want: "schema_version"}, - {name: "source mismatch", mutate: replaceJSON(testSourceDigest, "sha256:"+strings.Repeat("b", 64)), want: "source_digest"}, + return bytes.Replace(data, []byte(`{"schema_version"`), []byte(`{"SENTINEL_UNKNOWN_FIELD":true,"schema_version"`), 1) + }}, + {name: "truncated JSON", mutate: func(data []byte) []byte { return data[:len(data)/2] }}, + {name: "schema mismatch", mutate: replaceJSON(`notarius.chunk-plan.v1`, `SENTINEL_SCHEMA_VALUE`)}, + {name: "source mismatch", mutate: replaceJSON(testSourceDigest, "sha256:"+strings.Repeat("b", 64))}, {name: "plan digest mismatch", mutate: func(data []byte) []byte { prefix := []byte(`"plan_digest":"sha256:`) index := bytes.Index(data, prefix) @@ -137,14 +136,15 @@ func TestFilesystemStoreReportsInvalidRecordsAsRecoverable(t *testing.T) { data[index+len(prefix)] = '0' } return data - }, want: "plan_digest"}, + }}, {name: "noncanonical annotation", mutate: func(data []byte) []byte { - return bytes.Replace(data, []byte(`{"value":1}`), []byte(`{ "value": 1 }`), 1) - }, want: "canonical JSON"}, + return bytes.Replace(data, []byte(`"test/value"`), []byte(`"SENTINEL_ANNOTATION_NAMESPACE"`), 1) + }}, {name: "bad boundary", mutate: func(data []byte) []byte { return bytes.Replace(data, []byte(`"start_unit_id":1`), []byte(`"start_unit_id":0`), 1) - }, want: "boundaries must be positive"}, - {name: "trailing JSON", mutate: func(data []byte) []byte { return append(data, []byte(` {}`)...) }, want: "trailing"}, + }}, + {name: "timestamp", mutate: replaceJSON(`2026-07-18T12:00:00Z`, `SENTINEL_TIMESTAMP`)}, + {name: "trailing JSON", mutate: func(data []byte) []byte { return append(data, []byte(` {"SENTINEL_TRAILING":true}`)...) }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -163,9 +163,14 @@ func TestFilesystemStoreReportsInvalidRecordsAsRecoverable(t *testing.T) { t.Fatal(err) } got, decision, err := store.Load(testSourceDigest) - if err != nil || decision.Status != pipeline.ChunkPlanInvalid || !reflect.DeepEqual(got, pipeline.ChunkPlanRecord{}) || !strings.Contains(decision.Reason, tc.want) { + if err != nil || decision.Status != pipeline.ChunkPlanInvalid || !reflect.DeepEqual(got, pipeline.ChunkPlanRecord{}) || decision.Reason != "stored chunk plan is invalid" { t.Fatalf("record=%#v decision=%#v error=%v", got, decision, err) } + for _, sentinel := range []string{"SENTINEL_UNKNOWN_FIELD", "SENTINEL_SCHEMA_VALUE", "SENTINEL_ANNOTATION_NAMESPACE", "SENTINEL_TIMESTAMP", "SENTINEL_TRAILING"} { + if strings.Contains(decision.Reason, sentinel) { + t.Fatalf("decision leaked %q: %#v", sentinel, decision) + } + } }) } } diff --git a/internal/framework/pipeline/runner_chunk_plan.go b/internal/framework/pipeline/runner_chunk_plan.go index c16b625..54598e7 100644 --- a/internal/framework/pipeline/runner_chunk_plan.go +++ b/internal/framework/pipeline/runner_chunk_plan.go @@ -47,11 +47,11 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S if mode == ChunkCacheAuto { record, decision, err := input.ChunkPlans.Load(doc.Digest) result.lookup = decision - result.summary.LookupStatus = string(decision.Status) - result.summary.LookupReason = decision.Reason + result.summary.LookupStatus = chunkPlanLookupStatus(decision.Status) + result.summary.LookupReason = chunkPlanLookupReason(decision.Status) if err != nil { result.summary.LookupStatus = "skipped" - result.summary.LookupReason = "chunk plan lookup failed" + result.summary.LookupReason = chunkPlanLookupReason("") return result, fmt.Errorf("load chunk plan: %w", err) } switch decision.Status { @@ -68,13 +68,13 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S result.setValidation(validationWarnings, rejection, err) return result, err } - result.lookup = ChunkPlanDecision{Status: ChunkPlanInvalid, Reason: "stored chunk plan cannot be materialized against the current source"} + result.lookup = ChunkPlanDecision{Status: ChunkPlanInvalid, Reason: chunkPlanLookupReason(ChunkPlanInvalid)} result.summary.LookupStatus = "invalid" - result.summary.LookupReason = result.lookup.Reason + result.summary.LookupReason = chunkPlanLookupReason(ChunkPlanInvalid) case ChunkPlanMissing, ChunkPlanInvalid: // Generate below. default: - return result, fmt.Errorf("load chunk plan returned unsupported status %q", decision.Status) + return result, fmt.Errorf("load chunk plan returned unsupported decision status") } } if mode == ChunkCacheAuto || mode == ChunkCacheRefresh { @@ -171,6 +171,32 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S return result, nil } +func chunkPlanLookupStatus(status ChunkPlanStatus) string { + switch status { + case ChunkPlanHit: + return "hit" + case ChunkPlanMissing: + return "missing" + case ChunkPlanInvalid: + return "invalid" + default: + return "skipped" + } +} + +func chunkPlanLookupReason(status ChunkPlanStatus) string { + switch status { + case ChunkPlanHit: + return "stored chunk plan is valid" + case ChunkPlanMissing: + return "chunk plan not found" + case ChunkPlanInvalid: + return "stored chunk plan is invalid" + default: + return "chunk plan lookup skipped" + } +} + func (result *chunkPlanExecution) setCandidate(record ChunkPlanRecord, action string) { cloned := cloneChunkPlanRecord(record) result.record = &cloned diff --git a/internal/framework/pipeline/runner_chunk_plan_test.go b/internal/framework/pipeline/runner_chunk_plan_test.go index 6acef92..b83ec0d 100644 --- a/internal/framework/pipeline/runner_chunk_plan_test.go +++ b/internal/framework/pipeline/runner_chunk_plan_test.go @@ -143,6 +143,43 @@ func TestRunnerChunkPlanModeMatrix(t *testing.T) { } } +func TestRunnerUsesStatusDerivedChunkPlanSummaryReasons(t *testing.T) { + for _, tc := range []struct { + name string + status ChunkPlanStatus + wantStatus string + wantReason string + }{ + {name: "hit", status: ChunkPlanHit, wantStatus: "hit", wantReason: "stored chunk plan is valid"}, + {name: "missing", status: ChunkPlanMissing, wantStatus: "missing", wantReason: "chunk plan not found"}, + {name: "invalid", status: ChunkPlanInvalid, wantStatus: "invalid", wantReason: "stored chunk plan is invalid"}, + } { + t.Run(tc.name, func(t *testing.T) { + prepared, plan := preparedTerminalDebugPipeline(t) + store := &recordingChunkPlanStore{ + record: chunkPlanRecord(t, prepared, plan), + decision: ChunkPlanDecision{Status: tc.status, Reason: "SENTINEL_STORE_REASON"}, + } + output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store}) + if err != nil { + t.Fatal(err) + } + if output.ChunkPlan.LookupStatus != tc.wantStatus || output.ChunkPlan.LookupReason != tc.wantReason || strings.Contains(output.ChunkPlan.LookupReason, "SENTINEL_STORE_REASON") { + t.Fatalf("summary = %#v", output.ChunkPlan) + } + }) + } + + prepared, _ := preparedTerminalDebugPipeline(t) + output, err := New().Run(context.Background(), RunInput{ + Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, + ChunkPlans: &recordingChunkPlanStore{loadErr: errors.New("read failed")}, + }) + if err == nil || output.ChunkPlan == nil || output.ChunkPlan.LookupStatus != "skipped" || output.ChunkPlan.LookupReason != "chunk plan lookup skipped" { + t.Fatalf("operational lookup output=%#v error=%v", output.ChunkPlan, err) + } +} + func TestRunnerChunkPlanHitUsesStoredProducerProvenance(t *testing.T) { prepared, plan := preparedTerminalDebugPipeline(t) prepared.resolved.Chunk.Module = "chunk/requested"