Skip to content

Commit 94b2883

Browse files
Copilotgh-aw-bot
andauthored
Address PR review feedback: require full resolution for concatenated format string analysis
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
1 parent a86269c commit 94b2883

7 files changed

Lines changed: 55 additions & 76 deletions

File tree

docs/adr/58715-fix-concatenated-fmt-errorf-analysis.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ The `cacheRecoveryError` helper in the audit pipeline built its `fmt.Errorf` for
1212

1313
## Decision
1414

15-
We will add a shared AST utility, `astutil.ResolveFormatString`, that resolves `fmt.Errorf`-style format-string expressions from string literals and `+`-concatenated fragments while preserving opaque non-literal boundaries with a placeholder byte. We will update `errorfwrapv` and `fmterrorfnoverbs` to analyze the resolved format string instead of requiring a single `*ast.BasicLit`, and we will change `cacheRecoveryError` to wrap its causal error with `%w`. For concatenated no-verb cases, `fmterrorfnoverbs` will emit a generic `errors.New` suggestion instead of synthesizing a replacement literal that did not exist in source.
15+
We will refactor `cacheRecoveryError` to use a literal format string with `%s` for its message argument and `%w` for its error argument (`fmt.Errorf("%s\n\n...", message, runID, runOutputDir, err)`), avoiding format string concatenation in production while wrapping the causal error with `%w`.
16+
17+
We will add a shared AST utility, `astutil.ResolveFormatString`, that resolves `fmt.Errorf`-style format-string expressions built from string literals and `+`-concatenated literal trees. When an expression contains non-literal (opaque) operands, `ResolveFormatString` returns `ok = false` because format verbs and positional argument indices cannot be proven at compile time.
18+
19+
We will update `errorfwrapv` and `fmterrorfnoverbs` to analyze format strings resolved by `astutil.ResolveFormatString`. This allows detecting mistakes in multi-line or concatenated string literal format strings without risking false positives or incorrect argument indexing when opaque operands are present.
1620

1721
## Alternatives Considered
1822

@@ -37,12 +41,11 @@ A more aggressive option would be to resolve identifiers, function calls, or con
3741

3842
### Negative
3943

40-
- The shared AST utility adds more nuanced format-string reconstruction logic, which increases maintenance burden and requires careful regression coverage.
41-
- Placeholder-based reconstruction produces conservative generic diagnostics for some concatenated expressions, so the linter cannot always suggest an exact source replacement.
44+
- The shared AST utility adds concatenation tree traversal, which requires test coverage for nested `+` literal expressions.
4245

4346
### Neutral
4447

45-
- The new analysis intentionally treats non-literal operands as opaque boundaries rather than attempting full expression evaluation.
48+
- The new analysis intentionally skips format strings with non-literal (opaque) operands, choosing safety over unprovable expression evaluation.
4649
- Additional unit and testdata coverage is required to lock in behavior around concatenation, verb detection, and false-positive prevention.
4750

4851
---

pkg/cli/audit_run_pipeline.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,13 +315,13 @@ func downloadLegacyEvalsArtifactIfNeeded(ctx context.Context, cfg auditRunConfig
315315
}
316316

317317
func cacheRecoveryError(message string, runID int64, runOutputDir string, err error) error {
318-
return fmt.Errorf(message+"\n\n"+
318+
return fmt.Errorf("%s\n\n"+
319319
"To download artifacts, use the GitHub MCP server:\n\n"+
320320
"1. Use the github-mcp-server tool 'download_workflow_run_artifacts' with:\n"+
321321
" - run_id: %d\n"+
322322
" - output_directory: %s\n\n"+
323323
"2. After downloading, run this audit command again to analyze the cached artifacts.\n\n"+
324-
"Original error: %w", runID, runOutputDir, err)
324+
"Original error: %w", message, runID, runOutputDir, err)
325325
}
326326

