Validate normalize retry diagnostics

This commit is contained in:
2026-07-26 02:43:28 +00:00
parent d3c4d6f133
commit 80ec939383
5 changed files with 154 additions and 10 deletions

View File

@@ -331,6 +331,14 @@ normalize retry budget: `retries` permits that many additional attempts after
the initial attempt. It neither creates a normalizer-local retry loop nor the initial attempt. It neither creates a normalizer-local retry loop nor
records an accepted checkpoint for the discarded attempt. records an accepted checkpoint for the discarded attempt.
Before adding a normalize retry directive to attempt debug data, the runner
requires a nonblank, valid UTF-8 reason code of at most 128 bytes and a
nonblank, valid UTF-8 message of at most 4,096 bytes. These are encoded-byte
limits. The framework rejects an invalid directive without truncating or
rewriting either field. It validates only this mechanical contract; normalizers
remain responsible for ensuring their otherwise valid diagnostics do not expose
source material, credentials, paths, names, or other sensitive content.
When a later normalize attempt succeeds, its candidate alone proceeds through When a later normalize attempt succeeds, its candidate alone proceeds through
the usual validation and checkpoint path. When the final attempt still returns the usual validation and checkpoint path. When the final attempt still returns
a directive, the runner validates its supplied safe fallback through that same a directive, the runner validates its supplied safe fallback through that same

View File

