detached 7814119

fix(tokenstore): correctly enumerate pass tokens with / in subject path

commit
78141197899147a8e9e6165c4f576358ea545abf
author
bnema · · 2026-02-25 01:36 UTC
parent
8197cdd
signature
unsigned
2 files · +209 -25
M internal/adapters/out/tokenstore/pass.go +141 -25
@@ -10,6 +10,7 @@ import (
1010 "strings"
1111 "sync"
1212 "time"
13+ "unicode/utf8"
1314
1415 "github.com/bnema/zerowrap"
1516
@@ -192,49 +193,164 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma
192193 return jwt, token, nil
193194 }
194195
196+// treeEntry holds a single parsed entry from pass ls tree output.
197+type treeEntry struct {
198+ depth int
199+ name string
200+}
201+
202+// parsePassLsEntries parses raw pass ls output into a slice of tree entries.
203+// Each entry carries its nesting depth (0 = direct child of the listed root)
204+// and the bare file/directory name on that line.
205+func parsePassLsEntries(output string) []treeEntry {
206+ var entries []treeEntry
207+
208+ lines := strings.Split(output, "\n")
209+ // Skip the first line (the directory header, e.g. "gordon/registry/tokens").
210+ for _, line := range lines[1:] {
211+ // Strip ANSI escape sequences (pass may output coloured text on a TTY).
212+ line = ansiRegex.ReplaceAllString(line, "")
213+
214+ if strings.TrimSpace(line) == "" {
215+ continue
216+ }
217+
218+ // Locate the branch marker. pass uses Unicode box-drawing characters:
219+ // ├── or └── (U+251C / U+2514, then two U+2500 dashes, then a space)
220+ // Some terminals may fall back to ASCII equivalents:
221+ // |-- or `-- (1-byte ASCII chars)
222+ //
223+ // Regardless of Unicode vs ASCII, each indent level is exactly 4 characters
224+ // wide (e.g. "│ " = pipe + 3 spaces, " " = 4 spaces, "| " = pipe + 3).
225+ // We count runes before the branch character and divide by 4 to get depth.
226+
227+ var nameOffset int // byte offset where the entry name begins
228+ var branchRune int // rune count before the branch character (├ └ | `)
229+
230+ if idx := strings.Index(line, "── "); idx != -1 {
231+ // Unicode variant: the two '─' dashes (U+2500) start at byte idx.
232+ // The branch character (├ or └, 3 bytes each) immediately precedes them.
233+ branchByteIdx := idx - 3
234+ if branchByteIdx < 0 {
235+ branchByteIdx = 0
236+ }
237+ // Count runes in the indentation prefix before the branch char.
238+ branchRune = utf8.RuneCountInString(line[:branchByteIdx])
239+ // Name starts after: branchChar(3B) + ─(3B) + ─(3B) + SP(1B) = 10B from branch.
240+ nameOffset = branchByteIdx + 10
241+ } else if idx := strings.Index(line, "-- "); idx != -1 {
242+ // ASCII variant: branch char (| or `, 1 byte) is immediately before the dashes.
243+ branchByteIdx := idx - 1
244+ if branchByteIdx < 0 {
245+ branchByteIdx = 0
246+ }
247+ branchRune = utf8.RuneCountInString(line[:branchByteIdx])
248+ // Name starts after: branchChar(1B) + -(1B) + -(1B) + SP(1B) = 4B from branch.
249+ nameOffset = branchByteIdx + 4
250+ } else {
251+ // No recognisable tree marker on this line — skip it.
252+ continue
253+ }
254+
255+ if nameOffset > len(line) {
256+ continue
257+ }
258+ name := strings.TrimSpace(line[nameOffset:])
259+ if name == "" {
260+ continue
261+ }
262+
263+ // Each indent level is 4 characters wide.
264+ depth := branchRune / 4
265+ entries = append(entries, treeEntry{depth: depth, name: name})
266+ }
267+
268+ return entries
269+}
270+
271+// parsePassLsOutput parses the tree-formatted output of `pass ls` and returns
272+// a flat list of full subject paths. It reconstructs slash-separated paths for
273+// nested entries (e.g. "team/alice") by tracking the indentation depth.
274+// Only leaf nodes (actual pass entries, not intermediate directories) are returned.
275+//
276+// pass ls outputs a tree like:
277+//
278+// gordon/registry/tokens
279+// ├── admin
280+// └── team
281+// ├── alice
282+// └── bob
283+//
284+// This function returns ["admin", "team/alice", "team/bob"].
285+func parsePassLsOutput(output string) []string {
286+ type frame struct {
287+ depth int
288+ name string
289+ }
290+
291+ entries := parsePassLsEntries(output)
292+
293+ var subjects []string
294+ var stack []frame
295+
296+ for i, entry := range entries {
297+ // Pop ancestors that are at the same depth or deeper than the current entry.
298+ for len(stack) > 0 && stack[len(stack)-1].depth >= entry.depth {
299+ stack = stack[:len(stack)-1]
300+ }
301+
302+ // Build the full path for this entry by joining ancestors with this name.
303+ parts := make([]string, 0, len(stack)+1)
304+ for _, f := range stack {
305+ parts = append(parts, f.name)
306+ }
307+ parts = append(parts, entry.name)
308+ subject := strings.Join(parts, "/")
309+
310+ // Determine whether this entry is an intermediate directory node.
311+ // A node is a directory if the immediately following entry is deeper.
312+ isDir := i+1 < len(entries) && entries[i+1].depth > entry.depth
313+
314+ // Always push so deeper entries can use this as ancestor context.
315+ stack = append(stack, frame{depth: entry.depth, name: entry.name})
316+
317+ if !isDir {
318+ subjects = append(subjects, subject)
319+ }
320+ }
321+
322+ if subjects == nil {
323+ return []string{}
324+ }
325+ return subjects
326+}
327+
195328 // ListTokens returns all stored tokens from pass.
196329 func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
197330 ctx, cancel := context.WithTimeout(ctx, s.timeout)
198331 defer cancel()
199332
200333 // List all entries under the token path
201- cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath)
334+ cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) //nolint:gosec // passTokenPath is a fixed constant
202335 output, err := cmd.Output()
203336 if err != nil {
204337 // If the path doesn't exist, return empty list
205338 return []domain.Token{}, nil
206339 }
207340
208- var tokens []domain.Token
209- lines := strings.Split(string(output), "\n")
341+ subjects := parsePassLsOutput(string(output))
210342
211- for _, line := range lines {
212- line = strings.TrimSpace(line)
213-
214- // Strip ANSI escape sequences (pass outputs colored text on TTY)
215- line = ansiRegex.ReplaceAllString(line, "")
216-
217- // Skip tree formatting characters (both Unicode and ASCII variants)
218- // Unicode: ├── └── │
219- line = strings.TrimPrefix(line, "├── ")
220- line = strings.TrimPrefix(line, "└── ")
221- line = strings.TrimPrefix(line, "│ ")
222- // ASCII: |-- `-- |
223- line = strings.TrimPrefix(line, "|-- ")
224- line = strings.TrimPrefix(line, "`-- ")
225- line = strings.TrimPrefix(line, "| ")
226-
227- line = strings.TrimSpace(line)
228-
229- // Skip .meta files, empty lines, and the header line
230- if line == "" || strings.HasSuffix(line, ".meta") || line == passTokenPath {
343+ var tokens []domain.Token
344+ for _, subject := range subjects {
345+ // Skip .meta files — they are metadata companions, not tokens themselves.
346+ if strings.HasSuffix(subject, ".meta") {
231347 continue
232348 }
233349
234350 // Try to get the token metadata
235- _, token, err := s.GetToken(ctx, line)
351+ _, token, err := s.GetToken(ctx, subject)
236352 if err != nil {
237- s.log.Warn().Err(err).Str("subject", line).Msg("failed to get token")
353+ s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token")
238354 continue
239355 }
240356
@@ -10,6 +10,7 @@ import (
10 "strings"10 "strings"
11 "sync"11 "sync"
12 "time"12 "time"
13+ "unicode/utf8"
13 14
14 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
15 16
@@ -192,49 +193,164 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma
192 return jwt, token, nil193 return jwt, token, nil
193 }194 }
194 195
196+// treeEntry holds a single parsed entry from pass ls tree output.
197+type treeEntry struct {
198+ depth int
199+ name string
200+}
201+
202+// parsePassLsEntries parses raw pass ls output into a slice of tree entries.
203+// Each entry carries its nesting depth (0 = direct child of the listed root)
204+// and the bare file/directory name on that line.
205+func parsePassLsEntries(output string) []treeEntry {
206+ var entries []treeEntry
207+
208+ lines := strings.Split(output, "\n")
209+ // Skip the first line (the directory header, e.g. "gordon/registry/tokens").
210+ for _, line := range lines[1:] {
211+ // Strip ANSI escape sequences (pass may output coloured text on a TTY).
212+ line = ansiRegex.ReplaceAllString(line, "")
213+
214+ if strings.TrimSpace(line) == "" {
215+ continue
216+ }
217+
218+ // Locate the branch marker. pass uses Unicode box-drawing characters:
219+ // ├── or └── (U+251C / U+2514, then two U+2500 dashes, then a space)
220+ // Some terminals may fall back to ASCII equivalents:
221+ // |-- or `-- (1-byte ASCII chars)
222+ //
223+ // Regardless of Unicode vs ASCII, each indent level is exactly 4 characters
224+ // wide (e.g. "│ " = pipe + 3 spaces, " " = 4 spaces, "| " = pipe + 3).
225+ // We count runes before the branch character and divide by 4 to get depth.
226+
227+ var nameOffset int // byte offset where the entry name begins
228+ var branchRune int // rune count before the branch character (├ └ | `)
229+
230+ if idx := strings.Index(line, "── "); idx != -1 {
231+ // Unicode variant: the two '─' dashes (U+2500) start at byte idx.
232+ // The branch character (├ or └, 3 bytes each) immediately precedes them.
233+ branchByteIdx := idx - 3
234+ if branchByteIdx < 0 {
235+ branchByteIdx = 0
236+ }
237+ // Count runes in the indentation prefix before the branch char.
238+ branchRune = utf8.RuneCountInString(line[:branchByteIdx])
239+ // Name starts after: branchChar(3B) + ─(3B) + ─(3B) + SP(1B) = 10B from branch.
240+ nameOffset = branchByteIdx + 10
241+ } else if idx := strings.Index(line, "-- "); idx != -1 {
242+ // ASCII variant: branch char (| or `, 1 byte) is immediately before the dashes.
243+ branchByteIdx := idx - 1
244+ if branchByteIdx < 0 {
245+ branchByteIdx = 0
246+ }
247+ branchRune = utf8.RuneCountInString(line[:branchByteIdx])
248+ // Name starts after: branchChar(1B) + -(1B) + -(1B) + SP(1B) = 4B from branch.
249+ nameOffset = branchByteIdx + 4
250+ } else {
251+ // No recognisable tree marker on this line — skip it.
252+ continue
253+ }
254+
255+ if nameOffset > len(line) {
256+ continue
257+ }
258+ name := strings.TrimSpace(line[nameOffset:])
259+ if name == "" {
260+ continue
261+ }
262+
263+ // Each indent level is 4 characters wide.
264+ depth := branchRune / 4
265+ entries = append(entries, treeEntry{depth: depth, name: name})
266+ }
267+
268+ return entries
269+}
270+
271+// parsePassLsOutput parses the tree-formatted output of `pass ls` and returns
272+// a flat list of full subject paths. It reconstructs slash-separated paths for
273+// nested entries (e.g. "team/alice") by tracking the indentation depth.
274+// Only leaf nodes (actual pass entries, not intermediate directories) are returned.
275+//
276+// pass ls outputs a tree like:
277+//
278+// gordon/registry/tokens
279+// ├── admin
280+// └── team
281+// ├── alice
282+// └── bob
283+//
284+// This function returns ["admin", "team/alice", "team/bob"].
285+func parsePassLsOutput(output string) []string {
286+ type frame struct {
287+ depth int
288+ name string
289+ }
290+
291+ entries := parsePassLsEntries(output)
292+
293+ var subjects []string
294+ var stack []frame
295+
296+ for i, entry := range entries {
297+ // Pop ancestors that are at the same depth or deeper than the current entry.
298+ for len(stack) > 0 && stack[len(stack)-1].depth >= entry.depth {
299+ stack = stack[:len(stack)-1]
300+ }
301+
302+ // Build the full path for this entry by joining ancestors with this name.
303+ parts := make([]string, 0, len(stack)+1)
304+ for _, f := range stack {
305+ parts = append(parts, f.name)
306+ }
307+ parts = append(parts, entry.name)
308+ subject := strings.Join(parts, "/")
309+
310+ // Determine whether this entry is an intermediate directory node.
311+ // A node is a directory if the immediately following entry is deeper.
312+ isDir := i+1 < len(entries) && entries[i+1].depth > entry.depth
313+
314+ // Always push so deeper entries can use this as ancestor context.
315+ stack = append(stack, frame{depth: entry.depth, name: entry.name})
316+
317+ if !isDir {
318+ subjects = append(subjects, subject)
319+ }
320+ }
321+
322+ if subjects == nil {
323+ return []string{}
324+ }
325+ return subjects
326+}
327+
195 // ListTokens returns all stored tokens from pass.328 // ListTokens returns all stored tokens from pass.
196 func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {329 func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
197 ctx, cancel := context.WithTimeout(ctx, s.timeout)330 ctx, cancel := context.WithTimeout(ctx, s.timeout)
198 defer cancel()331 defer cancel()
199 332
200 // List all entries under the token path333 // List all entries under the token path
201- cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath)334+ cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) //nolint:gosec // passTokenPath is a fixed constant
202 output, err := cmd.Output()335 output, err := cmd.Output()
203 if err != nil {336 if err != nil {
204 // If the path doesn't exist, return empty list337 // If the path doesn't exist, return empty list
205 return []domain.Token{}, nil338 return []domain.Token{}, nil
206 }339 }
207 340
208- var tokens []domain.Token341+ subjects := parsePassLsOutput(string(output))
209- lines := strings.Split(string(output), "\n")
210 342
211- for _, line := range lines {343+ var tokens []domain.Token
212- line = strings.TrimSpace(line)344+ for _, subject := range subjects {
213-345+ // Skip .meta files — they are metadata companions, not tokens themselves.
214- // Strip ANSI escape sequences (pass outputs colored text on TTY)346+ if strings.HasSuffix(subject, ".meta") {
215- line = ansiRegex.ReplaceAllString(line, "")
216-
217- // Skip tree formatting characters (both Unicode and ASCII variants)
218- // Unicode: ├── └── │
219- line = strings.TrimPrefix(line, "├── ")
220- line = strings.TrimPrefix(line, "└── ")
221- line = strings.TrimPrefix(line, "│ ")
222- // ASCII: |-- `-- |
223- line = strings.TrimPrefix(line, "|-- ")
224- line = strings.TrimPrefix(line, "`-- ")
225- line = strings.TrimPrefix(line, "| ")
226-
227- line = strings.TrimSpace(line)
228-
229- // Skip .meta files, empty lines, and the header line
230- if line == "" || strings.HasSuffix(line, ".meta") || line == passTokenPath {
231 continue347 continue
232 }348 }
233 349
234 // Try to get the token metadata350 // Try to get the token metadata
235- _, token, err := s.GetToken(ctx, line)351+ _, token, err := s.GetToken(ctx, subject)
236 if err != nil {352 if err != nil {
237- s.log.Warn().Err(err).Str("subject", line).Msg("failed to get token")353+ s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token")
238 continue354 continue
239 }355 }
240 356
M internal/adapters/out/tokenstore/pass_test.go +68 -0
@@ -12,6 +12,74 @@ import (
1212 "github.com/bnema/gordon/internal/domain"
1313 )
1414
15+// TestParsePassLsOutput verifies that pass ls tree output is parsed correctly,
16+// including subjects that contain "/" (multi-level paths).
17+func TestParsePassLsOutput(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ input string
21+ want []string
22+ }{
23+ {
24+ name: "flat entries only",
25+ input: `gordon/registry/tokens
26+├── admin
27+└── bob
28+`,
29+ want: []string{"admin", "bob"},
30+ },
31+ {
32+ name: "nested entries with slash in subject",
33+ input: `gordon/registry/tokens
34+├── admin
35+└── team
36+ ├── alice
37+ └── bob
38+`,
39+ want: []string{"admin", "team/alice", "team/bob"},
40+ },
41+ {
42+ name: "deeply nested entries",
43+ input: `gordon/registry/tokens
44+└── org
45+ └── team
46+ └── alice
47+`,
48+ want: []string{"org/team/alice"},
49+ },
50+ {
51+ name: "mixed flat and nested",
52+ input: `gordon/registry/tokens
53+├── admin
54+├── team
55+│ ├── alice
56+│ └── bob
57+└── standalone
58+`,
59+ want: []string{"admin", "team/alice", "team/bob", "standalone"},
60+ },
61+ {
62+ name: "empty output (no entries)",
63+ input: "gordon/registry/tokens\n",
64+ want: []string{},
65+ },
66+ {
67+ name: "ascii tree decoration variant",
68+ input: `gordon/registry/tokens
69+|-- admin
70+` + "`-- team\n `-- alice\n",
71+ want: []string{"admin", "team/alice"},
72+ },
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ got := parsePassLsOutput(tt.input)
78+ assert.Equal(t, tt.want, got)
79+ })
80+ }
81+}
82+
1583 // These tests verify the in-memory caching behavior of PassStore.
1684 // They directly manipulate the cache to test cache hit/miss logic
1785 // without requiring the actual pass binary.
@@ -12,6 +12,74 @@ import (
12 "github.com/bnema/gordon/internal/domain"12 "github.com/bnema/gordon/internal/domain"
13 )13 )
14 14
15+// TestParsePassLsOutput verifies that pass ls tree output is parsed correctly,
16+// including subjects that contain "/" (multi-level paths).
17+func TestParsePassLsOutput(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ input string
21+ want []string
22+ }{
23+ {
24+ name: "flat entries only",
25+ input: `gordon/registry/tokens
26+├── admin
27+└── bob
28+`,
29+ want: []string{"admin", "bob"},
30+ },
31+ {
32+ name: "nested entries with slash in subject",
33+ input: `gordon/registry/tokens
34+├── admin
35+└── team
36+ ├── alice
37+ └── bob
38+`,
39+ want: []string{"admin", "team/alice", "team/bob"},
40+ },
41+ {
42+ name: "deeply nested entries",
43+ input: `gordon/registry/tokens
44+└── org
45+ └── team
46+ └── alice
47+`,
48+ want: []string{"org/team/alice"},
49+ },
50+ {
51+ name: "mixed flat and nested",
52+ input: `gordon/registry/tokens
53+├── admin
54+├── team
55+│ ├── alice
56+│ └── bob
57+└── standalone
58+`,
59+ want: []string{"admin", "team/alice", "team/bob", "standalone"},
60+ },
61+ {
62+ name: "empty output (no entries)",
63+ input: "gordon/registry/tokens\n",
64+ want: []string{},
65+ },
66+ {
67+ name: "ascii tree decoration variant",
68+ input: `gordon/registry/tokens
69+|-- admin
70+` + "`-- team\n `-- alice\n",
71+ want: []string{"admin", "team/alice"},
72+ },
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ got := parsePassLsOutput(tt.input)
78+ assert.Equal(t, tt.want, got)
79+ })
80+ }
81+}
82+
15 // These tests verify the in-memory caching behavior of PassStore.83 // These tests verify the in-memory caching behavior of PassStore.
16 // They directly manipulate the cache to test cache hit/miss logic84 // They directly manipulate the cache to test cache hit/miss logic
17 // without requiring the actual pass binary.85 // without requiring the actual pass binary.

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 106

↑↓ moveenter openesc close