327327
func prepareRunForAnalysis(run WorkflowRun, cfg auditRunConfig, useLocalCache bool) WorkflowRun {

pkg/cli/audit_run_pipeline_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,8 @@ func TestCacheRecoveryError(t *testing.T) {
131131
assert.Contains(t, msg, "1234")
132132
assert.Contains(t, msg, "/tmp/run-1234")
133133
assert.Contains(t, msg, "boom")
134-
assert.ErrorIs(t, err, sentinel, "cacheRecoveryError must wrap its cause with %%w so errors.Is can match it")
135-
assert.True(t, errors.Unwrap(err) != nil, "cacheRecoveryError result must be unwrappable")
134+
require.ErrorIs(t, err, sentinel, "cacheRecoveryError must wrap its cause with %%w so errors.Is can match it")
135+
require.Error(t, errors.Unwrap(err), "cacheRecoveryError result must be unwrappable")
136136
}
137137

138138
func TestPrepareRunForAnalysis(t *testing.T) {

pkg/linters/errorfwrapv/testdata/src/errorfwrapv/errorfwrapv.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,21 +94,26 @@ func GoodMixedVerbs(name string, err error) error {
9494
return fmt.Errorf("operation %v failed: %w", name, err)
9595
}
9696

97-
// BadConcatVWrap builds its format string via string concatenation with a
98-
// caller-supplied prefix, mirroring cacheRecoveryError. The trailing error
99-
// argument is formatted with %v instead of %w.
100-
func BadConcatVWrap(prefix string, err error) error {
101-
return fmt.Errorf(prefix+"\n\n"+ // want `fmt\.Errorf formats an error argument with %v`
102-
"context: %d\n"+
97+
// BadConcatVWrap builds its format string via string concatenation of literals.
98+
// The trailing error argument is formatted with %v instead of %w.
99+
func BadConcatVWrap(err error) error {
100+
return fmt.Errorf("context: %d\n"+ // want `fmt\.Errorf formats an error argument with %v`
103101
"Original error: %v", 42, err)
104102
}
105103

106-
// GoodConcatWWrap builds its format string via string concatenation with a
107-
// caller-supplied prefix but correctly wraps the trailing error with %w.
108-
func GoodConcatWWrap(prefix string, err error) error {
104+
// GoodConcatWWrap builds its format string via string concatenation of literals
105+
// and correctly wraps the trailing error with %w.
106+
func GoodConcatWWrap(err error) error {
107+
return fmt.Errorf("context: %d\n"+
108+
"Original error: %w", 42, err)
109+
}
110+
111+
// OpaqueConcatVWrap builds its format string with a caller-supplied opaque prefix;
112+
// because the format string contains non-literal components, it cannot be safely analyzed.
113+
func OpaqueConcatVWrap(prefix string, err error) error {
109114
return fmt.Errorf(prefix+"\n\n"+
110115
"context: %d\n"+
111-
"Original error: %w", 42, err)
116+
"Original error: %v", 42, err)
112117
}
113118

114119
// SuppressedByNolint is intentionally suppressed.

pkg/linters/fmterrorfnoverbs/testdata/src/fmterrorfnoverbs/fmterrorfnoverbs.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,20 @@ func multipleEscapePercents() error {
6464
return fmtalias.Errorf("between 50%% and 90%% utilised") // want `fmt\.Errorf called with no format verbs; use errors\.New`
6565
}
6666

67-
// concatNoVerbs builds its format string via string concatenation with a
68-
// caller-supplied prefix; the literal portion has no format verbs.
69-
func concatNoVerbs(prefix string) error {
70-
return fmtalias.Errorf(prefix + " occurred with no additional context") // want `fmt\.Errorf called with no format verbs; use errors\.New`
67+
// concatNoVerbs builds its format string via string concatenation of literals;
68+
// the literal portion has no format verbs.
69+
func concatNoVerbs() error {
70+
return fmtalias.Errorf("operation failed: " + "occurred with no additional context") // want `fmt\.Errorf called with no format verbs; use errors\.New`
7171
}
7272

73-
// concatWithVerb builds its format string via string concatenation and the
74-
// literal portion contains a real verb; must NOT be flagged.
75-
func concatWithVerb(prefix string, n int) error {
76-
return fmtalias.Errorf(prefix+" occurred %d times", n)
73+
// opaqueConcatNoVerbs builds format string with an opaque prefix;
74+
// must NOT be flagged because prefix may contain verbs at runtime.
75+
func opaqueConcatNoVerbs(prefix string) error {
76+
return fmtalias.Errorf(prefix + " occurred with no additional context")
77+
}
78+
79+
// concatWithVerb builds its format string via string concatenation of literals and
80+
// contains a real verb; must NOT be flagged.
81+
func concatWithVerb(n int) error {
82+
return fmtalias.Errorf("operation failed: "+"occurred %d times", n)
7783
}

pkg/linters/internal/astutil/astutil.go

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -708,35 +708,13 @@ func StringLitValue(expr ast.Expr) (string, bool) {
708708

709709
// ResolveFormatString resolves a fmt.Errorf-style format-string argument
710710
// expression into its literal text content. It handles plain string literals
711-
// as well as string concatenation via the `+` operator (e.g.
712-
// `message + "...\n" + "..."`), which is common when a format string is built
713-
// from a caller-supplied prefix followed by literal boilerplate.
711+
// as well as string concatenation via the `+` operator where all operands
712+
// are string literals (e.g. `"header\n" + "body %w"`).
714713
//
715-
// Non-literal operands (identifiers, calls, etc.) are treated as opaque and
716-
// their unknowable runtime content is never counted as a %-verb: they are
717-
// replaced with formatOpaquePlaceholder rather than dropped outright, so a
718-
// literal segment ending in '%' can never merge across an opaque boundary
719-
// with the next literal segment's leading character to fabricate a spurious
720-
// verb (e.g. `"abc%" + errStr + "v..."` cannot be mistaken for `"abc%v..."`).
721-
// Verbs that occur entirely within a single literal segment are unaffected
722-
// and keep their relative order, so they still line up positionally with the
723-
// call's trailing (fmt) arguments.
724-
//
725-
// Each returned literal segment is unquoted independently (via
726-
// StringLitValue), so the result is equivalent to unquoting every literal
727-
// operand and concatenating them (with opaque placeholders in between) — not
728-
// to unquoting the full original expression as a single token. This only
729-
// matters for callers that care about raw escape-sequence boundaries.
730-
//
731-
// ok is false unless expr resolves to at least one string literal, so a bare
732-
// non-literal expression (e.g. a lone identifier) is correctly rejected
733-
// rather than treated as an empty, verb-free format string.
734-
//
735-
// Recursion depth tracks the nesting of `+` operands in expr, which for
736-
// realistic source code (a chain of concatenated string operands) is
737-
// bounded by the number of operands and shallow in practice; it is not
738-
// guarded against pathological or generated expressions with extreme
739-
// nesting depth.
714+
// If any operand in a concatenated expression is not a string literal (e.g.
715+
// an identifier or function call), the format string cannot be fully resolved
716+
// at compile time (its format verbs and argument counts are unprovable), so ok
717+
// is false.
740718
func ResolveFormatString(expr ast.Expr) (value string, ok bool) {
741719
if s, litOK := StringLitValue(expr); litOK {
742720
return s, true
@@ -746,26 +724,16 @@ func ResolveFormatString(expr ast.Expr) (value string, ok bool) {
746724
return "", false
747725
}
748726
left, leftOK := ResolveFormatString(bin.X)
749-
right, rightOK := ResolveFormatString(bin.Y)
750-
if !leftOK && !rightOK {
751-
return "", false
752-
}
753727
if !leftOK {
754-
left = formatOpaquePlaceholder
728+
return "", false
755729
}
730+
right, rightOK := ResolveFormatString(bin.Y)
756731
if !rightOK {
757-
right = formatOpaquePlaceholder
732+
return "", false
758733
}
759734
return left + right, true
760735
}
761736

762-
// formatOpaquePlaceholder stands in for a non-literal (opaque) operand of a
763-
// concatenated format-string expression in ResolveFormatString. It is a
764-
// single character that is neither '%' nor a recognized format verb letter,
765-
// so it can never combine with an adjacent literal segment to fabricate a
766-
// spurious %-verb sequence across the boundary of a dropped operand.
767-
const formatOpaquePlaceholder = "\x00"
768-
769737
// IsInInitFunction reports whether cur is inside a top-level init() function.
770738
// Only top-level (no receiver) init functions are recognized; methods named
771739
// init are ordinary methods and are not exempt. A node whose innermost

pkg/linters/internal/astutil/astutil_test.go

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,19 +1109,16 @@ func TestResolveFormatString(t *testing.T) {
11091109
wantOK: true,
11101110
},
11111111
{
1112-
name: "concatenation with a leading non-literal identifier",
1112+
name: "concatenation with a leading non-literal identifier returns ok=false",
11131113
expr: concat(ast.NewIdent("message"), strLit(`"\n\nOriginal error: %v"`)),
1114-
want: "\x00\n\nOriginal error: %v",
1115-
wantOK: true,
1114+
want: "",
1115+
wantOK: false,
11161116
},
11171117
{
1118-
// A literal ending in '%' followed by an opaque operand followed
1119-
// by a literal starting with 'v' must not merge into a
1120-
// fabricated "%v" verb across the dropped operand's boundary.
1121-
name: "opaque operand between a trailing percent and a verb letter does not fabricate a verb",
1118+
name: "opaque operand between literal segments returns ok=false",
11221119
expr: concat(strLit(`"abc%"`), ast.NewIdent("errStr"), strLit(`"v..."`)),
1123-
want: "abc%\x00v...",
1124-
wantOK: true,
1120+
want: "",
1121+
wantOK: false,
11251122
},
11261123
{
11271124
name: "concatenation of only non-literal identifiers",

0 commit comments

Comments
 (0)