detached 1d86c5b

Merge branch 'main' into backlog/testcov-headless-tests

commit
1d86c5b2cb52ec6fc54fa4db5e4b8d101c51391e
author
bnema · · 2026-06-20 13:01 UTC
committer
GitHub · · 2026-06-20 13:01 UTC
merge of
42f0f48 + cfbaa81 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +403 -43
M internal/application/usecase/manage_favorites.go +9 -9
@@ -59,7 +59,7 @@ type AddFavoriteInput struct {
5959 // Add creates a new favorite.
6060 func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
6161 log := logging.FromContext(ctx)
62- log.Debug().Str("url", input.URL).Str("title", input.Title).Msg("adding favorite")
62+ log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("adding favorite")
6363
6464 // Check if already favorited
6565 existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL)
@@ -67,7 +67,7 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
6767 return nil, fmt.Errorf("failed to check existing favorite: %w", err)
6868 }
6969 if existing != nil {
70- log.Debug().Str("url", input.URL).Msg("URL already favorited")
70+ log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("URL already favorited")
7171 return existing, nil
7272 }
7373
@@ -95,7 +95,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
9595 }
9696 }
9797
98- log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added")
98+ log.Info().Str("url", logging.RedactURL(input.URL)).Int64("id", int64(fav.ID)).Msg("favorite added")
9999 uc.invalidateCache()
100100 return fav, nil
101101 }
@@ -235,14 +235,14 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite
235235 // RemoveByURL deletes a favorite by its URL.
236236 func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error {
237237 log := logging.FromContext(ctx)
238- log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL")
238+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("removing favorite by URL")
239239
240240 fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
241241 if err != nil {
242242 return fmt.Errorf("failed to find favorite: %w", err)
243243 }
244244 if fav == nil {
245- log.Debug().Str("url", favoriteURL).Msg("favorite not found")
245+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("favorite not found")
246246 return nil
247247 }
248248
@@ -314,7 +314,7 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*
314314 // GetByURL finds a favorite by its URL.
315315 func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) {
316316 log := logging.FromContext(ctx)
317- log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL")
317+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("getting favorite by URL")
318318
319319 return uc.favoriteRepo.FindByURL(ctx, favoriteURL)
320320 }
@@ -455,7 +455,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri
455455 title = strings.TrimSpace(title)
456456
457457 log := logging.FromContext(ctx)
458- log.Debug().Str("url", favoriteURL).Msg("toggling favorite")
458+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("toggling favorite")
459459
460460 // Check if already favorited, including legacy rows stored before URL canonicalization.
461461 existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL)
@@ -468,7 +468,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri
468468 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
469469 return nil, fmt.Errorf("failed to remove favorite: %w", err)
470470 }
471- log.Info().Str("url", favoriteURL).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
471+ log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
472472 uc.invalidateCache()
473473 return &ToggleResult{
474474 Added: false,
@@ -483,7 +483,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri
483483 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
484484 return nil, fmt.Errorf("failed to add favorite: %w", err)
485485 }
486- log.Info().Str("url", favoriteURL).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
486+ log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
487487 uc.invalidateCache()
488488 return &ToggleResult{
489489 Added: true,
@@ -59,7 +59,7 @@ type AddFavoriteInput struct {
59 // Add creates a new favorite.59 // Add creates a new favorite.
60 func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {60 func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
61 log := logging.FromContext(ctx)61 log := logging.FromContext(ctx)
62- log.Debug().Str("url", input.URL).Str("title", input.Title).Msg("adding favorite")62+ log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("adding favorite")
63 63
64 // Check if already favorited64 // Check if already favorited
65 existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL)65 existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL)
@@ -67,7 +67,7 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
67 return nil, fmt.Errorf("failed to check existing favorite: %w", err)67 return nil, fmt.Errorf("failed to check existing favorite: %w", err)
68 }68 }
69 if existing != nil {69 if existing != nil {
70- log.Debug().Str("url", input.URL).Msg("URL already favorited")70+ log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("URL already favorited")
71 return existing, nil71 return existing, nil
72 }72 }
73 73
@@ -95,7 +95,7 @@ func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddF
95 }95 }
96 }96 }
97 97
98- log.Info().Str("url", input.URL).Int64("id", int64(fav.ID)).Msg("favorite added")98+ log.Info().Str("url", logging.RedactURL(input.URL)).Int64("id", int64(fav.ID)).Msg("favorite added")
99 uc.invalidateCache()99 uc.invalidateCache()
100 return fav, nil100 return fav, nil
101 }101 }
@@ -235,14 +235,14 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite
235 // RemoveByURL deletes a favorite by its URL.235 // RemoveByURL deletes a favorite by its URL.
236 func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error {236 func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error {
237 log := logging.FromContext(ctx)237 log := logging.FromContext(ctx)
238- log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL")238+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("removing favorite by URL")
239 239
240 fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)240 fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
241 if err != nil {241 if err != nil {
242 return fmt.Errorf("failed to find favorite: %w", err)242 return fmt.Errorf("failed to find favorite: %w", err)
243 }243 }
244 if fav == nil {244 if fav == nil {
245- log.Debug().Str("url", favoriteURL).Msg("favorite not found")245+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("favorite not found")
246 return nil246 return nil
247 }247 }
248 248
@@ -314,7 +314,7 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*
314 // GetByURL finds a favorite by its URL.314 // GetByURL finds a favorite by its URL.
315 func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) {315 func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) {
316 log := logging.FromContext(ctx)316 log := logging.FromContext(ctx)
317- log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL")317+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("getting favorite by URL")
318 318
319 return uc.favoriteRepo.FindByURL(ctx, favoriteURL)319 return uc.favoriteRepo.FindByURL(ctx, favoriteURL)
320 }320 }
@@ -455,7 +455,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri
455 title = strings.TrimSpace(title)455 title = strings.TrimSpace(title)
456 456
457 log := logging.FromContext(ctx)457 log := logging.FromContext(ctx)
458- log.Debug().Str("url", favoriteURL).Msg("toggling favorite")458+ log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("toggling favorite")
459 459
460 // Check if already favorited, including legacy rows stored before URL canonicalization.460 // Check if already favorited, including legacy rows stored before URL canonicalization.
461 existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL)461 existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL)
@@ -468,7 +468,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri
468 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {468 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
469 return nil, fmt.Errorf("failed to remove favorite: %w", err)469 return nil, fmt.Errorf("failed to remove favorite: %w", err)
470 }470 }
471- log.Info().Str("url", favoriteURL).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")471+ log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
472 uc.invalidateCache()472 uc.invalidateCache()
473 return &ToggleResult{473 return &ToggleResult{
474 Added: false,474 Added: false,
@@ -483,7 +483,7 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title stri
483 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {483 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
484 return nil, fmt.Errorf("failed to add favorite: %w", err)484 return nil, fmt.Errorf("failed to add favorite: %w", err)
485 }485 }
486- log.Info().Str("url", favoriteURL).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")486+ log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
487 uc.invalidateCache()487 uc.invalidateCache()
488 return &ToggleResult{488 return &ToggleResult{
489 Added: true,489 Added: true,
M internal/application/usecase/manage_panes.go +8 -0
@@ -112,6 +112,14 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
112112 return nil, fmt.Errorf("target pane is required")
113113 }
114114
115+ // Validate split direction before any mutations
116+ switch input.Direction {
117+ case SplitLeft, SplitRight, SplitUp, SplitDown:
118+ // valid
119+ default:
120+ return nil, fmt.Errorf("invalid split direction: %s", input.Direction)
121+ }
122+
115123 // If target is inside a stack, split around the entire stack
116124 // (i.e., create the new pane as a sibling to the stack, not inside it)
117125 // This applies to all directions - we don't want to nest split containers inside stacks.
@@ -112,6 +112,14 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
112 return nil, fmt.Errorf("target pane is required")112 return nil, fmt.Errorf("target pane is required")
113 }113 }
114 114
115+ // Validate split direction before any mutations
116+ switch input.Direction {
117+ case SplitLeft, SplitRight, SplitUp, SplitDown:
118+ // valid
119+ default:
120+ return nil, fmt.Errorf("invalid split direction: %s", input.Direction)
121+ }
122+
115 // If target is inside a stack, split around the entire stack123 // If target is inside a stack, split around the entire stack
116 // (i.e., create the new pane as a sibling to the stack, not inside it)124 // (i.e., create the new pane as a sibling to the stack, not inside it)
117 // This applies to all directions - we don't want to nest split containers inside stacks.125 // This applies to all directions - we don't want to nest split containers inside stacks.
A internal/application/usecase/manage_panes_split_test.go +91 -0
@@ -0,0 +1,91 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func TestManagePanesUseCase_Split_ValidDirections(t *testing.T) {
12+ uc := NewManagePanesUseCase(func() string { return "id" })
13+ ctx := context.Background()
14+
15+ dirs := []SplitDirection{SplitLeft, SplitRight, SplitUp, SplitDown}
16+ for _, dir := range dirs {
17+ t.Run(string(dir), func(t *testing.T) {
18+ target := leaf("target")
19+ ws := &entity.Workspace{Root: target, ActivePaneID: "target"}
20+ target.Parent = nil // root
21+
22+ out, err := uc.Split(ctx, SplitPaneInput{
23+ Workspace: ws,
24+ TargetPane: target,
25+ Direction: dir,
26+ })
27+ if err != nil {
28+ t.Fatalf("unexpected error for direction %s: %v", dir, err)
29+ }
30+ if out == nil {
31+ t.Fatalf("output is nil for direction %s", dir)
32+ }
33+ if out.NewPaneNode == nil {
34+ t.Fatalf("new pane node is nil for direction %s", dir)
35+ }
36+ if out.ParentNode == nil {
37+ t.Fatalf("parent node is nil for direction %s", dir)
38+ }
39+ if !out.ParentNode.IsSplit() {
40+ t.Fatalf("parent node should be a split for direction %s, got SplitDir=%v", dir, out.ParentNode.SplitDir)
41+ }
42+ if ws.Root != out.ParentNode {
43+ t.Fatalf("workspace root should be the parent node for direction %s", dir)
44+ }
45+ // Verify the tree has 2 leaf panes
46+ if ws.PaneCount() != 2 {
47+ t.Fatalf("workspace should have 2 panes for direction %s, got %d", dir, ws.PaneCount())
48+ }
49+ })
50+ }
51+}
52+
53+func TestManagePanesUseCase_Split_InvalidDirection_ReturnsError(t *testing.T) {
54+ uc := NewManagePanesUseCase(func() string { return "id" })
55+ ctx := context.Background()
56+
57+ target := leaf("target")
58+ ws := &entity.Workspace{Root: target, ActivePaneID: "target"}
59+
60+ // Save initial state
61+ initialRoot := ws.Root
62+ initialActive := ws.ActivePaneID
63+
64+ _, err := uc.Split(ctx, SplitPaneInput{
65+ Workspace: ws,
66+ TargetPane: target,
67+ Direction: SplitDirection("diagonal"),
68+ })
69+ if err == nil {
70+ t.Fatalf("expected error for invalid direction, got nil")
71+ }
72+ if !strings.Contains(err.Error(), "invalid split direction: diagonal") {
73+ t.Fatalf("unexpected error message: %v", err)
74+ }
75+
76+ // Verify workspace state is unchanged
77+ if ws.Root != initialRoot {
78+ t.Fatalf("workspace root was modified")
79+ }
80+ if ws.ActivePaneID != initialActive {
81+ t.Fatalf("workspace active pane was modified")
82+ }
83+ // Verify target pane parent is still nil (was root)
84+ if target.Parent != nil {
85+ t.Fatalf("target pane parent was modified")
86+ }
87+ // Verify pane count is still 1
88+ if ws.PaneCount() != 1 {
89+ t.Fatalf("workspace pane count changed: got %d, want 1", ws.PaneCount())
90+ }
91+}
@@ -0,0 +1,91 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func TestManagePanesUseCase_Split_ValidDirections(t *testing.T) {
12+ uc := NewManagePanesUseCase(func() string { return "id" })
13+ ctx := context.Background()
14+
15+ dirs := []SplitDirection{SplitLeft, SplitRight, SplitUp, SplitDown}
16+ for _, dir := range dirs {
17+ t.Run(string(dir), func(t *testing.T) {
18+ target := leaf("target")
19+ ws := &entity.Workspace{Root: target, ActivePaneID: "target"}
20+ target.Parent = nil // root
21+
22+ out, err := uc.Split(ctx, SplitPaneInput{
23+ Workspace: ws,
24+ TargetPane: target,
25+ Direction: dir,
26+ })
27+ if err != nil {
28+ t.Fatalf("unexpected error for direction %s: %v", dir, err)
29+ }
30+ if out == nil {
31+ t.Fatalf("output is nil for direction %s", dir)
32+ }
33+ if out.NewPaneNode == nil {
34+ t.Fatalf("new pane node is nil for direction %s", dir)
35+ }
36+ if out.ParentNode == nil {
37+ t.Fatalf("parent node is nil for direction %s", dir)
38+ }
39+ if !out.ParentNode.IsSplit() {
40+ t.Fatalf("parent node should be a split for direction %s, got SplitDir=%v", dir, out.ParentNode.SplitDir)
41+ }
42+ if ws.Root != out.ParentNode {
43+ t.Fatalf("workspace root should be the parent node for direction %s", dir)
44+ }
45+ // Verify the tree has 2 leaf panes
46+ if ws.PaneCount() != 2 {
47+ t.Fatalf("workspace should have 2 panes for direction %s, got %d", dir, ws.PaneCount())
48+ }
49+ })
50+ }
51+}
52+
53+func TestManagePanesUseCase_Split_InvalidDirection_ReturnsError(t *testing.T) {
54+ uc := NewManagePanesUseCase(func() string { return "id" })
55+ ctx := context.Background()
56+
57+ target := leaf("target")
58+ ws := &entity.Workspace{Root: target, ActivePaneID: "target"}
59+
60+ // Save initial state
61+ initialRoot := ws.Root
62+ initialActive := ws.ActivePaneID
63+
64+ _, err := uc.Split(ctx, SplitPaneInput{
65+ Workspace: ws,
66+ TargetPane: target,
67+ Direction: SplitDirection("diagonal"),
68+ })
69+ if err == nil {
70+ t.Fatalf("expected error for invalid direction, got nil")
71+ }
72+ if !strings.Contains(err.Error(), "invalid split direction: diagonal") {
73+ t.Fatalf("unexpected error message: %v", err)
74+ }
75+
76+ // Verify workspace state is unchanged
77+ if ws.Root != initialRoot {
78+ t.Fatalf("workspace root was modified")
79+ }
80+ if ws.ActivePaneID != initialActive {
81+ t.Fatalf("workspace active pane was modified")
82+ }
83+ // Verify target pane parent is still nil (was root)
84+ if target.Parent != nil {
85+ t.Fatalf("target pane parent was modified")
86+ }
87+ // Verify pane count is still 1
88+ if ws.PaneCount() != 1 {
89+ t.Fatalf("workspace pane count changed: got %d, want 1", ws.PaneCount())
90+ }
91+}
M internal/application/usecase/navigate.go +11 -11
@@ -124,7 +124,7 @@ type NavigateOutput struct {
124124 func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
125125 log := logging.FromContext(ctx)
126126 log.Debug().
127- Str("url", input.URL).
127+ Str("url", logging.RedactURL(input.URL)).
128128 Str("pane_id", input.PaneID).
129129 Msg("navigating to URL")
130130
@@ -135,7 +135,7 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
135135 }
136136
137137 log.Info().
138- Str("url", input.URL).
138+ Str("url", logging.RedactURL(input.URL)).
139139 Msg("navigation initiated")
140140
141141 return &NavigateOutput{
@@ -309,7 +309,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
309309 // Check if entry exists
310310 existing, err := uc.historyRepo.FindByURL(ctx, record.url)
311311 if err != nil {
312- log.Warn().Err(err).Str("url", record.url).Msg("failed to check history")
312+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history")
313313 return
314314 }
315315
@@ -317,7 +317,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
317317 delta := max(1, record.visits)
318318 if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
319319 if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
320- log.Warn().Err(err).Str("url", record.url).Int("delta", delta).Msg("failed to increment visit count by delta")
320+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta")
321321 return
322322 }
323323 return
@@ -325,7 +325,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
325325 iterations := delta
326326 if iterations > historyFallbackIncrementCap {
327327 log.Warn().
328- Str("url", record.url).
328+ Str("url", logging.RedactURL(record.url)).
329329 Int("delta", delta).
330330 Int("cap", historyFallbackIncrementCap).
331331 Msg("visit count fallback increment capped")
@@ -333,7 +333,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
333333 }
334334 for i := 0; i < iterations; i++ {
335335 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
336- log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
336+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count")
337337 return
338338 }
339339 }
@@ -342,7 +342,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
342342 entry := entity.NewHistoryEntry(record.url, "")
343343 entry.VisitCount = int64(max(1, record.visits))
344344 if err := uc.historyRepo.Save(ctx, entry); err != nil {
345- log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
345+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history")
346346 }
347347 }
348348 }
@@ -358,7 +358,7 @@ func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, t
358358
359359 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
360360 if err != nil {
361- log.Warn().Err(err).Str("url", historyURL).Msg("failed to find history entry for title update")
361+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update")
362362 return
363363 }
364364 if entry == nil {
@@ -368,12 +368,12 @@ func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, t
368368 entry.Title = title
369369 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
370370 if err := updater.UpdateMetadata(ctx, entry); err != nil {
371- log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history metadata")
371+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata")
372372 }
373373 return
374374 }
375375 if err := uc.historyRepo.Save(ctx, entry); err != nil {
376- log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history title")
376+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title")
377377 }
378378 }
379379
@@ -392,7 +392,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, tit
392392 select {
393393 case uc.historyQueue <- historyRecord{url: historyURL, title: title}:
394394 default:
395- logging.FromContext(uc.ctx).Warn().Str("url", historyURL).Msg("history queue full, title update dropped")
395+ logging.FromContext(uc.ctx).Warn().Str("url", logging.RedactURL(historyURL)).Msg("history queue full, title update dropped")
396396 }
397397 }
398398
@@ -124,7 +124,7 @@ type NavigateOutput struct {
124 func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {124 func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*NavigateOutput, error) {
125 log := logging.FromContext(ctx)125 log := logging.FromContext(ctx)
126 log.Debug().126 log.Debug().
127- Str("url", input.URL).127+ Str("url", logging.RedactURL(input.URL)).
128 Str("pane_id", input.PaneID).128 Str("pane_id", input.PaneID).
129 Msg("navigating to URL")129 Msg("navigating to URL")
130 130
@@ -135,7 +135,7 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
135 }135 }
136 136
137 log.Info().137 log.Info().
138- Str("url", input.URL).138+ Str("url", logging.RedactURL(input.URL)).
139 Msg("navigation initiated")139 Msg("navigation initiated")
140 140
141 return &NavigateOutput{141 return &NavigateOutput{
@@ -309,7 +309,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
309 // Check if entry exists309 // Check if entry exists
310 existing, err := uc.historyRepo.FindByURL(ctx, record.url)310 existing, err := uc.historyRepo.FindByURL(ctx, record.url)
311 if err != nil {311 if err != nil {
312- log.Warn().Err(err).Str("url", record.url).Msg("failed to check history")312+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history")
313 return313 return
314 }314 }
315 315
@@ -317,7 +317,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
317 delta := max(1, record.visits)317 delta := max(1, record.visits)
318 if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {318 if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
319 if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {319 if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
320- log.Warn().Err(err).Str("url", record.url).Int("delta", delta).Msg("failed to increment visit count by delta")320+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta")
321 return321 return
322 }322 }
323 return323 return
@@ -325,7 +325,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
325 iterations := delta325 iterations := delta
326 if iterations > historyFallbackIncrementCap {326 if iterations > historyFallbackIncrementCap {
327 log.Warn().327 log.Warn().
328- Str("url", record.url).328+ Str("url", logging.RedactURL(record.url)).
329 Int("delta", delta).329 Int("delta", delta).
330 Int("cap", historyFallbackIncrementCap).330 Int("cap", historyFallbackIncrementCap).
331 Msg("visit count fallback increment capped")331 Msg("visit count fallback increment capped")
@@ -333,7 +333,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
333 }333 }
334 for i := 0; i < iterations; i++ {334 for i := 0; i < iterations; i++ {
335 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {335 if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
336- log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")336+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count")
337 return337 return
338 }338 }
339 }339 }
@@ -342,7 +342,7 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
342 entry := entity.NewHistoryEntry(record.url, "")342 entry := entity.NewHistoryEntry(record.url, "")
343 entry.VisitCount = int64(max(1, record.visits))343 entry.VisitCount = int64(max(1, record.visits))
344 if err := uc.historyRepo.Save(ctx, entry); err != nil {344 if err := uc.historyRepo.Save(ctx, entry); err != nil {
345- log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")345+ log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history")
346 }346 }
347 }347 }
348 }348 }
@@ -358,7 +358,7 @@ func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, t
358 358
359 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)359 entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
360 if err != nil {360 if err != nil {
361- log.Warn().Err(err).Str("url", historyURL).Msg("failed to find history entry for title update")361+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update")
362 return362 return
363 }363 }
364 if entry == nil {364 if entry == nil {
@@ -368,12 +368,12 @@ func (uc *NavigateUseCase) persistTitleUpdate(ctx context.Context, historyURL, t
368 entry.Title = title368 entry.Title = title
369 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {369 if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
370 if err := updater.UpdateMetadata(ctx, entry); err != nil {370 if err := updater.UpdateMetadata(ctx, entry); err != nil {
371- log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history metadata")371+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata")
372 }372 }
373 return373 return
374 }374 }
375 if err := uc.historyRepo.Save(ctx, entry); err != nil {375 if err := uc.historyRepo.Save(ctx, entry); err != nil {
376- log.Warn().Err(err).Str("url", historyURL).Msg("failed to update history title")376+ log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title")
377 }377 }
378 }378 }
379 379
@@ -392,7 +392,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, tit
392 select {392 select {
393 case uc.historyQueue <- historyRecord{url: historyURL, title: title}:393 case uc.historyQueue <- historyRecord{url: historyURL, title: title}:
394 default:394 default:
395- logging.FromContext(uc.ctx).Warn().Str("url", historyURL).Msg("history queue full, title update dropped")395+ logging.FromContext(uc.ctx).Warn().Str("url", logging.RedactURL(historyURL)).Msg("history queue full, title update dropped")
396 }396 }
397 }397 }
398 398
M internal/logging/context.go +2 -2
@@ -51,9 +51,9 @@ func WithTabID(ctx context.Context, tabID string) context.Context {
5151 return WithContext(ctx, childLogger)
5252 }
5353
54-// WithURL creates a child logger with a url field
54+// WithURL creates a child logger with a redacted url field.
5555 func WithURL(ctx context.Context, url string) context.Context {
5656 logger := FromContext(ctx)
57- childLogger := logger.With().Str("url", url).Logger()
57+ childLogger := logger.With().Str("url", RedactURL(url)).Logger()
5858 return WithContext(ctx, childLogger)
5959 }
@@ -51,9 +51,9 @@ func WithTabID(ctx context.Context, tabID string) context.Context {
51 return WithContext(ctx, childLogger)51 return WithContext(ctx, childLogger)
52 }52 }
53 53
54-// WithURL creates a child logger with a url field54+// WithURL creates a child logger with a redacted url field.
55 func WithURL(ctx context.Context, url string) context.Context {55 func WithURL(ctx context.Context, url string) context.Context {
56 logger := FromContext(ctx)56 logger := FromContext(ctx)
57- childLogger := logger.With().Str("url", url).Logger()57+ childLogger := logger.With().Str("url", RedactURL(url)).Logger()
58 return WithContext(ctx, childLogger)58 return WithContext(ctx, childLogger)
59 }59 }
M internal/logging/logger.go +49 -8
@@ -6,6 +6,7 @@ import (
66 neturl "net/url"
77 "os"
88 "path/filepath"
9+ "strings"
910 "sync"
1011 "time"
1112
@@ -71,8 +72,8 @@ func New(cfg Config) zerolog.Logger {
7172 // Returns the logger and a cleanup function to close the file.
7273 func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {
7374 const (
74- logDirPerm = 0o755
75- logFilePerm = 0o644
75+ logDirPerm = 0o700
76+ logFilePerm = 0o600
7677 )
7778 var writers []io.Writer
7879 cleanup := func() {}
@@ -97,16 +98,24 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error)
9798
9899 // Session file output (JSON format for parsing)
99100 if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {
100- // Ensure log directory exists (LogDir is from config, may not have logs subdir)
101+ // Ensure log directory exists (LogDir is from config, may not have logs subdir).
102+ // Session logs may contain browsing metadata, so keep them user-private.
101103 if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil {
102104 return zerolog.Logger{}, nil, err
103105 }
106+ if err := os.Chmod(fileCfg.LogDir, logDirPerm); err != nil {
107+ return zerolog.Logger{}, nil, err
108+ }
104109
105110 filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID))
106111 file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm)
107112 if err != nil {
108113 return zerolog.Logger{}, nil, err
109114 }
115+ if err := file.Chmod(logFilePerm); err != nil {
116+ _ = file.Close()
117+ return zerolog.Logger{}, nil, err
118+ }
110119
111120 sessionWriter := newSessionFileWriter(file)
112121 writers = append(writers, sessionWriter)
@@ -242,12 +251,44 @@ func ParseLevel(level string) zerolog.Level {
242251 // PermissionLogURLMaxLen is the standard max length for permission-related URL logging.
243252 const PermissionLogURLMaxLen = 96
244253
245-// TruncateURL truncates a URL to maxLen characters for logging.
254+// RedactedURLPlaceholder is emitted when input looks like a URL/path but has no safe host.
255+const RedactedURLPlaceholder = "[redacted-url]"
256+
257+// RedactURL returns privacy-preserving URL diagnostics. Absolute URLs with a
258+// host are reduced to host-only; query strings, fragments, paths, credentials,
259+// and local file paths are omitted.
260+func RedactURL(raw string) string {
261+ raw = strings.TrimSpace(raw)
262+ if raw == "" {
263+ return ""
264+ }
265+
266+ parsed, err := neturl.Parse(raw)
267+ if err != nil {
268+ return RedactedURLPlaceholder
269+ }
270+ if parsed.Host != "" {
271+ return parsed.Hostname()
272+ }
273+ if parsed.Scheme == "about" && parsed.Opaque != "" {
274+ // Use Opaque directly instead of parsed.String() to avoid leaking
275+ // RawQuery and Fragment (Go's URL parser separates them from Opaque,
276+ // but String() reconstructs the full URL including query/fragment).
277+ return parsed.Scheme + ":" + parsed.Opaque
278+ }
279+ if parsed.Scheme != "" {
280+ return parsed.Scheme + ":" + RedactedURLPlaceholder
281+ }
282+ return RedactedURLPlaceholder
283+}
284+
285+// TruncateURL redacts and then truncates a URL for logging.
246286 func TruncateURL(url string, maxLen int) string {
247- if len(url) <= maxLen {
248- return url
287+ redacted := RedactURL(url)
288+ if len(redacted) <= maxLen {
289+ return redacted
249290 }
250- return url[:maxLen] + "..."
291+ return redacted[:maxLen] + "..."
251292 }
252293
253294 // SafeURLHost returns only the host portion of a URL for privacy-preserving diagnostics.
@@ -256,5 +297,5 @@ func SafeURLHost(raw string) string {
256297 if err != nil {
257298 return ""
258299 }
259- return parsed.Host
300+ return parsed.Hostname()
260301 }
@@ -6,6 +6,7 @@ import (
6 neturl "net/url"6 neturl "net/url"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strings"
9 "sync"10 "sync"
10 "time"11 "time"
11 12
@@ -71,8 +72,8 @@ func New(cfg Config) zerolog.Logger {
71 // Returns the logger and a cleanup function to close the file.72 // Returns the logger and a cleanup function to close the file.
72 func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {73 func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {
73 const (74 const (
74- logDirPerm = 0o75575+ logDirPerm = 0o700
75- logFilePerm = 0o64476+ logFilePerm = 0o600
76 )77 )
77 var writers []io.Writer78 var writers []io.Writer
78 cleanup := func() {}79 cleanup := func() {}
@@ -97,16 +98,24 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error)
97 98
98 // Session file output (JSON format for parsing)99 // Session file output (JSON format for parsing)
99 if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {100 if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {
100- // Ensure log directory exists (LogDir is from config, may not have logs subdir)101+ // Ensure log directory exists (LogDir is from config, may not have logs subdir).
102+ // Session logs may contain browsing metadata, so keep them user-private.
101 if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil {103 if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil {
102 return zerolog.Logger{}, nil, err104 return zerolog.Logger{}, nil, err
103 }105 }
106+ if err := os.Chmod(fileCfg.LogDir, logDirPerm); err != nil {
107+ return zerolog.Logger{}, nil, err
108+ }
104 109
105 filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID))110 filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID))
106 file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm)111 file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm)
107 if err != nil {112 if err != nil {
108 return zerolog.Logger{}, nil, err113 return zerolog.Logger{}, nil, err
109 }114 }
115+ if err := file.Chmod(logFilePerm); err != nil {
116+ _ = file.Close()
117+ return zerolog.Logger{}, nil, err
118+ }
110 119
111 sessionWriter := newSessionFileWriter(file)120 sessionWriter := newSessionFileWriter(file)
112 writers = append(writers, sessionWriter)121 writers = append(writers, sessionWriter)
@@ -242,12 +251,44 @@ func ParseLevel(level string) zerolog.Level {
242 // PermissionLogURLMaxLen is the standard max length for permission-related URL logging.251 // PermissionLogURLMaxLen is the standard max length for permission-related URL logging.
243 const PermissionLogURLMaxLen = 96252 const PermissionLogURLMaxLen = 96
244 253
245-// TruncateURL truncates a URL to maxLen characters for logging.254+// RedactedURLPlaceholder is emitted when input looks like a URL/path but has no safe host.
255+const RedactedURLPlaceholder = "[redacted-url]"
256+
257+// RedactURL returns privacy-preserving URL diagnostics. Absolute URLs with a
258+// host are reduced to host-only; query strings, fragments, paths, credentials,
259+// and local file paths are omitted.
260+func RedactURL(raw string) string {
261+ raw = strings.TrimSpace(raw)
262+ if raw == "" {
263+ return ""
264+ }
265+
266+ parsed, err := neturl.Parse(raw)
267+ if err != nil {
268+ return RedactedURLPlaceholder
269+ }
270+ if parsed.Host != "" {
271+ return parsed.Hostname()
272+ }
273+ if parsed.Scheme == "about" && parsed.Opaque != "" {
274+ // Use Opaque directly instead of parsed.String() to avoid leaking
275+ // RawQuery and Fragment (Go's URL parser separates them from Opaque,
276+ // but String() reconstructs the full URL including query/fragment).
277+ return parsed.Scheme + ":" + parsed.Opaque
278+ }
279+ if parsed.Scheme != "" {
280+ return parsed.Scheme + ":" + RedactedURLPlaceholder
281+ }
282+ return RedactedURLPlaceholder
283+}
284+
285+// TruncateURL redacts and then truncates a URL for logging.
246 func TruncateURL(url string, maxLen int) string {286 func TruncateURL(url string, maxLen int) string {
247- if len(url) <= maxLen {287+ redacted := RedactURL(url)
248- return url288+ if len(redacted) <= maxLen {
289+ return redacted
249 }290 }
250- return url[:maxLen] + "..."291+ return redacted[:maxLen] + "..."
251 }292 }
252 293
253 // SafeURLHost returns only the host portion of a URL for privacy-preserving diagnostics.294 // SafeURLHost returns only the host portion of a URL for privacy-preserving diagnostics.
@@ -256,5 +297,5 @@ func SafeURLHost(raw string) string {
256 if err != nil {297 if err != nil {
257 return ""298 return ""
258 }299 }
259- return parsed.Host300+ return parsed.Hostname()
260 }301 }
M internal/logging/logger_test.go +105 -0
@@ -3,9 +3,43 @@ package logging
33 import (
44 "os"
55 "path/filepath"
6+ "strings"
67 "testing"
8+
9+ "github.com/rs/zerolog"
710 )
811
12+func TestRedactURLUsesHostOnlyForAbsoluteURLs(t *testing.T) {
13+ t.Parallel()
14+
15+ got := RedactURL("https://user:pass@example.com/private/path?token=secret#frag")
16+ if got != "example.com" {
17+ t.Fatalf("RedactURL returned %q, want %q", got, "example.com")
18+ }
19+}
20+
21+func TestRedactURLRedactsLocalOrOpaqueInputs(t *testing.T) {
22+ t.Parallel()
23+
24+ for _, raw := range []string{"/home/user/private/file.html?token=secret", "not a url"} {
25+ if got := RedactURL(raw); got != RedactedURLPlaceholder {
26+ t.Fatalf("RedactURL(%q) returned %q, want %q", raw, got, RedactedURLPlaceholder)
27+ }
28+ }
29+}
30+
31+func TestTruncateURLRedactsBeforeTruncating(t *testing.T) {
32+ t.Parallel()
33+
34+ got := TruncateURL("https://example.com/private/path?token=secret#frag", 96)
35+ if got != "example.com" {
36+ t.Fatalf("TruncateURL returned %q, want %q", got, "example.com")
37+ }
38+ if strings.Contains(got, "token") || strings.Contains(got, "/private") || strings.Contains(got, "frag") {
39+ t.Fatalf("TruncateURL leaked sensitive URL data: %q", got)
40+ }
41+}
42+
943 func TestSafeURLHostDropsPathQueryAndFragment(t *testing.T) {
1044 t.Parallel()
1145
@@ -25,6 +59,77 @@ func TestSafeURLHostReturnsEmptyForOpaqueOrInvalidInput(t *testing.T) {
2559 }
2660 }
2761
62+func TestRedactURLStripsQueryFragmentFromAboutOpaqueURLs(t *testing.T) {
63+ t.Parallel()
64+
65+ got := RedactURL("about:blank?token=secret#frag")
66+ if want := "about:blank"; got != want {
67+ t.Fatalf("RedactURL(about:blank?token=secret#frag) = %q, want %q", got, want)
68+ }
69+ if strings.Contains(got, "token") || strings.Contains(got, "secret") || strings.Contains(got, "frag") {
70+ t.Fatalf("RedactURL leaked query/fragment from about: opaque URL: %q", got)
71+ }
72+}
73+
74+func TestRedactURLRedactsFileURLPathQueryFragment(t *testing.T) {
75+ t.Parallel()
76+
77+ got := RedactURL("file:///home/user/private?token=secret#frag")
78+ if got != "file:"+RedactedURLPlaceholder {
79+ t.Fatalf("RedactURL(file:///home/user/private?token=secret#frag) = %q, want %q", got, "file:"+RedactedURLPlaceholder)
80+ }
81+ if strings.Contains(got, "home") || strings.Contains(got, "user") || strings.Contains(got, "private") {
82+ t.Fatalf("RedactURL leaked file path from file:// URL: %q", got)
83+ }
84+ if strings.Contains(got, "token") || strings.Contains(got, "secret") || strings.Contains(got, "frag") {
85+ t.Fatalf("RedactURL leaked query/fragment from file:// URL: %q", got)
86+ }
87+}
88+
89+func TestRedactURLReturnsPlaceholderForInvalidParse(t *testing.T) {
90+ t.Parallel()
91+
92+ got := RedactURL("http://[::1")
93+ if got != RedactedURLPlaceholder {
94+ t.Fatalf("RedactURL(http://[::1) = %q, want %q", got, RedactedURLPlaceholder)
95+ }
96+}
97+
98+func TestNewWithFileCreatesUserPrivateLogDirectoryAndFile(t *testing.T) {
99+ t.Parallel()
100+
101+ parent := t.TempDir()
102+ logDir := filepath.Join(parent, "logs")
103+ logger, cleanup, err := NewWithFile(Config{Level: zerolog.InfoLevel, Format: logFormatJSON}, FileConfig{
104+ Enabled: true,
105+ LogDir: logDir,
106+ SessionID: "test-session",
107+ })
108+ if err != nil {
109+ t.Fatalf("NewWithFile: %v", err)
110+ }
111+ defer cleanup()
112+
113+ logger.Info().Msg("hello")
114+ cleanup()
115+
116+ dirInfo, err := os.Stat(logDir)
117+ if err != nil {
118+ t.Fatalf("stat log dir: %v", err)
119+ }
120+ if got := dirInfo.Mode().Perm(); got != 0o700 {
121+ t.Fatalf("log dir permissions = %o, want 700", got)
122+ }
123+
124+ fileInfo, err := os.Stat(filepath.Join(logDir, SessionFilename("test-session")))
125+ if err != nil {
126+ t.Fatalf("stat log file: %v", err)
127+ }
128+ if got := fileInfo.Mode().Perm(); got != 0o600 {
129+ t.Fatalf("log file permissions = %o, want 600", got)
130+ }
131+}
132+
28133 func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) {
29134 t.Parallel()
30135
@@ -3,9 +3,43 @@ package logging
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6+ "strings"
6 "testing"7 "testing"
8+
9+ "github.com/rs/zerolog"
7 )10 )
8 11
12+func TestRedactURLUsesHostOnlyForAbsoluteURLs(t *testing.T) {
13+ t.Parallel()
14+
15+ got := RedactURL("https://user:pass@example.com/private/path?token=secret#frag")
16+ if got != "example.com" {
17+ t.Fatalf("RedactURL returned %q, want %q", got, "example.com")
18+ }
19+}
20+
21+func TestRedactURLRedactsLocalOrOpaqueInputs(t *testing.T) {
22+ t.Parallel()
23+
24+ for _, raw := range []string{"/home/user/private/file.html?token=secret", "not a url"} {
25+ if got := RedactURL(raw); got != RedactedURLPlaceholder {
26+ t.Fatalf("RedactURL(%q) returned %q, want %q", raw, got, RedactedURLPlaceholder)
27+ }
28+ }
29+}
30+
31+func TestTruncateURLRedactsBeforeTruncating(t *testing.T) {
32+ t.Parallel()
33+
34+ got := TruncateURL("https://example.com/private/path?token=secret#frag", 96)
35+ if got != "example.com" {
36+ t.Fatalf("TruncateURL returned %q, want %q", got, "example.com")
37+ }
38+ if strings.Contains(got, "token") || strings.Contains(got, "/private") || strings.Contains(got, "frag") {
39+ t.Fatalf("TruncateURL leaked sensitive URL data: %q", got)
40+ }
41+}
42+
9 func TestSafeURLHostDropsPathQueryAndFragment(t *testing.T) {43 func TestSafeURLHostDropsPathQueryAndFragment(t *testing.T) {
10 t.Parallel()44 t.Parallel()
11 45
@@ -25,6 +59,77 @@ func TestSafeURLHostReturnsEmptyForOpaqueOrInvalidInput(t *testing.T) {
25 }59 }
26 }60 }
27 61
62+func TestRedactURLStripsQueryFragmentFromAboutOpaqueURLs(t *testing.T) {
63+ t.Parallel()
64+
65+ got := RedactURL("about:blank?token=secret#frag")
66+ if want := "about:blank"; got != want {
67+ t.Fatalf("RedactURL(about:blank?token=secret#frag) = %q, want %q", got, want)
68+ }
69+ if strings.Contains(got, "token") || strings.Contains(got, "secret") || strings.Contains(got, "frag") {
70+ t.Fatalf("RedactURL leaked query/fragment from about: opaque URL: %q", got)
71+ }
72+}
73+
74+func TestRedactURLRedactsFileURLPathQueryFragment(t *testing.T) {
75+ t.Parallel()
76+
77+ got := RedactURL("file:///home/user/private?token=secret#frag")
78+ if got != "file:"+RedactedURLPlaceholder {
79+ t.Fatalf("RedactURL(file:///home/user/private?token=secret#frag) = %q, want %q", got, "file:"+RedactedURLPlaceholder)
80+ }
81+ if strings.Contains(got, "home") || strings.Contains(got, "user") || strings.Contains(got, "private") {
82+ t.Fatalf("RedactURL leaked file path from file:// URL: %q", got)
83+ }
84+ if strings.Contains(got, "token") || strings.Contains(got, "secret") || strings.Contains(got, "frag") {
85+ t.Fatalf("RedactURL leaked query/fragment from file:// URL: %q", got)
86+ }
87+}
88+
89+func TestRedactURLReturnsPlaceholderForInvalidParse(t *testing.T) {
90+ t.Parallel()
91+
92+ got := RedactURL("http://[::1")
93+ if got != RedactedURLPlaceholder {
94+ t.Fatalf("RedactURL(http://[::1) = %q, want %q", got, RedactedURLPlaceholder)
95+ }
96+}
97+
98+func TestNewWithFileCreatesUserPrivateLogDirectoryAndFile(t *testing.T) {
99+ t.Parallel()
100+
101+ parent := t.TempDir()
102+ logDir := filepath.Join(parent, "logs")
103+ logger, cleanup, err := NewWithFile(Config{Level: zerolog.InfoLevel, Format: logFormatJSON}, FileConfig{
104+ Enabled: true,
105+ LogDir: logDir,
106+ SessionID: "test-session",
107+ })
108+ if err != nil {
109+ t.Fatalf("NewWithFile: %v", err)
110+ }
111+ defer cleanup()
112+
113+ logger.Info().Msg("hello")
114+ cleanup()
115+
116+ dirInfo, err := os.Stat(logDir)
117+ if err != nil {
118+ t.Fatalf("stat log dir: %v", err)
119+ }
120+ if got := dirInfo.Mode().Perm(); got != 0o700 {
121+ t.Fatalf("log dir permissions = %o, want 700", got)
122+ }
123+
124+ fileInfo, err := os.Stat(filepath.Join(logDir, SessionFilename("test-session")))
125+ if err != nil {
126+ t.Fatalf("stat log file: %v", err)
127+ }
128+ if got := fileInfo.Mode().Perm(); got != 0o600 {
129+ t.Fatalf("log file permissions = %o, want 600", got)
130+ }
131+}
132+
28 func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) {133 func TestSessionFileWriter_DropsWritesAfterClose(t *testing.T) {
29 t.Parallel()134 t.Parallel()
30 135
M internal/ui/coordinator/content/navigation.go +7 -13
@@ -330,25 +330,19 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID
330330 isExternal := urlutil.IsExternalScheme(uri)
331331
332332 if isExternal {
333- log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
333+ log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme URI change blocked")
334334
335- // Launch externally via injected callback
336- if c.onLaunchExternalURL != nil {
337- c.onLaunchExternalURL(uri)
338- } else {
339- log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external URL not launched: no handler registered")
340- }
341-
342- // Stop loading to prevent WebKit from showing an error page
343- // The page stays on the previous URL before the JS redirect
335+ // External schemes must only be launched from WebKit's navigation-policy path,
336+ // where NavigationAction.IsUserGesture is available and verified. URI changes can
337+ // be driven by page script or redirects, so this path only cleans up WebKit state.
344338 if err := wv.Stop(ctx); err != nil {
345- log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for external URL")
339+ log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for blocked external URL")
346340 }
347341
348- // Navigate back to avoid stale URI in omnibox/history
342+ // Navigate back to avoid stale URI in omnibox/history.
349343 if wv.CanGoBack() {
350344 if err := wv.GoBack(ctx); err != nil {
351- log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("GoBack after external URL")
345+ log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("GoBack after blocked external URL")
352346 }
353347 }
354348 return
@@ -330,25 +330,19 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID
330 isExternal := urlutil.IsExternalScheme(uri)330 isExternal := urlutil.IsExternalScheme(uri)
331 331
332 if isExternal {332 if isExternal {
333- log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")333+ log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme URI change blocked")
334 334
335- // Launch externally via injected callback335+ // External schemes must only be launched from WebKit's navigation-policy path,
336- if c.onLaunchExternalURL != nil {336+ // where NavigationAction.IsUserGesture is available and verified. URI changes can
337- c.onLaunchExternalURL(uri)337+ // be driven by page script or redirects, so this path only cleans up WebKit state.
338- } else {
339- log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external URL not launched: no handler registered")
340- }
341-
342- // Stop loading to prevent WebKit from showing an error page
343- // The page stays on the previous URL before the JS redirect
344 if err := wv.Stop(ctx); err != nil {338 if err := wv.Stop(ctx); err != nil {
345- log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for external URL")339+ log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for blocked external URL")
346 }340 }
347 341
348- // Navigate back to avoid stale URI in omnibox/history342+ // Navigate back to avoid stale URI in omnibox/history.
349 if wv.CanGoBack() {343 if wv.CanGoBack() {
350 if err := wv.GoBack(ctx); err != nil {344 if err := wv.GoBack(ctx); err != nil {
351- log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("GoBack after external URL")345+ log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("GoBack after blocked external URL")
352 }346 }
353 }347 }
354 return348 return
A internal/ui/coordinator/content/navigation_security_test.go +121 -0
@@ -0,0 +1,121 @@
1+package content
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/dumber/internal/application/port/mocks"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+)
13+
14+func TestHandleURIChanged_BlocksExternalSchemeWithoutLaunching(t *testing.T) {
15+ t.Parallel()
16+
17+ ctx := context.Background()
18+ paneID := entity.PaneID("pane-1")
19+ wv := mocks.NewMockWebView(t)
20+ wv.EXPECT().Stop(ctx).Return(nil).Once()
21+ wv.EXPECT().CanGoBack().Return(false).Once()
22+
23+ launchCount := 0
24+ c := &Coordinator{
25+ onLaunchExternalURL: func(uri string) {
26+ launchCount++
27+ },
28+ }
29+
30+ c.handleURIChanged(ctx, paneID, wv, "vscode://file/etc/passwd")
31+
32+ assert.Equal(t, 0, launchCount)
33+}
34+
35+func TestHandleURIChanged_BlocksExternalSchemeRedirectAndRestoresPreviousPage(t *testing.T) {
36+ t.Parallel()
37+
38+ ctx := context.Background()
39+ paneID := entity.PaneID("pane-1")
40+ wv := mocks.NewMockWebView(t)
41+ wv.EXPECT().Stop(ctx).Return(nil).Once()
42+ wv.EXPECT().CanGoBack().Return(true).Once()
43+ wv.EXPECT().GoBack(ctx).Return(nil).Once()
44+
45+ launchCount := 0
46+ c := &Coordinator{
47+ onLaunchExternalURL: func(uri string) {
48+ launchCount++
49+ },
50+ }
51+
52+ c.handleURIChanged(ctx, paneID, wv, "spotify://track/123")
53+
54+ assert.Equal(t, 0, launchCount)
55+}
56+
57+func TestHandleURIChanged_HTTPURIStillRecordsSPANavigation(t *testing.T) {
58+ t.Parallel()
59+
60+ ctx := context.Background()
61+ paneID := entity.PaneID("pane-1")
62+ wv := mocks.NewMockWebView(t)
63+ wv.EXPECT().IsLoading().Return(false).Once()
64+
65+ var recordedPane entity.PaneID
66+ var recordedURI string
67+ c := &Coordinator{
68+ onPaneURIUpdated: func(paneID entity.PaneID, uri string) {
69+ recordedPane = paneID
70+ recordedURI = uri
71+ },
72+ }
73+
74+ c.handleURIChanged(ctx, paneID, wv, "https://example.com/app#state")
75+
76+ assert.Equal(t, paneID, recordedPane)
77+ assert.Equal(t, "https://example.com/app#state", recordedURI)
78+}
79+
80+func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) {
81+ t.Parallel()
82+
83+ ctx := context.Background()
84+ paneID := entity.PaneID("pane-1")
85+ wv := mocks.NewMockWebView(t)
86+ wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()
87+ wv.EXPECT().CanGoBack().Return(false).Once()
88+
89+ launchCount := 0
90+ c := &Coordinator{
91+ onLaunchExternalURL: func(uri string) {
92+ launchCount++
93+ },
94+ }
95+
96+ c.handleURIChanged(ctx, paneID, wv, "vscode://file/etc/passwd")
97+
98+ assert.Equal(t, 0, launchCount)
99+}
100+
101+func TestHandleURIChanged_GoBackErrorDoesNotPanic(t *testing.T) {
102+ t.Parallel()
103+
104+ ctx := context.Background()
105+ paneID := entity.PaneID("pane-1")
106+ wv := mocks.NewMockWebView(t)
107+ wv.EXPECT().Stop(ctx).Return(nil).Once()
108+ wv.EXPECT().CanGoBack().Return(true).Once()
109+ wv.EXPECT().GoBack(ctx).Return(errors.New("goback failed")).Once()
110+
111+ launchCount := 0
112+ c := &Coordinator{
113+ onLaunchExternalURL: func(uri string) {
114+ launchCount++
115+ },
116+ }
117+
118+ c.handleURIChanged(ctx, paneID, wv, "spotify://track/123")
119+
120+ assert.Equal(t, 0, launchCount)
121+}
@@ -0,0 +1,121 @@
1+package content
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/dumber/internal/application/port/mocks"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+)
13+
14+func TestHandleURIChanged_BlocksExternalSchemeWithoutLaunching(t *testing.T) {
15+ t.Parallel()
16+
17+ ctx := context.Background()
18+ paneID := entity.PaneID("pane-1")
19+ wv := mocks.NewMockWebView(t)
20+ wv.EXPECT().Stop(ctx).Return(nil).Once()
21+ wv.EXPECT().CanGoBack().Return(false).Once()
22+
23+ launchCount := 0
24+ c := &Coordinator{
25+ onLaunchExternalURL: func(uri string) {
26+ launchCount++
27+ },
28+ }
29+
30+ c.handleURIChanged(ctx, paneID, wv, "vscode://file/etc/passwd")
31+
32+ assert.Equal(t, 0, launchCount)
33+}
34+
35+func TestHandleURIChanged_BlocksExternalSchemeRedirectAndRestoresPreviousPage(t *testing.T) {
36+ t.Parallel()
37+
38+ ctx := context.Background()
39+ paneID := entity.PaneID("pane-1")
40+ wv := mocks.NewMockWebView(t)
41+ wv.EXPECT().Stop(ctx).Return(nil).Once()
42+ wv.EXPECT().CanGoBack().Return(true).Once()
43+ wv.EXPECT().GoBack(ctx).Return(nil).Once()
44+
45+ launchCount := 0
46+ c := &Coordinator{
47+ onLaunchExternalURL: func(uri string) {
48+ launchCount++
49+ },
50+ }
51+
52+ c.handleURIChanged(ctx, paneID, wv, "spotify://track/123")
53+
54+ assert.Equal(t, 0, launchCount)
55+}
56+
57+func TestHandleURIChanged_HTTPURIStillRecordsSPANavigation(t *testing.T) {
58+ t.Parallel()
59+
60+ ctx := context.Background()
61+ paneID := entity.PaneID("pane-1")
62+ wv := mocks.NewMockWebView(t)
63+ wv.EXPECT().IsLoading().Return(false).Once()
64+
65+ var recordedPane entity.PaneID
66+ var recordedURI string
67+ c := &Coordinator{
68+ onPaneURIUpdated: func(paneID entity.PaneID, uri string) {
69+ recordedPane = paneID
70+ recordedURI = uri
71+ },
72+ }
73+
74+ c.handleURIChanged(ctx, paneID, wv, "https://example.com/app#state")
75+
76+ assert.Equal(t, paneID, recordedPane)
77+ assert.Equal(t, "https://example.com/app#state", recordedURI)
78+}
79+
80+func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) {
81+ t.Parallel()
82+
83+ ctx := context.Background()
84+ paneID := entity.PaneID("pane-1")
85+ wv := mocks.NewMockWebView(t)
86+ wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()
87+ wv.EXPECT().CanGoBack().Return(false).Once()
88+
89+ launchCount := 0
90+ c := &Coordinator{
91+ onLaunchExternalURL: func(uri string) {
92+ launchCount++
93+ },
94+ }
95+
96+ c.handleURIChanged(ctx, paneID, wv, "vscode://file/etc/passwd")
97+
98+ assert.Equal(t, 0, launchCount)
99+}
100+
101+func TestHandleURIChanged_GoBackErrorDoesNotPanic(t *testing.T) {
102+ t.Parallel()
103+
104+ ctx := context.Background()
105+ paneID := entity.PaneID("pane-1")
106+ wv := mocks.NewMockWebView(t)
107+ wv.EXPECT().Stop(ctx).Return(nil).Once()
108+ wv.EXPECT().CanGoBack().Return(true).Once()
109+ wv.EXPECT().GoBack(ctx).Return(errors.New("goback failed")).Once()
110+
111+ launchCount := 0
112+ c := &Coordinator{
113+ onLaunchExternalURL: func(uri string) {
114+ launchCount++
115+ },
116+ }
117+
118+ c.handleURIChanged(ctx, paneID, wv, "spotify://track/123")
119+
120+ assert.Equal(t, 0, launchCount)
121+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close