fix(review): context timeouts, defer cancel, skip malformed tree lines
- commit
- 32c6f5d050ee4b802fa5785956b42c2bd9869ff0
- author
- bnema · · 2026-02-25 02:25 UTC
- parent
- 0ef5b19
- signature
- unsigned
4 files · +28 -12
M internal/adapters/in/cli/ui/components/selector.go +2 -1
| @@ -82,7 +82,8 @@ func (m SelectorModel) View() string { | |||
| 82 | 82 | if i == m.cursor { | |
| 83 | 83 | b.WriteString(styles.Theme.Highlight.Render(cursor + line)) | |
| 84 | 84 | } else { | |
| 85 | - | fmt.Fprintf(&b, "%s%s", cursor, line) | |
| 85 | + | b.WriteString(cursor) | |
| 86 | + | b.WriteString(line) | |
| 86 | 87 | } | |
| 87 | 88 | b.WriteString("\n") | |
| 88 | 89 | } | |
| @@ -82,7 +82,8 @@ func (m SelectorModel) View() string { | |||||
| 82 | if i == m.cursor { | 82 | if i == m.cursor { | ||
| 83 | b.WriteString(styles.Theme.Highlight.Render(cursor + line)) | 83 | b.WriteString(styles.Theme.Highlight.Render(cursor + line)) | ||
| 84 | } else { | 84 | } else { | ||
| 85 | - | fmt.Fprintf(&b, "%s%s", cursor, line) | 85 | + | b.WriteString(cursor) |
| 86 | + | b.WriteString(line) | |||
| 86 | } | 87 | } | ||
| 87 | b.WriteString("\n") | 88 | b.WriteString("\n") | ||
| 88 | } | 89 | } | ||
M internal/adapters/out/domainsecrets/pass_store.go +11 -4
| @@ -33,7 +33,10 @@ type PassStore struct { | |||
| 33 | 33 | ||
| 34 | 34 | // NewPassStore creates a new pass-based domain secret store. | |
| 35 | 35 | func NewPassStore(log zerowrap.Logger) (*PassStore, error) { | |
| 36 | - | if err := exec.Command("pass", "version").Run(); err != nil { | |
| 36 | + | // Use a timeout so a stalled GPG agent or keyring does not hang startup. | |
| 37 | + | probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 38 | + | defer probeCancel() | |
| 39 | + | if err := exec.CommandContext(probeCtx, "pass", "version").Run(); err != nil { //nolint:gosec // binary is a constant ("pass"), no user input | |
| 37 | 40 | return nil, fmt.Errorf("pass is not available: %w", err) | |
| 38 | 41 | } | |
| 39 | 42 | ||
| @@ -809,8 +812,12 @@ func (s *PassStore) cleanupInsertedPaths(paths []string) { | |||
| 809 | 812 | } | |
| 810 | 813 | ||
| 811 | 814 | for _, path := range paths { | |
| 812 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 813 | - | _ = s.passRemove(ctx, path) | |
| 814 | - | cancel() | |
| 815 | + | // Wrap in a closure so defer cancel() runs at the end of each iteration, | |
| 816 | + | // not at the end of cleanupInsertedPaths (which would delay all cancels). | |
| 817 | + | func(p string) { | |
| 818 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 819 | + | defer cancel() | |
| 820 | + | _ = s.passRemove(ctx, p) | |
| 821 | + | }(path) | |
| 815 | 822 | } | |
| 816 | 823 | } | |
| @@ -33,7 +33,10 @@ type PassStore struct { | |||||
| 33 | 33 | ||||
| 34 | // NewPassStore creates a new pass-based domain secret store. | 34 | // NewPassStore creates a new pass-based domain secret store. | ||
| 35 | func NewPassStore(log zerowrap.Logger) (*PassStore, error) { | 35 | func NewPassStore(log zerowrap.Logger) (*PassStore, error) { | ||
| 36 | - | if err := exec.Command("pass", "version").Run(); err != nil { | 36 | + | // Use a timeout so a stalled GPG agent or keyring does not hang startup. |
| 37 | + | probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second) | |||
| 38 | + | defer probeCancel() | |||
| 39 | + | if err := exec.CommandContext(probeCtx, "pass", "version").Run(); err != nil { //nolint:gosec // binary is a constant ("pass"), no user input | |||
| 37 | return nil, fmt.Errorf("pass is not available: %w", err) | 40 | return nil, fmt.Errorf("pass is not available: %w", err) | ||
| 38 | } | 41 | } | ||
| 39 | 42 | ||||
| @@ -809,8 +812,12 @@ func (s *PassStore) cleanupInsertedPaths(paths []string) { | |||||
| 809 | } | 812 | } | ||
| 810 | 813 | ||||
| 811 | for _, path := range paths { | 814 | for _, path := range paths { | ||
| 812 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | 815 | + | // Wrap in a closure so defer cancel() runs at the end of each iteration, |
| 813 | - | _ = s.passRemove(ctx, path) | 816 | + | // not at the end of cleanupInsertedPaths (which would delay all cancels). |
| 814 | - | cancel() | 817 | + | func(p string) { |
| 818 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 819 | + | defer cancel() | |||
| 820 | + | _ = s.passRemove(ctx, p) | |||
| 821 | + | }(path) | |||
| 815 | } | 822 | } | ||
| 816 | } | 823 | } | ||
M internal/adapters/out/secrets/pass.go +5 -3
| @@ -105,8 +105,10 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro | |||
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | 107 | // IsAvailable checks if pass is available in the system. | |
| 108 | + | // Uses a short timeout so a stalled GPG agent does not hang the caller. | |
| 108 | 109 | func (p *PassProvider) IsAvailable() bool { | |
| 109 | - | cmd := exec.Command("pass", "version") | |
| 110 | - | err := cmd.Run() | |
| 111 | - | return err == nil | |
| 110 | + | ctx, cancel := context.WithTimeout(context.Background(), p.timeout) | |
| 111 | + | defer cancel() | |
| 112 | + | cmd := exec.CommandContext(ctx, "pass", "version") //nolint:gosec // binary is a constant ("pass"), no user input | |
| 113 | + | return cmd.Run() == nil | |
| 112 | 114 | } | |
| @@ -105,8 +105,10 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro | |||||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | // IsAvailable checks if pass is available in the system. | 107 | // IsAvailable checks if pass is available in the system. | ||
| 108 | + | // Uses a short timeout so a stalled GPG agent does not hang the caller. | |||
| 108 | func (p *PassProvider) IsAvailable() bool { | 109 | func (p *PassProvider) IsAvailable() bool { | ||
| 109 | - | cmd := exec.Command("pass", "version") | 110 | + | ctx, cancel := context.WithTimeout(context.Background(), p.timeout) |
| 110 | - | err := cmd.Run() | 111 | + | defer cancel() |
| 111 | - | return err == nil | 112 | + | cmd := exec.CommandContext(ctx, "pass", "version") //nolint:gosec // binary is a constant ("pass"), no user input |
| 113 | + | return cmd.Run() == nil | |||
| 112 | } | 114 | } | ||
M internal/adapters/out/tokenstore/pass.go +10 -4
| @@ -236,7 +236,8 @@ func parsePassLsEntries(output string) []treeEntry { | |||
| 236 | 236 | // The branch character (├ or └, 3 bytes each) immediately precedes them. | |
| 237 | 237 | branchByteIdx := idx - 3 | |
| 238 | 238 | if branchByteIdx < 0 { | |
| 239 | - | branchByteIdx = 0 | |
| 239 | + | // Marker is too close to the start — malformed line, skip. | |
| 240 | + | continue | |
| 240 | 241 | } | |
| 241 | 242 | // Count runes in the indentation prefix before the branch char. | |
| 242 | 243 | branchRune = utf8.RuneCountInString(line[:branchByteIdx]) | |
| @@ -246,7 +247,8 @@ func parsePassLsEntries(output string) []treeEntry { | |||
| 246 | 247 | // ASCII variant: branch char (| or `, 1 byte) is immediately before the dashes. | |
| 247 | 248 | branchByteIdx := idx - 1 | |
| 248 | 249 | if branchByteIdx < 0 { | |
| 249 | - | branchByteIdx = 0 | |
| 250 | + | // Marker is too close to the start — malformed line, skip. | |
| 251 | + | continue | |
| 250 | 252 | } | |
| 251 | 253 | branchRune = utf8.RuneCountInString(line[:branchByteIdx]) | |
| 252 | 254 | // Name starts after: branchChar(1B) + -(1B) + -(1B) + SP(1B) = 4B from branch. | |
| @@ -351,8 +353,12 @@ func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) { | |||
| 351 | 353 | continue | |
| 352 | 354 | } | |
| 353 | 355 | ||
| 354 | - | // Try to get the token metadata | |
| 355 | - | _, token, err := s.GetToken(ctx, subject) | |
| 356 | + | // Try to get the token metadata. | |
| 357 | + | // Use a fresh per-call context so each GetToken gets the full timeout, | |
| 358 | + | // not the shared (potentially exhausted) deadline from the outer ctx. | |
| 359 | + | callCtx, callCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 360 | + | _, token, err := s.GetToken(callCtx, subject) | |
| 361 | + | callCancel() | |
| 356 | 362 | if err != nil { | |
| 357 | 363 | s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token") | |
| 358 | 364 | continue | |
| @@ -236,7 +236,8 @@ func parsePassLsEntries(output string) []treeEntry { | |||||
| 236 | // The branch character (├ or └, 3 bytes each) immediately precedes them. | 236 | // The branch character (├ or └, 3 bytes each) immediately precedes them. | ||
| 237 | branchByteIdx := idx - 3 | 237 | branchByteIdx := idx - 3 | ||
| 238 | if branchByteIdx < 0 { | 238 | if branchByteIdx < 0 { | ||
| 239 | - | branchByteIdx = 0 | 239 | + | // Marker is too close to the start — malformed line, skip. |
| 240 | + | continue | |||
| 240 | } | 241 | } | ||
| 241 | // Count runes in the indentation prefix before the branch char. | 242 | // Count runes in the indentation prefix before the branch char. | ||
| 242 | branchRune = utf8.RuneCountInString(line[:branchByteIdx]) | 243 | branchRune = utf8.RuneCountInString(line[:branchByteIdx]) | ||
| @@ -246,7 +247,8 @@ func parsePassLsEntries(output string) []treeEntry { | |||||
| 246 | // ASCII variant: branch char (| or `, 1 byte) is immediately before the dashes. | 247 | // ASCII variant: branch char (| or `, 1 byte) is immediately before the dashes. | ||
| 247 | branchByteIdx := idx - 1 | 248 | branchByteIdx := idx - 1 | ||
| 248 | if branchByteIdx < 0 { | 249 | if branchByteIdx < 0 { | ||
| 249 | - | branchByteIdx = 0 | 250 | + | // Marker is too close to the start — malformed line, skip. |
| 251 | + | continue | |||
| 250 | } | 252 | } | ||
| 251 | branchRune = utf8.RuneCountInString(line[:branchByteIdx]) | 253 | branchRune = utf8.RuneCountInString(line[:branchByteIdx]) | ||
| 252 | // Name starts after: branchChar(1B) + -(1B) + -(1B) + SP(1B) = 4B from branch. | 254 | // Name starts after: branchChar(1B) + -(1B) + -(1B) + SP(1B) = 4B from branch. | ||
| @@ -351,8 +353,12 @@ func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) { | |||||
| 351 | continue | 353 | continue | ||
| 352 | } | 354 | } | ||
| 353 | 355 | ||||
| 354 | - | // Try to get the token metadata | 356 | + | // Try to get the token metadata. |
| 355 | - | _, token, err := s.GetToken(ctx, subject) | 357 | + | // Use a fresh per-call context so each GetToken gets the full timeout, |
| 358 | + | // not the shared (potentially exhausted) deadline from the outer ctx. | |||
| 359 | + | callCtx, callCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 360 | + | _, token, err := s.GetToken(callCtx, subject) | |||
| 361 | + | callCancel() | |||
| 356 | if err != nil { | 362 | if err != nil { | ||
| 357 | s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token") | 363 | s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token") | ||
| 358 | continue | 364 | continue | ||