@@ -95,6 +95,11 @@ type TypedNormalizeResult[T any] struct {
// NormalizeRetry asks the framework to retry normalization while retaining a // NormalizeRetry asks the framework to retry normalization while retaining a
// safe candidate for acceptance if the retry budget is exhausted. // safe candidate for acceptance if the retry budget is exhausted.
const (
MaxNormalizeRetryReasonCodeBytes = 128
MaxNormalizeRetryMessageBytes = 4096
)
type NormalizeRetry struct { type NormalizeRetry struct {
ReasonCode string ReasonCode string
Message string Message string

View File

@@ -0,0 +1,34 @@
package pipeline
import (
"errors"
"strings"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func validateNormalizeRetry(retry *contracts.NormalizeRetry) error {
if retry == nil {
return errors.New("normalize retry directive is missing")
}
if !utf8.ValidString(retry.ReasonCode) {
return errors.New("normalize retry directive reason code has invalid UTF-8")
}
if strings.TrimSpace(retry.ReasonCode) == "" {
return errors.New("normalize retry directive reason code is blank")
}
if len(retry.ReasonCode) > contracts.MaxNormalizeRetryReasonCodeBytes {
return errors.New("normalize retry directive reason code exceeds maximum length")
}
if !utf8.ValidString(retry.Message) {
return errors.New("normalize retry directive message has invalid UTF-8")
}
if strings.TrimSpace(retry.Message) == "" {
return errors.New("normalize retry directive message is blank")
}
if len(retry.Message) > contracts.MaxNormalizeRetryMessageBytes {
return errors.New("normalize retry directive message exceeds maximum length")
}
return nil
}

View File

@@ -150,14 +150,112 @@ func TestRunnerHandlesRetryableNormalizeFallbacks(t *testing.T) {
} }
} }
func TestRunnerRejectsBlankNormalizeRetryDiagnostic(t *testing.T) { func TestRunnerValidatesNormalizeRetryDiagnostics(t *testing.T) {
const (
reasonSentinel = "reason-diagnostic-sentinel"
messageSentinel = "message-diagnostic-sentinel"
)
reasonOverLimit := strings.Repeat("r", contracts.MaxNormalizeRetryReasonCodeBytes-len(reasonSentinel)) + reasonSentinel + "x"
messageOverLimit := strings.Repeat("m", contracts.MaxNormalizeRetryMessageBytes-len(messageSentinel)) + messageSentinel + "x"
tests := []struct {
name string
retry contracts.NormalizeRetry
wantError string
hiddenValues []string
}{
{
name: "accepts byte limits",
retry: contracts.NormalizeRetry{
ReasonCode: strings.Repeat("r", contracts.MaxNormalizeRetryReasonCodeBytes),
Message: strings.Repeat("m", contracts.MaxNormalizeRetryMessageBytes),
},
},
{
name: "rejects oversized reason code",
retry: contracts.NormalizeRetry{
ReasonCode: reasonOverLimit,
Message: messageSentinel,
},
wantError: "reason code exceeds maximum length",
hiddenValues: []string{reasonSentinel, messageSentinel},
},
{
name: "rejects oversized message",
retry: contracts.NormalizeRetry{
ReasonCode: reasonSentinel,
Message: messageOverLimit,
},
wantError: "message exceeds maximum length",
hiddenValues: []string{reasonSentinel, messageSentinel},
},
{
name: "rejects invalid reason code UTF-8",
retry: contracts.NormalizeRetry{
ReasonCode: reasonSentinel + string([]byte{0xff}),
Message: messageSentinel,
},
wantError: "reason code has invalid UTF-8",
hiddenValues: []string{reasonSentinel, messageSentinel},
},
{
name: "rejects invalid message UTF-8",
retry: contracts.NormalizeRetry{
ReasonCode: reasonSentinel,
Message: messageSentinel + string([]byte{0xff}),
},
wantError: "message has invalid UTF-8",
hiddenValues: []string{reasonSentinel, messageSentinel},
},
{
name: "rejects blank reason code",
retry: contracts.NormalizeRetry{
ReasonCode: " \t\n ",
Message: messageSentinel,
},
wantError: "reason code is blank",
hiddenValues: []string{messageSentinel},
},
{
name: "rejects blank message",
retry: contracts.NormalizeRetry{
ReasonCode: reasonSentinel,
Message: " \t\n ",
},
wantError: "message is blank",
hiddenValues: []string{reasonSentinel},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
prepared := preparedAttemptDebugPipeline(t) prepared := preparedAttemptDebugPipeline(t)
prepared.Steps[0].lanes[0].typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) { prepared.Steps[0].lanes[0].typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
return erasedTypedResult{Value: codecNotes{Items: []string{"safe"}}, Retry: &contracts.NormalizeRetry{ReasonCode: " ", Message: "missing reason"}}, nil return erasedTypedResult{Value: codecNotes{Items: []string{"safe"}}, Retry: &tc.retry}, nil
} }
_, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: newCapturedDebugRecorder()}) debug := newCapturedDebugRecorder()
if err == nil || !strings.Contains(err.Error(), "blank reason code or message") { output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: debug})
t.Fatalf("Run() error = %v, want retry diagnostic contract error", err) if tc.wantError == "" {
if err != nil {
t.Fatalf("Run() error = %v, want accepted retry fallback", err)
}
if len(output.NormalizeOutputs) != 1 {
t.Fatalf("normalize outputs = %#v, want one accepted fallback", output.NormalizeOutputs)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("Run() error = %v, want fixed %q error", err, tc.wantError)
}
var debugOutput strings.Builder
for _, name := range debug.names() {
debugOutput.Write(debug.json[name])
}
for _, value := range tc.hiddenValues {
if strings.Contains(err.Error(), value) || strings.Contains(debugOutput.String(), value) {
t.Fatalf("retry diagnostic leaked %q", value)
}
}
})
} }
} }

View File

@@ -8,7 +8,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"path" "path"
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
@@ -365,8 +364,8 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
} }
var retryPayload map[string]any var retryPayload map[string]any
if result.Retry != nil { if result.Retry != nil {
if strings.TrimSpace(result.Retry.ReasonCode) == "" || strings.TrimSpace(result.Retry.Message) == "" { if err := validateNormalizeRetry(result.Retry); err != nil {
return false, nil, terminal.record(map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings)}, fmt.Errorf("normalize lane %q returned retry directive with blank reason code or message", lane.ID)) return false, nil, terminal.record(map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings)}, fmt.Errorf("normalize lane %q returned invalid retry directive: %w", lane.ID, err))
} }
retryRemaining := attempt <= lane.Normalize.Retries retryRemaining := attempt <= lane.Normalize.Retries
retryPayload = map[string]any{ retryPayload = map[string]any{