diff --git a/docs/architecture.md b/docs/architecture.md index 308fa41..7cf39de 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,7 +40,7 @@ The dependency direction is deliberate: - Write commands in `internal/rwcmd/` compose the read command and add leaves; read packages never import write packages. - `cmd/gro` cannot reach `internal/rw` or `internal/rwcmd` through its link graph. -Support packages such as `auth`, `bulk`, `config`, `keychain`, `output`, and `testutil` stay under `internal` and are available to both applications where appropriate. +Support packages such as `auth`, `bulk`, `config`, `keychain`, `output`, `sanitize`, `testutil`, and `view` stay under `internal` and are available to both applications where appropriate. ## Domain shape diff --git a/docs/golden-principles.md b/docs/golden-principles.md index ff5d1e5..dec8cc2 100644 --- a/docs/golden-principles.md +++ b/docs/golden-principles.md @@ -65,3 +65,9 @@ Verified by `TestSystemErrorUnwrap`, `TestAttributedTokenSource_NamesRefOnAuthEr Use function-field mocks in `mock_test.go` plus a compile-time interface assertion. Use `testutil.WithFactory` for temporary factory replacement, `testutil.CaptureStdout` for command output, the assertion helpers in `internal/testutil`, and existing `Sample*` fixtures before creating local fixtures. Compile-time assertions enforce mock conformance. Package handler tests exercise `WithFactory` and `CaptureStdout`; assertion helpers have focused tests such as `TestEqual`, `TestErrorIs`, and `TestContains`. + +## 11. Google-sourced text is sanitized before it reaches the terminal + +Text controlled by Google users or collaborators passes through `sanitize.Output` before printing. File and attachment names use `sanitize.Filename`, while identifiers and machine-readable values remain unchanged. + +Enforced by `TestPrintedDTOTextIsSanitized`. diff --git a/internal/architecture/sanitize_test.go b/internal/architecture/sanitize_test.go new file mode 100644 index 0000000..c53975f --- /dev/null +++ b/internal/architecture/sanitize_test.go @@ -0,0 +1,200 @@ +package architecture + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPrintedDTOTextIsSanitized(t *testing.T) { + t.Parallel() + root := repoRoot(t) + textFields := dtoTextFields(t, root) + wrappedSites := 0 + + for _, kind := range []string{"cmd", "rwcmd"} { + for _, pkg := range packageDirs(t, kind) { + dir := filepath.Join(root, "internal", kind, pkg) + for _, source := range nonTestSources(t, dir) { + ast.Inspect(source.file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok || !isOutputCall(call) { + return true + } + wrapped := false + for _, arg := range call.Args { + inspectPrintedArgument(arg, textFields, false, func(sel *ast.SelectorExpr, sanitized bool) { + if sanitized { + wrapped = true + return + } + t.Errorf("%s:%d: printed DTO text %s must be wrapped in sanitize.Output or sanitize.Filename", relativePath(root, source.path), source.fset.Position(sel.Pos()).Line, sel.Sel.Name) + }) + } + if wrapped { + wrappedSites++ + } + return true + }) + } + } + } + + if wrappedSites < 20 { + t.Fatalf("saw %d sanitized DTO print sites, want at least 20", wrappedSites) + } +} + +// dtoTextFields returns the names of DTO text sources: exported string and +// []string struct fields in internal/api, plus the argument-less Get* string +// methods declared on those DTOs (GetDisplayName and friends), so text reached +// through a getter is held to the same rule as a field. Formatting helpers +// such as FormatTimeRange are not getters and render only dates. +func dtoTextFields(t *testing.T, root string) map[string]bool { + t.Helper() + fields := map[string]bool{} + for _, pkg := range packageDirs(t, "api") { + for _, file := range parseNonTestFiles(t, filepath.Join(root, "internal", "api", pkg)) { + ast.Inspect(file, func(node ast.Node) bool { + if fn, ok := node.(*ast.FuncDecl); ok { + if fn.Recv != nil && strings.HasPrefix(fn.Name.Name, "Get") && isStringGetter(fn.Type) { + fields[fn.Name.Name] = true + } + return false + } + structType, ok := node.(*ast.StructType) + if !ok { + return true + } + for _, field := range structType.Fields.List { + if !isStringOrStringSlice(field.Type) { + continue + } + for _, name := range field.Names { + if name.IsExported() { + fields[name.Name] = true + } + } + } + return true + }) + } + } + + // These fields are identifiers or machine-readable values, not prose. + for _, name := range []string{"ID", "ResourceName", "ThreadID", "MessageID", "MimeType", "Type", "Status", "ETag", "TimeZone", "Date", "DateTime"} { + delete(fields, name) + } + for name := range fields { + if strings.HasSuffix(name, "ID") || strings.HasSuffix(name, "IDs") || strings.HasSuffix(name, "URL") || strings.HasSuffix(name, "Link") { + delete(fields, name) + } + } + return fields +} + +func isStringGetter(fn *ast.FuncType) bool { + if fn.Params != nil && len(fn.Params.List) > 0 { + return false + } + if fn.Results == nil || len(fn.Results.List) != 1 { + return false + } + return isIdent(fn.Results.List[0].Type, "string") +} + +func isStringOrStringSlice(expr ast.Expr) bool { + if ident, ok := expr.(*ast.Ident); ok { + return ident.Name == "string" + } + array, ok := expr.(*ast.ArrayType) + if !ok || array.Len != nil { + return false + } + ident, ok := array.Elt.(*ast.Ident) + return ok && ident.Name == "string" +} + +func isOutputCall(call *ast.CallExpr) bool { + if ident, ok := call.Fun.(*ast.Ident); ok { + return ident.Name == "append" && len(call.Args) > 0 && isIdent(call.Args[0], "rows") + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + methods := map[string]bool{"Printf": true, "Println": true, "Success": true, "Info": true, "Error": true} + if methods[sel.Sel.Name] { + return true + } + fmtFunctions := map[string]bool{"Print": true, "Fprint": true, "Fprintf": true, "Fprintln": true, "Sprintf": true, "Sprint": true} + return fmtFunctions[sel.Sel.Name] && isIdent(sel.X, "fmt") +} + +func inspectPrintedArgument(expr ast.Expr, fields map[string]bool, sanitized bool, visit func(*ast.SelectorExpr, bool)) { + ast.Inspect(expr, func(node ast.Node) bool { + if call, ok := node.(*ast.CallExpr); ok && isSanitizerCall(call) { + for _, arg := range call.Args { + inspectPrintedArgument(arg, fields, true, visit) + } + return false + } + if sel, ok := node.(*ast.SelectorExpr); ok && fields[sel.Sel.Name] { + visit(sel, sanitized) + } + return true + }) +} + +func isSanitizerCall(call *ast.CallExpr) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + return ok && isIdent(sel.X, "sanitize") && (sel.Sel.Name == "Output" || sel.Sel.Name == "Filename") +} + +func isIdent(expr ast.Expr, name string) bool { + ident, ok := expr.(*ast.Ident) + return ok && ident.Name == name +} + +// parsedSource keeps a parsed file together with the path and file set needed +// to report positions in it. +type parsedSource struct { + path string + fset *token.FileSet + file *ast.File +} + +func nonTestSources(t *testing.T, dir string) []parsedSource { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read %s: %v", dir, err) + } + var sources []parsedSource + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + path := filepath.Join(dir, name) + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + sources = append(sources, parsedSource{path: path, fset: fset, file: file}) + } + return sources +} + +func relativePath(root, path string) string { + rel, err := filepath.Rel(root, path) + if err != nil { + return path + } + return rel +} diff --git a/internal/cmd/calendar/output.go b/internal/cmd/calendar/output.go index df28c56..7169426 100644 --- a/internal/cmd/calendar/output.go +++ b/internal/cmd/calendar/output.go @@ -7,6 +7,7 @@ import ( calendarv3 "google.golang.org/api/calendar/v3" "github.com/open-cli-collective/google-cli/internal/api/calendar" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) // CalendarClient defines the interface for Calendar client operations used by calendar commands. @@ -32,22 +33,22 @@ func newCalendarClient(ctx context.Context) (CalendarClient, error) { // printEvent prints a single event in text format func printEvent(event *calendar.Event, showDescription bool) { fmt.Printf("ID: %s\n", event.ID) - fmt.Printf("Summary: %s\n", event.Summary) + fmt.Printf("Summary: %s\n", sanitize.Output(event.Summary)) fmt.Printf("When: %s\n", event.FormatTimeRange()) if event.Location != "" { - fmt.Printf("Location: %s\n", event.Location) + fmt.Printf("Location: %s\n", sanitize.Output(event.Location)) } if event.HangoutLink != "" { - fmt.Printf("Meet: %s\n", event.HangoutLink) + fmt.Printf("Meet: %s\n", sanitize.Output(event.HangoutLink)) } if event.Organizer != nil { if event.Organizer.DisplayName != "" { - fmt.Printf("Organizer: %s <%s>\n", event.Organizer.DisplayName, event.Organizer.Email) + fmt.Printf("Organizer: %s <%s>\n", sanitize.Output(event.Organizer.DisplayName), sanitize.Output(event.Organizer.Email)) } else { - fmt.Printf("Organizer: %s\n", event.Organizer.Email) + fmt.Printf("Organizer: %s\n", sanitize.Output(event.Organizer.Email)) } } @@ -59,9 +60,9 @@ func printEvent(event *calendar.Event, showDescription bool) { status = fmt.Sprintf(" (%s)", a.Status) } if a.DisplayName != "" { - fmt.Printf(" - %s <%s>%s\n", a.DisplayName, a.Email, status) + fmt.Printf(" - %s <%s>%s\n", sanitize.Output(a.DisplayName), sanitize.Output(a.Email), status) } else { - fmt.Printf(" - %s%s\n", a.Email, status) + fmt.Printf(" - %s%s\n", sanitize.Output(a.Email), status) } } } @@ -69,22 +70,22 @@ func printEvent(event *calendar.Event, showDescription bool) { if showDescription && event.Description != "" { fmt.Println() fmt.Println("--- Description ---") - fmt.Println(event.Description) + fmt.Println(sanitize.Output(event.Description)) } } // printEventSummary prints a brief event summary for list views func printEventSummary(event *calendar.Event) { fmt.Printf("ID: %s\n", event.ID) - fmt.Printf("Summary: %s\n", event.Summary) + fmt.Printf("Summary: %s\n", sanitize.Output(event.Summary)) fmt.Printf("When: %s\n", event.FormatTimeRange()) if event.Location != "" { - fmt.Printf("Location: %s\n", event.Location) + fmt.Printf("Location: %s\n", sanitize.Output(event.Location)) } if event.HangoutLink != "" { - fmt.Printf("Meet: %s\n", event.HangoutLink) + fmt.Printf("Meet: %s\n", sanitize.Output(event.HangoutLink)) } fmt.Println("---") @@ -97,11 +98,11 @@ func printCalendar(cal *calendar.CalendarInfo) { primary = " (primary)" } fmt.Printf("ID: %s%s\n", cal.ID, primary) - fmt.Printf("Name: %s\n", cal.Summary) + fmt.Printf("Name: %s\n", sanitize.Output(cal.Summary)) if cal.Description != "" { - fmt.Printf("Description: %s\n", cal.Description) + fmt.Printf("Description: %s\n", sanitize.Output(cal.Description)) } - fmt.Printf("Access: %s\n", cal.AccessRole) + fmt.Printf("Access: %s\n", sanitize.Output(cal.AccessRole)) if cal.TimeZone != "" { fmt.Printf("Timezone: %s\n", cal.TimeZone) } diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 1766ad7..355042e 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -17,6 +17,7 @@ import ( "github.com/open-cli-collective/google-cli/internal/config" "github.com/open-cli-collective/google-cli/internal/keychain" "github.com/open-cli-collective/google-cli/internal/output" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) // configFilesForClear returns the config files `clear --all` should remove, @@ -252,7 +253,7 @@ func runTest(cmd *cobra.Command, _ []string) error { fmt.Println(" Gmail API: OK") fmt.Printf(" Messages: %d total\n", profile.MessagesTotal) fmt.Println() - fmt.Printf("Authenticated as: %s\n", profile.EmailAddress) + fmt.Printf("Authenticated as: %s\n", sanitize.Output(profile.EmailAddress)) return nil } diff --git a/internal/cmd/contacts/output.go b/internal/cmd/contacts/output.go index 314febb..0a03c3b 100644 --- a/internal/cmd/contacts/output.go +++ b/internal/cmd/contacts/output.go @@ -7,6 +7,7 @@ import ( "google.golang.org/api/people/v1" "github.com/open-cli-collective/google-cli/internal/api/contacts" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) // ContactsClient defines the interface for Contacts client operations used by contacts commands. @@ -35,18 +36,18 @@ func newContactsClient(ctx context.Context) (ContactsClient, error) { // printContact prints a single contact in text format func printContact(contact *contacts.Contact, showDetails bool) { fmt.Printf("ID: %s\n", contact.ResourceName) - fmt.Printf("Name: %s\n", contact.GetDisplayName()) + fmt.Printf("Name: %s\n", sanitize.Output(contact.GetDisplayName())) if email := contact.GetPrimaryEmail(); email != "" { - fmt.Printf("Email: %s\n", email) + fmt.Printf("Email: %s\n", sanitize.Output(email)) } if phone := contact.GetPrimaryPhone(); phone != "" { - fmt.Printf("Phone: %s\n", phone) + fmt.Printf("Phone: %s\n", sanitize.Output(phone)) } if org := contact.GetOrganization(); org != "" { - fmt.Printf("Organization: %s\n", org) + fmt.Printf("Organization: %s\n", sanitize.Output(org)) } if showDetails { @@ -62,7 +63,7 @@ func printContact(contact *contacts.Contact, showDetails bool) { if e.Type != "" { typeStr = fmt.Sprintf(" [%s]", e.Type) } - fmt.Printf(" - %s%s%s\n", e.Value, typeStr, primary) + fmt.Printf(" - %s%s%s\n", sanitize.Output(e.Value), typeStr, primary) } } @@ -74,7 +75,7 @@ func printContact(contact *contacts.Contact, showDetails bool) { if p.Type != "" { typeStr = fmt.Sprintf(" [%s]", p.Type) } - fmt.Printf(" - %s%s\n", p.Value, typeStr) + fmt.Printf(" - %s%s\n", sanitize.Output(p.Value), typeStr) } } @@ -83,16 +84,16 @@ func printContact(contact *contacts.Contact, showDetails bool) { fmt.Println("Organizations:") for _, o := range contact.Organizations { if o.Name != "" { - fmt.Printf(" - %s", o.Name) + fmt.Printf(" - %s", sanitize.Output(o.Name)) if o.Title != "" { - fmt.Printf(" (%s)", o.Title) + fmt.Printf(" (%s)", sanitize.Output(o.Title)) } if o.Department != "" { - fmt.Printf(" - %s", o.Department) + fmt.Printf(" - %s", sanitize.Output(o.Department)) } fmt.Println() } else if o.Title != "" { - fmt.Printf(" - %s\n", o.Title) + fmt.Printf(" - %s\n", sanitize.Output(o.Title)) } } } @@ -105,7 +106,7 @@ func printContact(contact *contacts.Contact, showDetails bool) { if a.Type != "" { typeStr = fmt.Sprintf("[%s] ", a.Type) } - fmt.Printf(" - %s%s\n", typeStr, a.FormattedValue) + fmt.Printf(" - %s%s\n", typeStr, sanitize.Output(a.FormattedValue)) } } @@ -117,20 +118,20 @@ func printContact(contact *contacts.Contact, showDetails bool) { if u.Type != "" { typeStr = fmt.Sprintf("[%s] ", u.Type) } - fmt.Printf(" - %s%s\n", typeStr, u.Value) + fmt.Printf(" - %s%s\n", typeStr, sanitize.Output(u.Value)) } } // Show birthday if contact.Birthday != "" { - fmt.Printf("Birthday: %s\n", contact.Birthday) + fmt.Printf("Birthday: %s\n", sanitize.Output(contact.Birthday)) } // Show biography if contact.Biography != "" { fmt.Println() fmt.Println("--- Biography ---") - fmt.Println(contact.Biography) + fmt.Println(sanitize.Output(contact.Biography)) } } } @@ -138,18 +139,18 @@ func printContact(contact *contacts.Contact, showDetails bool) { // printContactSummary prints a brief contact summary for list views func printContactSummary(contact *contacts.Contact) { fmt.Printf("ID: %s\n", contact.ResourceName) - fmt.Printf("Name: %s\n", contact.GetDisplayName()) + fmt.Printf("Name: %s\n", sanitize.Output(contact.GetDisplayName())) if email := contact.GetPrimaryEmail(); email != "" { - fmt.Printf("Email: %s\n", email) + fmt.Printf("Email: %s\n", sanitize.Output(email)) } if phone := contact.GetPrimaryPhone(); phone != "" { - fmt.Printf("Phone: %s\n", phone) + fmt.Printf("Phone: %s\n", sanitize.Output(phone)) } if org := contact.GetOrganization(); org != "" { - fmt.Printf("Organization: %s\n", org) + fmt.Printf("Organization: %s\n", sanitize.Output(org)) } fmt.Println("---") @@ -158,9 +159,9 @@ func printContactSummary(contact *contacts.Contact) { // printContactGroup prints a contact group func printContactGroup(group *contacts.ContactGroup) { fmt.Printf("ID: %s\n", group.ResourceName) - fmt.Printf("Name: %s\n", group.Name) + fmt.Printf("Name: %s\n", sanitize.Output(group.Name)) if group.GroupType != "" { - fmt.Printf("Type: %s\n", group.GroupType) + fmt.Printf("Type: %s\n", sanitize.Output(group.GroupType)) } fmt.Printf("Members: %d\n", group.MemberCount) fmt.Println("---") diff --git a/internal/cmd/drive/download.go b/internal/cmd/drive/download.go index 204cafc..fcf1d23 100644 --- a/internal/cmd/drive/download.go +++ b/internal/cmd/drive/download.go @@ -11,6 +11,7 @@ import ( "github.com/open-cli-collective/google-cli/internal/api/drive" "github.com/open-cli-collective/google-cli/internal/config" formatpkg "github.com/open-cli-collective/google-cli/internal/format" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) func newDownloadCommand() *cobra.Command { @@ -73,7 +74,7 @@ Export formats: } if !stdout { - fmt.Printf("Exporting: %s\n", file.Name) + fmt.Printf("Exporting: %s\n", sanitize.Filename(file.Name)) fmt.Printf("Format: %s\n", format) } @@ -85,11 +86,11 @@ Export formats: // Regular file - download directly if format != "" { return fmt.Errorf("--format flag is only for Google Workspace files; %s is a %s", - file.Name, drive.GetTypeName(file.MimeType)) + sanitize.Filename(file.Name), drive.GetTypeName(file.MimeType)) } if !stdout { - fmt.Printf("Downloading: %s\n", file.Name) + fmt.Printf("Downloading: %s\n", sanitize.Filename(file.Name)) } data, err = client.DownloadFile(ctx, fileID) @@ -114,7 +115,7 @@ Export formats: } fmt.Printf("Size: %s\n", formatpkg.Size(int64(len(data)))) - fmt.Printf("Saved to: %s\n", outputPath) + fmt.Printf("Saved to: %s\n", sanitize.Filename(outputPath)) return nil }, } diff --git a/internal/cmd/drive/drives.go b/internal/cmd/drive/drives.go index 676a244..0321f57 100644 --- a/internal/cmd/drive/drives.go +++ b/internal/cmd/drive/drives.go @@ -11,6 +11,7 @@ import ( "github.com/open-cli-collective/google-cli/internal/api/drive" "github.com/open-cli-collective/google-cli/internal/cache" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) func newDrivesCommand() *cobra.Command { @@ -102,7 +103,7 @@ func printSharedDrives(drives []*drive.SharedDrive) { _, _ = fmt.Fprintln(w, "ID\tNAME") for _, d := range drives { - _, _ = fmt.Fprintf(w, "%s\t%s\n", d.ID, d.Name) + _, _ = fmt.Fprintf(w, "%s\t%s\n", d.ID, sanitize.Output(d.Name)) } _ = w.Flush() diff --git a/internal/cmd/drive/get.go b/internal/cmd/drive/get.go index 255660a..9f3c0f1 100644 --- a/internal/cmd/drive/get.go +++ b/internal/cmd/drive/get.go @@ -8,6 +8,7 @@ import ( "github.com/open-cli-collective/google-cli/internal/api/drive" "github.com/open-cli-collective/google-cli/internal/format" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) func newGetCommand() *cobra.Command { @@ -45,7 +46,7 @@ func printFileDetails(f *drive.File) { fmt.Println("────────────────────────────────────────") fmt.Printf("ID: %s\n", f.ID) - fmt.Printf("Name: %s\n", f.Name) + fmt.Printf("Name: %s\n", sanitize.Filename(f.Name)) fmt.Printf("Type: %s\n", drive.GetTypeName(f.MimeType)) if f.Size > 0 { @@ -63,7 +64,7 @@ func printFileDetails(f *drive.File) { } if len(f.Owners) > 0 { - fmt.Printf("Owner: %s\n", strings.Join(f.Owners, ", ")) + fmt.Printf("Owner: %s\n", sanitize.Output(strings.Join(f.Owners, ", "))) } if f.Shared { @@ -73,10 +74,10 @@ func printFileDetails(f *drive.File) { } if f.WebViewLink != "" { - fmt.Printf("Web Link: %s\n", f.WebViewLink) + fmt.Printf("Web Link: %s\n", sanitize.Output(f.WebViewLink)) } if len(f.Parents) > 0 { - fmt.Printf("Parent: %s\n", strings.Join(f.Parents, ", ")) + fmt.Printf("Parent: %s\n", sanitize.Output(strings.Join(f.Parents, ", "))) } } diff --git a/internal/cmd/drive/list.go b/internal/cmd/drive/list.go index a389351..77540a4 100644 --- a/internal/cmd/drive/list.go +++ b/internal/cmd/drive/list.go @@ -11,6 +11,7 @@ import ( "github.com/open-cli-collective/google-cli/internal/api/drive" "github.com/open-cli-collective/google-cli/internal/format" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) func newListCommand() *cobra.Command { @@ -199,7 +200,7 @@ func printFileTable(files []*drive.File) { typeName := drive.GetTypeName(f.MimeType) _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", - f.ID, f.Name, typeName, size, modified) + f.ID, sanitize.Filename(f.Name), typeName, size, modified) } _ = w.Flush() diff --git a/internal/cmd/drive/tree.go b/internal/cmd/drive/tree.go index 59ba193..e7da021 100644 --- a/internal/cmd/drive/tree.go +++ b/internal/cmd/drive/tree.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/open-cli-collective/google-cli/internal/api/drive" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) // TreeNode represents a node in the folder tree @@ -173,7 +174,7 @@ func buildTreeWithScope(ctx context.Context, client DriveClient, folderID, rootN // printTree prints the tree structure with tree characters func printTree(node *TreeNode, prefix string, isRoot bool) { if isRoot { - fmt.Println(node.Name) + fmt.Println(sanitize.Filename(node.Name)) } for i, child := range node.Children { @@ -181,9 +182,9 @@ func printTree(node *TreeNode, prefix string, isRoot bool) { // Print the current line if isLast { - fmt.Printf("%s└── %s\n", prefix, child.Name) + fmt.Printf("%s└── %s\n", prefix, sanitize.Filename(child.Name)) } else { - fmt.Printf("%s├── %s\n", prefix, child.Name) + fmt.Printf("%s├── %s\n", prefix, sanitize.Filename(child.Name)) } // Print children with updated prefix diff --git a/internal/cmd/init/init.go b/internal/cmd/init/init.go index f2c9169..3864fdc 100644 --- a/internal/cmd/init/init.go +++ b/internal/cmd/init/init.go @@ -27,6 +27,7 @@ import ( "github.com/open-cli-collective/google-cli/internal/config" "github.com/open-cli-collective/google-cli/internal/identitycache" "github.com/open-cli-collective/google-cli/internal/keychain" + "github.com/open-cli-collective/google-cli/internal/sanitize" "github.com/open-cli-collective/google-cli/internal/view" ) @@ -426,8 +427,8 @@ func runWith(ctx context.Context, d initDeps, opts *initOptions) error { d.View.Printf("Setting up profile: %s\n", ref) } if cachedEmail != "" { - d.View.Printf("Currently holds: %s\n", cachedEmail) - target = fmt.Sprintf("%s (%s)", ref, cachedEmail) + d.View.Printf("Currently holds: %s\n", sanitize.Output(cachedEmail)) + target = fmt.Sprintf("%s (%s)", ref, sanitize.Output(cachedEmail)) } if opts.profile == "" { d.View.Printf("To add a different account instead, use '%s init --profile '.\n", config.ProductName()) @@ -531,7 +532,7 @@ func runWith(ctx context.Context, d initDeps, opts *initOptions) error { if err != nil { return fmt.Errorf("verifying Gmail API: %w", err) } - d.View.Success("Verified Gmail API for %s", email) + d.View.Success("Verified Gmail API for %s", sanitize.Output(email)) if d.RecordIdentity != nil { d.RecordIdentity(email) } @@ -543,7 +544,7 @@ func runWith(ctx context.Context, d initDeps, opts *initOptions) error { } d.View.Println("") _, _ = fmt.Fprintf(d.View.Out, "%s | %s | %s\n", - oneLinerField(profile.ResourceName), oneLinerField(profile.DisplayName), oneLinerField(profile.PrimaryEmail)) + oneLinerField(profile.ResourceName), oneLinerField(sanitize.Output(profile.DisplayName)), oneLinerField(sanitize.Output(profile.PrimaryEmail))) } } @@ -676,7 +677,7 @@ func tryExistingToken(ctx context.Context, d initDeps, opts *initOptions, target } return false, err } - d.View.Success("Already authenticated as %s", email) + d.View.Success("Already authenticated as %s", sanitize.Output(email)) if d.RecordIdentity != nil { d.RecordIdentity(email) } @@ -743,7 +744,7 @@ func finishExisting(d initDeps, profile *people.Profile) error { if profile != nil { d.View.Println("") _, _ = fmt.Fprintf(d.View.Out, "%s | %s | %s\n", - oneLinerField(profile.ResourceName), oneLinerField(profile.DisplayName), oneLinerField(profile.PrimaryEmail)) + oneLinerField(profile.ResourceName), oneLinerField(sanitize.Output(profile.DisplayName)), oneLinerField(sanitize.Output(profile.PrimaryEmail))) } return nil } diff --git a/internal/cmd/mail/attachments_download.go b/internal/cmd/mail/attachments_download.go index 11cb3a0..d7e3fd5 100644 --- a/internal/cmd/mail/attachments_download.go +++ b/internal/cmd/mail/attachments_download.go @@ -12,6 +12,7 @@ import ( "github.com/open-cli-collective/google-cli/internal/api/gmail" "github.com/open-cli-collective/google-cli/internal/config" "github.com/open-cli-collective/google-cli/internal/format" + "github.com/open-cli-collective/google-cli/internal/sanitize" ziputil "github.com/open-cli-collective/google-cli/internal/zip" ) @@ -112,7 +113,7 @@ Examples: usedNames[downloadName] = true // Sanitize filename for display to prevent terminal injection - safeFilename := SanitizeFilename(downloadName) + safeFilename := sanitize.Filename(downloadName) // Security: Validate output path to prevent path traversal attacks outputPath, err := safeOutputPath(absOutputDir, downloadName) @@ -132,7 +133,7 @@ Examples: continue } - fmt.Printf("Downloaded: %s (%s)\n", outputPath, format.Size(int64(len(data)))) + fmt.Printf("Downloaded: %s (%s)\n", sanitize.Filename(outputPath), format.Size(int64(len(data)))) // Extract if zip and --extract flag if extract && isZipFile(downloadName, att.MimeType) { @@ -141,7 +142,7 @@ Examples: if err := ziputil.Extract(outputPath, extractDir, ziputil.DefaultOptions()); err != nil { fmt.Fprintf(os.Stderr, "Error extracting %s: %v\n", safeFilename, err) } else { - fmt.Printf("Extracted to: %s\n", extractDir) + fmt.Printf("Extracted to: %s\n", sanitize.Filename(extractDir)) } } } diff --git a/internal/cmd/mail/attachments_list.go b/internal/cmd/mail/attachments_list.go index 8ce3780..f64edfe 100644 --- a/internal/cmd/mail/attachments_list.go +++ b/internal/cmd/mail/attachments_list.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/open-cli-collective/google-cli/internal/format" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) func newListAttachmentsCommand() *cobra.Command { @@ -38,7 +39,7 @@ Examples: fmt.Printf("Found %d attachment(s):\n\n", len(attachments)) for i, att := range attachments { // Sanitize filename to prevent terminal injection from malicious attachment names - fmt.Printf("%d. %s\n", i+1, SanitizeFilename(att.Filename)) + fmt.Printf("%d. %s\n", i+1, sanitize.Filename(att.Filename)) fmt.Printf(" Type: %s\n", att.MimeType) fmt.Printf(" Size: %s\n", format.Size(att.Size)) if att.IsInline { diff --git a/internal/cmd/mail/draft.go b/internal/cmd/mail/draft.go index fd6ea0b..389b770 100644 --- a/internal/cmd/mail/draft.go +++ b/internal/cmd/mail/draft.go @@ -17,6 +17,7 @@ import ( xhtml "golang.org/x/net/html" gmailapi "github.com/open-cli-collective/google-cli/internal/api/gmail" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) func newDraftCommand() *cobra.Command { @@ -311,18 +312,18 @@ Examples: } fmt.Printf("Draft created: %s\n", result.ID) - fmt.Printf("To: %s\n", SanitizeOutput(strings.Join(toAddrs, ", "))) + fmt.Printf("To: %s\n", sanitize.Output(strings.Join(toAddrs, ", "))) if len(ccAddrs) > 0 { - fmt.Printf("Cc: %s\n", SanitizeOutput(strings.Join(ccAddrs, ", "))) + fmt.Printf("Cc: %s\n", sanitize.Output(strings.Join(ccAddrs, ", "))) } if len(bccAddrs) > 0 { - fmt.Printf("Bcc: %s\n", SanitizeOutput(strings.Join(bccAddrs, ", "))) + fmt.Printf("Bcc: %s\n", sanitize.Output(strings.Join(bccAddrs, ", "))) } - fmt.Printf("Subject: %s\n", SanitizeOutput(subject)) + fmt.Printf("Subject: %s\n", sanitize.Output(subject)) if len(attachments) > 0 { fmt.Printf("Attachments: %d\n", len(attachments)) for _, a := range attachments { - fmt.Printf(" - %s\n", SanitizeOutput(a.Filename)) + fmt.Printf(" - %s\n", sanitize.Filename(a.Filename)) } } return nil @@ -485,7 +486,7 @@ func replyAttribution(src *gmailapi.Message) string { if t, err := mail.ParseDate(src.Date); err == nil { when = t.Format("Mon, Jan 2, 2006 at 3:04 PM") } - return fmt.Sprintf("On %s %s wrote:", when, src.From) + return fmt.Sprintf("On %s %s wrote:", when, sanitize.Output(src.From)) } // quotePlain prefixes each line of body for a plain-text reply: a non-empty diff --git a/internal/cmd/mail/labels.go b/internal/cmd/mail/labels.go index bad9121..c30ad82 100644 --- a/internal/cmd/mail/labels.go +++ b/internal/cmd/mail/labels.go @@ -9,6 +9,7 @@ import ( gmailapi "google.golang.org/api/gmail/v1" "github.com/open-cli-collective/google-cli/internal/format" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) // Label represents a Gmail label for output @@ -70,7 +71,7 @@ Examples: fmt.Println(strings.Repeat("-", 60)) for _, label := range labels { fmt.Printf("%-30s %-10s %8d %8d\n", - format.Truncate(label.Name, 30), + format.Truncate(sanitize.Output(label.Name), 30), label.Type, label.MessagesTotal, label.MessagesUnread) diff --git a/internal/cmd/mail/output.go b/internal/cmd/mail/output.go index 41e80ad..0727df9 100644 --- a/internal/cmd/mail/output.go +++ b/internal/cmd/mail/output.go @@ -8,6 +8,7 @@ import ( gmailv1 "google.golang.org/api/gmail/v1" "github.com/open-cli-collective/google-cli/internal/api/gmail" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) // MailClient defines the interface for Gmail client operations used by mail commands. @@ -55,20 +56,20 @@ func printMessageHeader(msg *gmail.Message, opts MessagePrintOptions) { fmt.Printf("ThreadID: %s\n", msg.ThreadID) } // Sanitize user-provided content to prevent terminal injection attacks - fmt.Printf("From: %s\n", SanitizeOutput(msg.From)) + fmt.Printf("From: %s\n", sanitize.Output(msg.From)) if opts.IncludeTo { - fmt.Printf("To: %s\n", SanitizeOutput(msg.To)) + fmt.Printf("To: %s\n", sanitize.Output(msg.To)) } - fmt.Printf("Subject: %s\n", SanitizeOutput(msg.Subject)) + fmt.Printf("Subject: %s\n", sanitize.Output(msg.Subject)) fmt.Printf("Date: %s\n", msg.Date) if len(msg.Labels) > 0 { - fmt.Printf("Labels: %s\n", strings.Join(msg.Labels, ", ")) + fmt.Printf("Labels: %s\n", sanitize.Output(strings.Join(msg.Labels, ", "))) } if len(msg.Categories) > 0 { - fmt.Printf("Categories: %s\n", strings.Join(msg.Categories, ", ")) + fmt.Printf("Categories: %s\n", sanitize.Output(strings.Join(msg.Categories, ", "))) } if opts.IncludeSnippet { - fmt.Printf("Snippet: %s\n", SanitizeOutput(msg.Snippet)) + fmt.Printf("Snippet: %s\n", sanitize.Output(msg.Snippet)) } if opts.IncludeBody { body := msg.Body @@ -76,6 +77,6 @@ func printMessageHeader(msg *gmail.Message, opts MessagePrintOptions) { body, _ = elideQuotedReplyBody(body, msg.BodyIsHTML) } fmt.Print("\n--- Body ---\n\n") - fmt.Println(SanitizeOutput(body)) + fmt.Println(sanitize.Output(body)) } } diff --git a/internal/cmd/me/output.go b/internal/cmd/me/output.go index d0b9377..7128d48 100644 --- a/internal/cmd/me/output.go +++ b/internal/cmd/me/output.go @@ -9,6 +9,7 @@ import ( "github.com/open-cli-collective/google-cli/internal/api/gmail" "github.com/open-cli-collective/google-cli/internal/api/people" "github.com/open-cli-collective/google-cli/internal/keychain" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) // PeopleClient defines the interface for People client operations used by the me command. @@ -81,6 +82,7 @@ func RenderID(w io.Writer, p *people.Profile) { } func normalizeField(s string) string { + s = sanitize.Output(s) if s == "" { return "-" } diff --git a/internal/cmd/profiles/profiles.go b/internal/cmd/profiles/profiles.go index 78019eb..d99dfef 100644 --- a/internal/cmd/profiles/profiles.go +++ b/internal/cmd/profiles/profiles.go @@ -27,6 +27,7 @@ import ( "github.com/open-cli-collective/google-cli/internal/identitycache" "github.com/open-cli-collective/google-cli/internal/keychain" "github.com/open-cli-collective/google-cli/internal/output" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) // NewCommand returns the profiles command with subcommands. @@ -174,7 +175,7 @@ func runList(ctx context.Context, jsonOut, check bool) error { if email == "" { email = "-" } - line := fmt.Sprintf("%s\t%s\t%s\t%s", marker, r.Profile, presence(r.TokenPresent), email) + line := fmt.Sprintf("%s\t%s\t%s\t%s", marker, r.Profile, presence(r.TokenPresent), sanitize.Output(email)) if check { line += "\t" + r.Health } diff --git a/internal/rwcmd/calendar/output.go b/internal/rwcmd/calendar/output.go index adec8c8..076c985 100644 --- a/internal/rwcmd/calendar/output.go +++ b/internal/rwcmd/calendar/output.go @@ -7,6 +7,7 @@ import ( calendarapi "github.com/open-cli-collective/google-cli/internal/api/calendar" calendarcmd "github.com/open-cli-collective/google-cli/internal/cmd/calendar" calendarrw "github.com/open-cli-collective/google-cli/internal/rw/calendar" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) // WriteClient is the Calendar surface used by grw commands. @@ -29,7 +30,7 @@ func printEvent(event *calendarapi.Event) { fmt.Printf("ID: %s\n", event.ID) } if event.Summary != "" { - fmt.Printf("Summary: %s\n", event.Summary) + fmt.Printf("Summary: %s\n", sanitize.Output(event.Summary)) } if event.Start != nil { fmt.Printf("Start: %s\n", eventTimeValue(event.Start)) @@ -38,13 +39,13 @@ func printEvent(event *calendarapi.Event) { fmt.Printf("End: %s\n", eventTimeValue(event.End)) } if event.Location != "" { - fmt.Printf("Location: %s\n", event.Location) + fmt.Printf("Location: %s\n", sanitize.Output(event.Location)) } if event.Description != "" { - fmt.Printf("Description: %s\n", event.Description) + fmt.Printf("Description: %s\n", sanitize.Output(event.Description)) } for _, attendee := range event.Attendees { - fmt.Printf("Attendee: %s\n", attendee.Email) + fmt.Printf("Attendee: %s\n", sanitize.Output(attendee.Email)) } } diff --git a/internal/rwcmd/contacts/output.go b/internal/rwcmd/contacts/output.go index 56166f8..e931479 100644 --- a/internal/rwcmd/contacts/output.go +++ b/internal/rwcmd/contacts/output.go @@ -8,6 +8,7 @@ import ( contactsapi "github.com/open-cli-collective/google-cli/internal/api/contacts" contactscmd "github.com/open-cli-collective/google-cli/internal/cmd/contacts" contactsrw "github.com/open-cli-collective/google-cli/internal/rw/contacts" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) // WriteClient is the Contacts surface used by grw commands. @@ -32,10 +33,10 @@ func printContact(contact *contactsapi.Contact) { } if len(contact.Names) > 0 { name := contact.Names[0] - fmt.Printf("Name: %s\n", strings.TrimSpace(strings.Join([]string{name.HonorificPrefix, name.GivenName, name.MiddleName, name.FamilyName, name.HonorificSuffix}, " "))) + fmt.Printf("Name: %s\n", sanitize.Output(strings.TrimSpace(strings.Join([]string{name.HonorificPrefix, name.GivenName, name.MiddleName, name.FamilyName, name.HonorificSuffix}, " ")))) } for _, email := range contact.Emails { - fmt.Printf("Email: %s", email.Value) + fmt.Printf("Email: %s", sanitize.Output(email.Value)) if email.Type != "" { fmt.Printf(" [%s]", email.Type) } @@ -45,33 +46,33 @@ func printContact(contact *contactsapi.Contact) { fmt.Println() } for _, phone := range contact.Phones { - fmt.Printf("Phone: %s", phone.Value) + fmt.Printf("Phone: %s", sanitize.Output(phone.Value)) if phone.Type != "" { fmt.Printf(" [%s]", phone.Type) } fmt.Println() } for _, organization := range contact.Organizations { - fmt.Printf("Organization: %s", organization.Name) + fmt.Printf("Organization: %s", sanitize.Output(organization.Name)) if organization.Title != "" { - fmt.Printf(" (%s)", organization.Title) + fmt.Printf(" (%s)", sanitize.Output(organization.Title)) } if organization.Department != "" { - fmt.Printf(" - %s", organization.Department) + fmt.Printf(" - %s", sanitize.Output(organization.Department)) } fmt.Println() } for _, address := range contact.Addresses { - fmt.Printf("Address: %s\n", address.FormattedValue) + fmt.Printf("Address: %s\n", sanitize.Output(address.FormattedValue)) } for _, url := range contact.URLs { - fmt.Printf("URL: %s\n", url.Value) + fmt.Printf("URL: %s\n", sanitize.Output(url.Value)) } if contact.Biography != "" { - fmt.Printf("Biography: %s\n", contact.Biography) + fmt.Printf("Biography: %s\n", sanitize.Output(contact.Biography)) } if contact.Birthday != "" { - fmt.Printf("Birthday: %s\n", contact.Birthday) + fmt.Printf("Birthday: %s\n", sanitize.Output(contact.Birthday)) } } @@ -79,5 +80,5 @@ func printGroup(group *contactsapi.ContactGroup) { if group.ResourceName != "" { fmt.Printf("ID: %s\n", group.ResourceName) } - fmt.Printf("Name: %s\n", group.Name) + fmt.Printf("Name: %s\n", sanitize.Output(group.Name)) } diff --git a/internal/rwcmd/drive/output.go b/internal/rwcmd/drive/output.go index 1c639e6..f3a9f5a 100644 --- a/internal/rwcmd/drive/output.go +++ b/internal/rwcmd/drive/output.go @@ -7,8 +7,8 @@ import ( driveapi "github.com/open-cli-collective/google-cli/internal/api/drive" drivecmd "github.com/open-cli-collective/google-cli/internal/cmd/drive" - mailcmd "github.com/open-cli-collective/google-cli/internal/cmd/mail" driverw "github.com/open-cli-collective/google-cli/internal/rw/drive" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) // WriteClient is the Drive surface used by grw commands. @@ -28,14 +28,14 @@ var ClientFactory = func(ctx context.Context) (WriteClient, error) { return driv func newWriteClient(ctx context.Context) (WriteClient, error) { return ClientFactory(ctx) } -// printFile prints a file's identity. Names come from Drive, where a -// collaborator may have set them, so they are sanitized before reaching the -// terminal. +// printFile prints a file's identity. Every field comes from Drive, where a +// collaborator may have set it, so all of them are sanitized before reaching +// the terminal. func printFile(file *driveapi.File) { - fmt.Printf("ID: %s\n", mailcmd.SanitizeOutput(file.ID)) - fmt.Printf("Name: %s\n", mailcmd.SanitizeFilename(file.Name)) - fmt.Printf("Type: %s\n", mailcmd.SanitizeOutput(file.MimeType)) + fmt.Printf("ID: %s\n", sanitize.Output(file.ID)) + fmt.Printf("Name: %s\n", sanitize.Filename(file.Name)) + fmt.Printf("Type: %s\n", sanitize.Output(file.MimeType)) if len(file.Parents) > 0 { - fmt.Printf("Parent: %s\n", mailcmd.SanitizeOutput(file.Parents[0])) + fmt.Printf("Parent: %s\n", sanitize.Output(file.Parents[0])) } } diff --git a/internal/rwcmd/mail/filter.go b/internal/rwcmd/mail/filter.go index 5706607..3c196f8 100644 --- a/internal/rwcmd/mail/filter.go +++ b/internal/rwcmd/mail/filter.go @@ -6,6 +6,8 @@ import ( "github.com/spf13/cobra" gmailv1 "google.golang.org/api/gmail/v1" + + "github.com/open-cli-collective/google-cli/internal/sanitize" ) func newFilterCommand() *cobra.Command { @@ -46,8 +48,8 @@ func newFilterListCommand() *cobra.Command { } for _, f := range filters { fmt.Printf("%s\n", f.Id) - fmt.Printf(" when: %s\n", criteriaSummary(f.Criteria)) - fmt.Printf(" then: %s\n", actionSummary(f.Action, names)) + fmt.Printf(" when: %s\n", sanitize.Output(criteriaSummary(f.Criteria))) + fmt.Printf(" then: %s\n", sanitize.Output(actionSummary(f.Action, names))) } return nil }, diff --git a/internal/rwcmd/mail/folder.go b/internal/rwcmd/mail/folder.go index abfd4e5..048f7a8 100644 --- a/internal/rwcmd/mail/folder.go +++ b/internal/rwcmd/mail/folder.go @@ -4,6 +4,8 @@ import ( "fmt" "github.com/spf13/cobra" + + "github.com/open-cli-collective/google-cli/internal/sanitize" ) // newFolderCommand groups the label-lifecycle operations. In Gmail a "folder" @@ -42,7 +44,7 @@ func newFolderCreateCommand() *cobra.Command { if err != nil { return err } - fmt.Printf("Created folder %q (id %s).\n", label.Name, label.Id) + fmt.Printf("Created folder %q (id %s).\n", sanitize.Output(label.Name), label.Id) return nil }, } @@ -74,7 +76,7 @@ func newFolderRenameCommand() *cobra.Command { if err != nil { return err } - fmt.Printf("Renamed folder to %q.\n", label.Name) + fmt.Printf("Renamed folder to %q.\n", sanitize.Output(label.Name)) return nil }, } diff --git a/internal/rwcmd/mail/send.go b/internal/rwcmd/mail/send.go index 4ccba99..35722e0 100644 --- a/internal/rwcmd/mail/send.go +++ b/internal/rwcmd/mail/send.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" gmailapi "github.com/open-cli-collective/google-cli/internal/api/gmail" - mailcmd "github.com/open-cli-collective/google-cli/internal/cmd/mail" + "github.com/open-cli-collective/google-cli/internal/sanitize" ) func newSendCommand() *cobra.Command { @@ -50,10 +50,10 @@ elsewhere between the preview and the send, the edited version goes out.`, } func printDraftSummary(draft *gmailapi.DraftSummary) { - fmt.Printf("From: %s\n", mailcmd.SanitizeOutput(draft.From)) - fmt.Printf("To: %s\n", mailcmd.SanitizeOutput(draft.To)) - fmt.Printf("Cc: %s\n", mailcmd.SanitizeOutput(draft.Cc)) - fmt.Printf("Bcc: %s\n", mailcmd.SanitizeOutput(draft.Bcc)) - fmt.Printf("Subject: %s\n", mailcmd.SanitizeOutput(draft.Subject)) + fmt.Printf("From: %s\n", sanitize.Output(draft.From)) + fmt.Printf("To: %s\n", sanitize.Output(draft.To)) + fmt.Printf("Cc: %s\n", sanitize.Output(draft.Cc)) + fmt.Printf("Bcc: %s\n", sanitize.Output(draft.Bcc)) + fmt.Printf("Subject: %s\n", sanitize.Output(draft.Subject)) fmt.Printf("Attachments: %d\n", draft.AttachmentCount) } diff --git a/internal/cmd/mail/sanitize.go b/internal/sanitize/sanitize.go similarity index 84% rename from internal/cmd/mail/sanitize.go rename to internal/sanitize/sanitize.go index e794ae1..beaac7c 100644 --- a/internal/cmd/mail/sanitize.go +++ b/internal/sanitize/sanitize.go @@ -1,4 +1,5 @@ -package mail +// Package sanitize removes terminal control sequences from untrusted text. +package sanitize import ( "regexp" @@ -15,10 +16,10 @@ var ansiEscapeRegex = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*\ // excluding common whitespace (tab, newline, carriage return) var controlCharRegex = regexp.MustCompile(`[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f]`) -// SanitizeOutput removes ANSI escape sequences and dangerous control characters +// Output removes ANSI escape sequences and dangerous control characters // from a string to prevent terminal injection attacks. Safe whitespace characters // (tab, newline, carriage return) are preserved. -func SanitizeOutput(s string) string { +func Output(s string) string { // Remove ANSI escape sequences s = ansiEscapeRegex.ReplaceAllString(s, "") @@ -28,11 +29,11 @@ func SanitizeOutput(s string) string { return s } -// SanitizeFilename sanitizes a filename for display, removing potentially +// Filename sanitizes a filename for display, removing potentially // dangerous characters while preserving readability. -func SanitizeFilename(s string) string { +func Filename(s string) string { // First apply general output sanitization - s = SanitizeOutput(s) + s = Output(s) // Additionally handle Unicode direction overrides that could be used // to disguise file extensions (e.g., making "evil.exe" appear as "exe.live") diff --git a/internal/cmd/mail/sanitize_test.go b/internal/sanitize/sanitize_test.go similarity index 94% rename from internal/cmd/mail/sanitize_test.go rename to internal/sanitize/sanitize_test.go index 17e58d7..4bfd380 100644 --- a/internal/cmd/mail/sanitize_test.go +++ b/internal/sanitize/sanitize_test.go @@ -1,4 +1,4 @@ -package mail +package sanitize import ( "testing" @@ -6,7 +6,7 @@ import ( "github.com/open-cli-collective/google-cli/internal/testutil" ) -func TestSanitizeOutput(t *testing.T) { +func TestOutput(t *testing.T) { t.Parallel() tests := []struct { name string @@ -123,13 +123,13 @@ func TestSanitizeOutput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - result := SanitizeOutput(tt.input) + result := Output(tt.input) testutil.Equal(t, result, tt.expected) }) } } -func TestSanitizeFilename(t *testing.T) { +func TestFilename(t *testing.T) { t.Parallel() tests := []struct { name string @@ -186,13 +186,13 @@ func TestSanitizeFilename(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - result := SanitizeFilename(tt.input) + result := Filename(tt.input) testutil.Equal(t, result, tt.expected) }) } } -func TestSanitizeOutput_RealWorldExamples(t *testing.T) { +func TestOutput_RealWorldExamples(t *testing.T) { t.Parallel() tests := []struct { name string @@ -219,7 +219,7 @@ func TestSanitizeOutput_RealWorldExamples(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - result := SanitizeOutput(tt.input) + result := Output(tt.input) testutil.Equal(t, result, tt.expected) }) }