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 ( | |||
| 10 | 10 | "strings" | |
| 11 | 11 | "sync" | |
| 12 | 12 | "time" | |
| 13 | + | "unicode/utf8" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/bnema/zerowrap" | |
| 15 | 16 | ||
| @@ -192,49 +193,164 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma | |||
| 192 | 193 | 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 | 328 | // ListTokens returns all stored tokens from pass. | |
| 196 | 329 | func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) { | |
| 197 | 330 | ctx, cancel := context.WithTimeout(ctx, s.timeout) | |
| 198 | 331 | defer cancel() | |
| 199 | 332 | ||
| 200 | 333 | // 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 | 335 | output, err := cmd.Output() | |
| 203 | 336 | if err != nil { | |
| 204 | 337 | // If the path doesn't exist, return empty list | |
| 205 | 338 | return []domain.Token{}, nil | |
| 206 | 339 | } | |
| 207 | 340 | ||
| 208 | - | var tokens []domain.Token | |
| 209 | - | lines := strings.Split(string(output), "\n") | |
| 341 | + | subjects := parsePassLsOutput(string(output)) | |
| 210 | 342 | ||
| 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") { | |
| 231 | 347 | continue | |
| 232 | 348 | } | |
| 233 | 349 | ||
| 234 | 350 | // Try to get the token metadata | |
| 235 | - | _, token, err := s.GetToken(ctx, line) | |
| 351 | + | _, token, err := s.GetToken(ctx, subject) | |
| 236 | 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 | 354 | continue | |
| 239 | 355 | } | |
| 240 | 356 | ||
| @@ -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, nil | 193 | 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 path | 333 | // 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 list | 337 | // If the path doesn't exist, return empty list | ||
| 205 | return []domain.Token{}, nil | 338 | return []domain.Token{}, nil | ||
| 206 | } | 339 | } | ||
| 207 | 340 | ||||
| 208 | - | var tokens []domain.Token | 341 | + | 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 | continue | 347 | continue | ||
| 232 | } | 348 | } | ||
| 233 | 349 | ||||
| 234 | // Try to get the token metadata | 350 | // 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 | continue | 354 | continue | ||
| 239 | } | 355 | } | ||
| 240 | 356 | ||||
M internal/adapters/out/tokenstore/pass_test.go +68 -0
| @@ -12,6 +12,74 @@ import ( | |||
| 12 | 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 | 83 | // These tests verify the in-memory caching behavior of PassStore. | |
| 16 | 84 | // They directly manipulate the cache to test cache hit/miss logic | |
| 17 | 85 | // 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 logic | 84 | // 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. | ||