Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ The dependency direction is deliberate:
- Write commands in `internal/rwcmd/<domain>` 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low (documentation:docs-reviewer): The updated support-package list adds both sanitize and view, but the PR description only introduces internal/sanitize; there is no mention of a new or previously-undocumented view package. Confirm internal/view exists and that its addition here is intentional rather than an unrelated/accidental edit, since an incorrect package name in this list would mislead readers about the codebase's structure.

Reply to this thread when addressed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

internal/view exists (the Success/Info/Error output helper used by init and profiles) and was missing from the list; adding it alongside sanitize is intentional.


## Domain shape

Expand Down
6 changes: 6 additions & 0 deletions docs/golden-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
200 changes: 200 additions & 0 deletions internal/architecture/sanitize_test.go
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium (harness-engineering:harness-enforcement-reviewer): The new detector (isOutputCall/inspectPrintedArgument) only matches direct struct-field selectors (e.g. event.Summary) against the DTO text-field set. It does not recognize text reached through a getter method (e.g. contact.GetDisplayName(), contact.GetPrimaryEmail(), contact.GetPrimaryPhone(), contact.GetOrganization() in internal/cmd/contacts/output.go), since the call's selector name never matches a raw field name in the collected set. Those exact call sites are correctly wrapped in sanitize.Output today, but if a future edit drops the wrapper around a getter-derived value, this architecture test — the enforcement mechanism golden-principles.md item 11 cites — would not catch the regression. Worth extending the detector to also flag calls whose selector matches 'Get' for any field in the DTO text-field set.

Reply to this thread when addressed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cce4479: the text-source set now includes every exported argument-less string getter declared on the DTOs, so contact.GetDisplayName() and friends are held to the same rule. Verified by unwrapping one getter site and watching the test fail.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up in 774ac60: the getter rule is limited to Get* methods; the first cut also caught Event.FormatTimeRange, which renders only dates.

}
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
}
29 changes: 15 additions & 14 deletions internal/cmd/calendar/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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))
}
}

Expand All @@ -59,32 +60,32 @@ 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)
}
}
}

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("---")
Expand All @@ -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)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/cmd/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading