diff --git a/analyzer/compare.go b/analyzer/compare.go index 3bf830e..590d67a 100644 --- a/analyzer/compare.go +++ b/analyzer/compare.go @@ -159,7 +159,7 @@ func printCallPath(fns []*cpb.Function) { 0) // flags for _, f := range fns { if f.Site != nil { - fmt.Fprint(tw, f.Site.GetFilename(), ":", f.Site.GetLine(), ":", f.Site.GetColumn()) + fmt.Fprint(tw, escapeControlChars(f.Site.GetFilename()), ":", f.Site.GetLine(), ":", f.Site.GetColumn()) } fmt.Fprint(tw, "\t", f.GetName(), "\n") } diff --git a/analyzer/scan.go b/analyzer/scan.go index da1571a..f40f9d8 100644 --- a/analyzer/scan.go +++ b/analyzer/scan.go @@ -53,6 +53,7 @@ func RunCapslock(args []string, output string, pkgs []*packages.Package, queried } templateFuncMap := template.FuncMap{ "format": templateFormat, + "escape": escapeControlChars, } if output == "json" || output == "j" { cil := GetCapabilityInfo(pkgs, queriedPackages, config) diff --git a/analyzer/static/verbose.tmpl b/analyzer/static/verbose.tmpl index 7cd0baf..bfaab42 100644 --- a/analyzer/static/verbose.tmpl +++ b/analyzer/static/verbose.tmpl @@ -7,5 +7,5 @@ Share feedback and file bugs at {{format "highlight"}}https://github.com/google/ {{end}}{{end}}{{if .CapabilityStats}}{{range $index, $p := .CapabilityStats}} {{$p.CapabilityName}}: {{$p.Count}} references ({{$p.DirectCount}} direct, {{$p.TransitiveCount}} transitive) Example {{if eq (len $p.ExampleCallpath) 1}}function{{else}}callpath{{end}}: -{{range $val := $p.ExampleCallpath}} {{format "callpath-site"}}{{if $val.Site}}{{$val.Site.Filename}}:{{$val.Site.Line}}:{{$val.Site.Column}}:{{end}}{{format "callpath"}}{{$val.Name}}{{format}} +{{range $val := $p.ExampleCallpath}} {{format "callpath-site"}}{{if $val.Site}}{{escape $val.Site.Filename}}:{{$val.Site.Line}}:{{$val.Site.Column}}:{{end}}{{format "callpath"}}{{$val.Name}}{{format}} {{end}}{{end}}{{else}}{{format "nocap"}}Capslock found no capabilities in this package.{{format}}{{end}} diff --git a/analyzer/util.go b/analyzer/util.go index 0d9da3a..2550bb6 100644 --- a/analyzer/util.go +++ b/analyzer/util.go @@ -12,6 +12,7 @@ import ( "go/types" "os" "path" + "strconv" "strings" cpb "github.com/google/capslock/proto" @@ -537,6 +538,15 @@ func programName() string { return "capslock" } +// escapeControlChars escapes any control characters in s, returning a string +// safe to write to a terminal. Strings parsed from analyzed source (notably +// filenames originating from //line directives) can contain arbitrary bytes, +// including ANSI escape sequences. The surrounding quotation marks added by +// strconv.Quote are stripped. +func escapeControlChars(s string) string { + return strings.TrimPrefix(strings.TrimSuffix(strconv.Quote(s), `"`), `"`) +} + // addFunction adds an entry to *fns for the given node and edge. // The edge can be nil. func addFunction(fns *[]*cpb.Function, v *callgraph.Node, incomingEdge *callgraph.Edge) { diff --git a/analyzer/util_test.go b/analyzer/util_test.go new file mode 100644 index 0000000..b25ff6f --- /dev/null +++ b/analyzer/util_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 Google LLC +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file or at +// https://developers.google.com/open-source/licenses/bsd + +package analyzer + +import ( + "strings" + "testing" +) + +func TestEscapeControlChars(t *testing.T) { + for _, tc := range []struct { + name string + in string + want string + }{ + { + name: "plain filename unchanged", + in: "foo.go", + want: "foo.go", + }, + { + name: "filename with spaces unchanged", + in: "my file.go", + want: "my file.go", + }, + { + name: "ansi csi escaped", + in: "\x1b[2J\x1b[H\x1b[32mOK\x1b[0m", + want: `\x1b[2J\x1b[H\x1b[32mOK\x1b[0m`, + }, + { + name: "tab escaped", + in: "evil\tbenign", + want: `evil\tbenign`, + }, + { + name: "bel escaped", + in: "x\x07y", + want: `x\ay`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := escapeControlChars(tc.in) + if got != tc.want { + t.Errorf("escapeControlChars(%q) = %q, want %q", tc.in, got, tc.want) + } + if strings.ContainsAny(got, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x7f") { + t.Errorf("escapeControlChars(%q) returned %q, which still contains a control byte", tc.in, got) + } + }) + } +} diff --git a/cmd/capslock-git-diff/main.go b/cmd/capslock-git-diff/main.go index 05512f0..ef8e89b 100644 --- a/cmd/capslock-git-diff/main.go +++ b/cmd/capslock-git-diff/main.go @@ -39,6 +39,7 @@ import ( "path/filepath" "slices" "sort" + "strconv" "strings" "text/tabwriter" @@ -447,13 +448,22 @@ func printCallPath(fns []*cpb.Function) { 0) // flags for _, f := range fns { if f.Site != nil { - fmt.Fprint(tw, f.Site.GetFilename(), ":", f.Site.GetLine(), ":", f.Site.GetColumn()) + fmt.Fprint(tw, escapeControlChars(f.Site.GetFilename()), ":", f.Site.GetLine(), ":", f.Site.GetColumn()) } fmt.Fprint(tw, "\t", f.GetName(), "\n") } tw.Flush() } +// escapeControlChars escapes any control characters in s, returning a string +// safe to write to a terminal. Filenames in callpath output originate from +// //line directives in analyzed source and may contain arbitrary bytes, +// including ANSI escape sequences. The surrounding quotation marks added by +// strconv.Quote are stripped. +func escapeControlChars(s string) string { + return strings.TrimPrefix(strings.TrimSuffix(strconv.Quote(s), `"`), `"`) +} + func listCommits(revisions [2]string) { var b bytes.Buffer run(&b, "git", "log", "--no-decorate", "--oneline", "^"+revisions[0], revisions[1])