diff --git a/pkg/diff/diff_test.go b/pkg/diff/diff_test.go index 3d63080a60..97c0e5fd00 100644 --- a/pkg/diff/diff_test.go +++ b/pkg/diff/diff_test.go @@ -1157,6 +1157,64 @@ func TestWriteTable_DriftDetected(t *testing.T) { } } +// TestWriteTable_ControlCharactersStayInCell pins that a value or path holding +// a tab, newline, or carriage return renders escaped inside its own cell. The +// sysctl collector stores /proc/sys files verbatim, and some, such as +// fs/file-nr, are tab-separated; written raw, tabwriter reads the tabs as +// column breaks and the newlines as new rows. +func TestWriteTable_ControlCharactersStayInCell(t *testing.T) { + tests := []struct { + name string + change Change + want []string + }{ + { + name: "tab-separated sysctl value", + change: Change{Kind: Modified, Severity: SeverityInfo, Path: "OS.sysctl./proc/sys/fs/file-nr", + Baseline: strPtr("2656\t0\t9223372036854775807"), Target: strPtr("2912\t0\t9223372036854775807")}, + want: []string{"MODIFIED", "OS.sysctl./proc/sys/fs/file-nr", `"2656\t0\t9223372036854775807"`, `"2912\t0\t9223372036854775807"`}, + }, + { + name: "multi-line value", + change: Change{Kind: Added, Severity: SeverityInfo, Path: "OS.kmod.params", + Target: strPtr("line1\nMODIFIED K8s.server.version v1.30 v1.31\r")}, + want: []string{"ADDED", "OS.kmod.params", "-", `"line1\nMODIFIED K8s.server.version v1.30 v1.31\r"`}, + }, + { + name: "path with a tab", + change: Change{Kind: Removed, Severity: SeverityInfo, Path: "OS.sysctl.a\tb", + Baseline: strPtr("1")}, + want: []string{"REMOVED", `"OS.sysctl.a\tb"`, "1", "-"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &Result{Changes: []Change{tt.change}} + var buf bytes.Buffer + if err := WriteTable(&buf, result); err != nil { + t.Fatalf("WriteTable failed: %v", err) + } + + // CHANGES line, header, separator, one row, blank line, footer. + lines := strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n") + if len(lines) != 6 { + t.Fatalf("got %d lines, want 6:\n%s", len(lines), buf.String()) + } + row := lines[3] + for _, cell := range tt.want { + if !strings.Contains(row, cell) { + t.Errorf("row %q does not contain cell %q", row, cell) + } + } + header := lines[1] + if got, want := strings.Index(row, tt.want[3]), strings.Index(header, "TARGET"); got != want { + t.Errorf("target cell starts at column %d, TARGET header at %d:\n%s", got, want, buf.String()) + } + }) + } +} + // failingWriter returns an error after N successful writes. Used to simulate // broken pipes and full disks during table output. type failingWriter struct { diff --git a/pkg/diff/table.go b/pkg/diff/table.go index 2133436e2e..32c595169f 100644 --- a/pkg/diff/table.go +++ b/pkg/diff/table.go @@ -17,6 +17,7 @@ package diff import ( "fmt" "io" + "strconv" "strings" "text/tabwriter" @@ -81,7 +82,7 @@ func WriteTable(w io.Writer, result *Result) error { baseline := renderCell(c.Baseline) target := renderCell(c.Target) if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", - strings.ToUpper(string(c.Kind)), c.Path, baseline, target); err != nil { + strings.ToUpper(string(c.Kind)), escapeCell(c.Path), baseline, target); err != nil { return wrapTableErr(err) } } @@ -107,7 +108,17 @@ func renderCell(p *string) string { if *p == "" { return `""` } - return *p + return escapeCell(*p) +} + +// escapeCell quotes s when it holds a tab, newline, or carriage return, which +// tabwriter would otherwise read as a column or row break. Sysctl readings +// such as /proc/sys/fs/file-nr are tab-separated. +func escapeCell(s string) string { + if strings.ContainsAny(s, "\t\n\r") { + return strconv.Quote(s) + } + return s } func wrapTableErr(err error) error {