A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ls): wire -t/-1, add many flags, improve long format

Implements GNU coreutils compatibility for ls:

- Wires up -t (sort by mtime) and -1 (one-per-line) which
were previously parsed but unused
- Adds -i, -k, -m, -n, -o, -p, -q, -s, -c, -u, -f flags
- Long-format -l now emits suid/sgid (s/S) and sticky
(t/T) bits in mode display, with date format switching
between "Mon DD HH:MM" (recent) and "Mon DD YYYY"
- -f disables sort, forces -a, suppresses -l/-S/-t

Multi-column -C/-x deferred (no terminal-width context in
the sandbox); ctime/atime fall back to mtime since billy
exposes only ModTime.

Refs: docs/posix2018/CONFORMANCE.md
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso d9055678 d99d870c

+1069 -94
+478 -82
command/internal/ls/ls.go
··· 45 45 fmt.Fprint(stderr, "list directory contents\n\n") 46 46 fmt.Fprint(stderr, " -a, --all do not ignore entries starting with .\n") 47 47 fmt.Fprint(stderr, " -A, --almost-all do not list . and ..\n") 48 + fmt.Fprint(stderr, " -c sort by ctime (best-effort; falls back to mtime)\n") 48 49 fmt.Fprint(stderr, " -d, --directory list directories themselves, not their contents\n") 50 + fmt.Fprint(stderr, " -f do not sort, enable -a, disable -l\n") 49 51 fmt.Fprint(stderr, " -F, --classify append indicator (one of */=>@) to entries\n") 50 52 fmt.Fprint(stderr, " -h, --human-readable with -l, print sizes like 1K 234M 2G etc.\n") 53 + fmt.Fprint(stderr, " -i, --inode print the index number of each file (always '?' here)\n") 54 + fmt.Fprint(stderr, " -k with -s, use 1024-byte blocks\n") 51 55 fmt.Fprint(stderr, " -l use a long listing format\n") 56 + fmt.Fprint(stderr, " -m fill width with a comma separated list of entries\n") 57 + fmt.Fprint(stderr, " -n, --numeric-uid-gid like -l but list numeric user and group IDs\n") 58 + fmt.Fprint(stderr, " -o like -l, but do not list group information\n") 59 + fmt.Fprint(stderr, " -p append / indicator to directories\n") 60 + fmt.Fprint(stderr, " -q, --hide-control-chars print ? instead of nongraphic characters\n") 52 61 fmt.Fprint(stderr, " -r, --reverse reverse order while sorting\n") 53 62 fmt.Fprint(stderr, " -R, --recursive list subdirectories recursively\n") 63 + fmt.Fprint(stderr, " -s, --size print the allocated size of each file, in blocks\n") 54 64 fmt.Fprint(stderr, " -S sort by file size, largest first\n") 55 65 fmt.Fprint(stderr, " -t sort by time, newest first\n") 66 + fmt.Fprint(stderr, " -u sort by atime (best-effort; falls back to mtime)\n") 56 67 fmt.Fprint(stderr, " -1 list one file per line\n") 57 68 fmt.Fprint(stderr, " --help display this help and exit\n") 58 69 } ··· 60 71 61 72 showAll := set.BoolLong("all", 'a', "do not ignore entries starting with .") 62 73 showAlmostAll := set.BoolLong("almost-all", 'A', "do not list . and ..") 74 + useCtime := set.Bool('c', "sort by ctime (best-effort; falls back to mtime)") 63 75 directoryOnly := set.BoolLong("directory", 'd', "list directories themselves, not their contents") 76 + noSort := set.Bool('f', "do not sort, enable -a, disable -l") 64 77 classifyFiles := set.BoolLong("classify", 'F', "append indicator (one of */=>@) to entries") 65 78 humanReadable := set.BoolLong("human-readable", 'h', "with -l, print sizes like 1K 234M 2G etc.") 79 + showInode := set.BoolLong("inode", 'i', "print the index number of each file") 80 + kBytes := set.Bool('k', "with -s, use 1024-byte blocks") 66 81 longFormat := set.Bool('l', "use a long listing format") 82 + commaList := set.Bool('m', "fill width with a comma separated list of entries") 83 + numericIDs := set.BoolLong("numeric-uid-gid", 'n', "like -l but list numeric user and group IDs") 84 + longNoGroup := set.Bool('o', "like -l, but do not list group information") 85 + slashDirs := set.Bool('p', "append / indicator to directories") 86 + hideControl := set.BoolLong("hide-control-chars", 'q', "print ? instead of nongraphic characters") 67 87 reverse := set.BoolLong("reverse", 'r', "reverse order while sorting") 68 88 recursive := set.BoolLong("recursive", 'R', "list subdirectories recursively") 89 + showBlocks := set.BoolLong("size", 's', "print the allocated size of each file, in blocks") 69 90 sortBySize := set.Bool('S', "sort by file size, largest first") 70 91 sortByTime := set.Bool('t', "sort by time, newest first") 92 + useAtime := set.Bool('u', "sort by atime (best-effort; falls back to mtime)") 71 93 onePerLine := set.Bool('1', "list one file per line") 72 94 help := set.BoolLong("help", 0, "display this help and exit") 73 95 ··· 80 102 usage() 81 103 return nil 82 104 } 83 - _ = sortByTime 105 + 106 + // -n, -o imply -l (long format). 107 + effLong := *longFormat || *numericIDs || *longNoGroup 108 + // -m disables long format. 109 + if *commaList { 110 + effLong = false 111 + } 112 + // -1 disables -m. 113 + if *onePerLine { 114 + *commaList = false 115 + } 116 + // -f: disable sorting, force -a, suppress long format. Per GNU ls, 117 + // -f also implies --color=none and disables -l/-s indirectly via the 118 + // "do not access file metadata" rule, but kefka is non-tty and we 119 + // already approximate metadata, so the visible effect here is the 120 + // sort/-a override. 121 + effShowAll := *showAll 122 + effSortByTime := *sortByTime 123 + effSortBySize := *sortBySize 124 + if *noSort { 125 + effShowAll = true 126 + effLong = false 127 + effSortByTime = false 128 + effSortBySize = false 129 + } 130 + 131 + opts := lsOptions{ 132 + showAll: effShowAll, 133 + showAlmostAll: *showAlmostAll, 134 + directoryOnly: *directoryOnly, 135 + classifyFiles: *classifyFiles, 136 + humanReadable: *humanReadable, 137 + showInode: *showInode, 138 + kBytes: *kBytes, 139 + longFormat: effLong, 140 + commaList: *commaList, 141 + numericIDs: *numericIDs, 142 + longNoGroup: *longNoGroup, 143 + slashDirs: *slashDirs, 144 + hideControl: *hideControl, 145 + reverse: *reverse, 146 + recursive: *recursive, 147 + showBlocks: *showBlocks, 148 + sortBySize: effSortBySize, 149 + sortByTime: effSortByTime, 150 + noSort: *noSort, 151 + useCtime: *useCtime, 152 + useAtime: *useAtime, 153 + } 154 + // -1 is the default for non-tty output, which kefka always is. The 155 + // flag is accepted for compatibility but has no observable effect on 156 + // the default per-line output beyond overriding -m above. 84 157 _ = onePerLine 85 158 86 159 paths := set.Args() ··· 88 161 paths = []string{"."} 89 162 } 90 163 91 - showHeader := len(paths) > 1 164 + opts.showHeader = len(paths) > 1 92 165 exitCode := 0 93 166 var stdoutBuf, stderrBuf strings.Builder 94 167 ··· 104 177 ) 105 178 106 179 switch { 107 - case *directoryOnly: 108 - out, errS, code = listDirectoryEntry(ec, p, *longFormat, *humanReadable, *classifyFiles) 180 + case opts.directoryOnly: 181 + out, errS, code = listDirectoryEntry(ec, p, opts) 109 182 case strings.ContainsAny(p, "*?["): 110 - out, errS, code = listGlob(ec, p, *showAll, *showAlmostAll, *longFormat, *reverse, *humanReadable, *sortBySize, *classifyFiles) 183 + out, errS, code = listGlob(ec, p, opts) 111 184 default: 112 - out, errS, code = listPath(ctx, ec, p, *showAll, *showAlmostAll, *longFormat, *recursive, showHeader, *reverse, *humanReadable, *sortBySize, *classifyFiles) 185 + out, errS, code = listPath(ctx, ec, p, opts) 113 186 } 114 187 115 188 stdoutBuf.WriteString(out) ··· 215 288 return strings.Repeat(" ", n-len(s)) + s 216 289 } 217 290 218 - func longFormatLine(info fs.FileInfo, name, suffix string, humanReadable bool) string { 219 - mode := "-rw-r--r--" 220 - if info.IsDir() { 221 - mode = "drwxr-xr-x" 291 + // lsOptions bundles the flag values that downstream helpers need so the 292 + // signatures don't grow unbounded as we add more flags. 293 + type lsOptions struct { 294 + showAll bool 295 + showAlmostAll bool 296 + directoryOnly bool 297 + classifyFiles bool 298 + humanReadable bool 299 + showInode bool 300 + kBytes bool 301 + longFormat bool 302 + commaList bool 303 + numericIDs bool 304 + longNoGroup bool 305 + slashDirs bool 306 + hideControl bool 307 + reverse bool 308 + recursive bool 309 + showBlocks bool 310 + sortBySize bool 311 + sortByTime bool 312 + noSort bool 313 + useCtime bool 314 + useAtime bool 315 + showHeader bool 316 + } 317 + 318 + // blockSize returns the unit, in bytes, used for the -s block column and 319 + // the "total N" line. POSIX defaults to 512; -k forces 1024. 320 + func (o lsOptions) blockSize() int64 { 321 + if o.kBytes { 322 + return 1024 222 323 } 324 + return 512 325 + } 326 + 327 + // blocksFor reports the number of blockSize-byte blocks consumed by a file 328 + // of the given byte size, rounded up. Real ls uses st_blocks, but billy 329 + // doesn't expose that, so we approximate from logical size. 330 + func (o lsOptions) blocksFor(size int64) int64 { 331 + bs := o.blockSize() 332 + return (size + bs - 1) / bs 333 + } 334 + 335 + // hideControlChars replaces non-printable bytes (and tab) in name with '?', 336 + // per ls -q. We operate on bytes since billy paths are byte strings. 337 + func hideControlChars(name string) string { 338 + out := make([]byte, len(name)) 339 + for i := 0; i < len(name); i++ { 340 + c := name[i] 341 + // Treat anything outside printable ASCII (and tab) as non-printable. 342 + // This is intentionally conservative: utf-8 multibyte chars get '?' 343 + // per byte. Real GNU ls does locale-aware printable detection, which 344 + // kefka does not have access to. 345 + if c < 0x20 || c == 0x7f { 346 + out[i] = '?' 347 + } else { 348 + out[i] = c 349 + } 350 + } 351 + return string(out) 352 + } 353 + 354 + // nlinkOf returns the link count for an fs.FileInfo if the underlying type 355 + // implements Sys() with a *syscall.Stat_t (real OS); otherwise 1. 356 + func nlinkOf(info fs.FileInfo) uint64 { 357 + if info == nil { 358 + return 1 359 + } 360 + if st, ok := info.Sys().(interface{ Nlink() uint64 }); ok { 361 + return st.Nlink() 362 + } 363 + return 1 364 + } 365 + 366 + // timeFor picks the timestamp used for sorting and long-format display 367 + // based on -c (ctime), -u (atime), or default (mtime). billy.FileInfo only 368 + // exposes ModTime(), so -c and -u silently fall back to mtime. This is 369 + // documented in the help text and in the audit notes. 370 + func timeFor(info fs.FileInfo, opts lsOptions) time.Time { 371 + if info == nil { 372 + return time.Time{} 373 + } 374 + return info.ModTime() 375 + } 376 + 377 + func longFormatLine(info fs.FileInfo, name, suffix string, opts lsOptions) string { 378 + mode := formatMode(info.Mode()) 223 379 var sizeStr string 224 - if humanReadable { 380 + if opts.humanReadable { 225 381 sizeStr = formatHumanSize(info.Size()) 226 382 } else { 227 383 sizeStr = strconv.FormatInt(info.Size(), 10) 228 384 } 229 385 sizeStr = padLeft(sizeStr, 5) 230 - return fmt.Sprintf("%s 1 user user %s %s %s%s", mode, sizeStr, formatDate(info.ModTime()), name, suffix) 386 + 387 + var ownerField, groupField string 388 + if opts.numericIDs { 389 + // billy doesn't expose UID/GID; fall back to 0. 390 + ownerField = "0" 391 + groupField = "0" 392 + } else { 393 + ownerField = "user" 394 + // Match historical kefka output: same as owner. Real ls would 395 + // resolve via getgrgid; billy doesn't expose gid. 396 + groupField = "user" 397 + } 398 + 399 + displayName := name 400 + if opts.hideControl { 401 + displayName = hideControlChars(displayName) 402 + } 403 + 404 + nlink := nlinkOf(info) 405 + t := timeFor(info, opts) 406 + 407 + if opts.longNoGroup { 408 + // -o: omit group column. 409 + return fmt.Sprintf("%s %d %s %s %s %s%s", mode, nlink, ownerField, sizeStr, formatDate(t), displayName, suffix) 410 + } 411 + return fmt.Sprintf("%s %d %s %s %s %s %s%s", mode, nlink, ownerField, groupField, sizeStr, formatDate(t), displayName, suffix) 412 + } 413 + 414 + // inodePrefix returns the inode column for -i. billy doesn't expose inodes, 415 + // so we use '?' to be honest about that. Width 1, single space separator. 416 + func inodePrefix(opts lsOptions) string { 417 + if !opts.showInode { 418 + return "" 419 + } 420 + return "? " 421 + } 422 + 423 + // blockPrefix returns the -s block-count column. billy doesn't track 424 + // st_blocks, so we approximate from logical size, rounded up to blockSize. 425 + func blockPrefix(info fs.FileInfo, opts lsOptions) string { 426 + if !opts.showBlocks || info == nil { 427 + return "" 428 + } 429 + return strconv.FormatInt(opts.blocksFor(info.Size()), 10) + " " 430 + } 431 + 432 + // totalBlocks sums the rounded block counts for the given paths. 433 + func totalBlocks(ec *command.ExecContext, dir string, names []string, opts lsOptions) int64 { 434 + var total int64 435 + for _, name := range names { 436 + var p string 437 + switch name { 438 + case ".": 439 + p = dir 440 + case "..": 441 + p = path.Dir(dir) 442 + if p == "" { 443 + p = "." 444 + } 445 + default: 446 + p = path.Join(dir, name) 447 + } 448 + info, err := ec.FS.Stat(p) 449 + if err != nil { 450 + continue 451 + } 452 + total += opts.blocksFor(info.Size()) 453 + } 454 + return total 455 + } 456 + 457 + // shortSuffix computes the trailing indicator for a non-long entry given the 458 + // active flags. -F wins over -p; -p only adds slashes for directories. 459 + func shortSuffix(info fs.FileInfo, opts lsOptions) string { 460 + if info == nil { 461 + return "" 462 + } 463 + if opts.classifyFiles { 464 + return classifySuffix(info) 465 + } 466 + if opts.slashDirs && info.IsDir() { 467 + return "/" 468 + } 469 + return "" 470 + } 471 + 472 + // formatNameForOutput applies -q hiding and -F/-p suffixing to a bare entry 473 + // name, producing the string ls should write for non-long modes. 474 + func formatNameForOutput(name string, info fs.FileInfo, opts lsOptions) string { 475 + display := name 476 + if opts.hideControl { 477 + display = hideControlChars(display) 478 + } 479 + return display + shortSuffix(info, opts) 480 + } 481 + 482 + // formatMode renders an os.FileMode as a 10-char string like "drwxrwxrwt", 483 + // matching GNU ls 9.x: type prefix + perms with suid/sgid/sticky overlays. 484 + func formatMode(m fs.FileMode) string { 485 + b := []byte("----------") 486 + switch { 487 + case m&fs.ModeDir != 0: 488 + b[0] = 'd' 489 + case m&fs.ModeSymlink != 0: 490 + b[0] = 'l' 491 + case m&fs.ModeNamedPipe != 0: 492 + b[0] = 'p' 493 + case m&fs.ModeSocket != 0: 494 + b[0] = 's' 495 + case m&fs.ModeDevice != 0: 496 + if m&fs.ModeCharDevice != 0 { 497 + b[0] = 'c' 498 + } else { 499 + b[0] = 'b' 500 + } 501 + case m&fs.ModeCharDevice != 0: 502 + b[0] = 'c' 503 + default: 504 + b[0] = '-' 505 + } 506 + 507 + perm := m.Perm() 508 + const rwx = "rwxrwxrwx" 509 + for i := 0; i < 9; i++ { 510 + if perm&(1<<uint(8-i)) != 0 { 511 + b[i+1] = rwx[i] 512 + } 513 + } 514 + 515 + if m&fs.ModeSetuid != 0 { 516 + if b[3] == 'x' { 517 + b[3] = 's' 518 + } else { 519 + b[3] = 'S' 520 + } 521 + } 522 + if m&fs.ModeSetgid != 0 { 523 + if b[6] == 'x' { 524 + b[6] = 's' 525 + } else { 526 + b[6] = 'S' 527 + } 528 + } 529 + if m&fs.ModeSticky != 0 { 530 + if b[9] == 'x' { 531 + b[9] = 't' 532 + } else { 533 + b[9] = 'T' 534 + } 535 + } 536 + return string(b) 231 537 } 232 538 233 539 func reverseStrings(s []string) { ··· 236 542 } 237 543 } 238 544 239 - func listDirectoryEntry(ec *command.ExecContext, p string, longFormat, humanReadable, classifyFiles bool) (string, string, int) { 545 + func listDirectoryEntry(ec *command.ExecContext, p string, opts lsOptions) (string, string, int) { 240 546 full := resolvePath(ec, p) 241 547 info, err := ec.FS.Stat(full) 242 548 if err != nil { 243 549 return "", fmt.Sprintf("ls: cannot access '%s': No such file or directory\n", p), 2 244 550 } 245 - if longFormat { 551 + li, _ := lstatFS(ec.FS, full) 552 + if li == nil { 553 + li = info 554 + } 555 + 556 + if opts.longFormat { 246 557 suffix := "" 247 - if classifyFiles { 248 - if li, lerr := lstatFS(ec.FS, full); lerr == nil { 249 - suffix = classifySuffix(li) 250 - } 558 + if opts.classifyFiles { 559 + suffix = classifySuffix(li) 251 560 } else if info.IsDir() { 252 561 suffix = "/" 253 562 } 254 - return longFormatLine(info, p, suffix, humanReadable) + "\n", "", 0 255 - } 256 - suffix := "" 257 - if classifyFiles { 258 - if li, lerr := lstatFS(ec.FS, full); lerr == nil { 259 - suffix = classifySuffix(li) 260 - } 563 + var prefix strings.Builder 564 + prefix.WriteString(inodePrefix(opts)) 565 + prefix.WriteString(blockPrefix(info, opts)) 566 + return prefix.String() + longFormatLine(info, p, suffix, opts) + "\n", "", 0 261 567 } 262 - return p + suffix + "\n", "", 0 568 + 569 + displayName := formatNameForOutput(p, li, opts) 570 + var line strings.Builder 571 + line.WriteString(inodePrefix(opts)) 572 + line.WriteString(blockPrefix(info, opts)) 573 + line.WriteString(displayName) 574 + line.WriteString("\n") 575 + return line.String(), "", 0 263 576 } 264 577 265 - func listGlob(ec *command.ExecContext, pattern string, showAll, showAlmostAll, longFormat, reverse, humanReadable, sortBySize, classifyFiles bool) (string, string, int) { 266 - showHidden := showAll || showAlmostAll 578 + func listGlob(ec *command.ExecContext, pattern string, opts lsOptions) (string, string, int) { 579 + showHidden := opts.showAll || opts.showAlmostAll 267 580 dir := ec.Dir 268 581 if dir == "" { 269 582 dir = "." ··· 304 617 return "", fmt.Sprintf("ls: %s: No such file or directory\n", pattern), 2 305 618 } 306 619 307 - if sortBySize { 620 + switch { 621 + case opts.noSort: 622 + // -f: leave glob order as returned by the matcher. 623 + case opts.sortByTime: 624 + mtimes := make(map[string]time.Time, len(displayPaths)) 625 + for _, m := range displayPaths { 626 + if info, e := ec.FS.Stat(resolvePath(ec, m)); e == nil { 627 + mtimes[m] = info.ModTime() 628 + } 629 + } 630 + sort.SliceStable(displayPaths, func(i, j int) bool { 631 + return mtimes[displayPaths[i]].After(mtimes[displayPaths[j]]) 632 + }) 633 + case opts.sortBySize: 308 634 sizes := make(map[string]int64, len(displayPaths)) 309 635 for _, m := range displayPaths { 310 636 if info, e := ec.FS.Stat(resolvePath(ec, m)); e == nil { ··· 314 640 sort.SliceStable(displayPaths, func(i, j int) bool { 315 641 return sizes[displayPaths[i]] > sizes[displayPaths[j]] 316 642 }) 317 - } else { 643 + default: 318 644 sort.Strings(displayPaths) 319 645 } 320 - if reverse { 646 + if opts.reverse { 321 647 reverseStrings(displayPaths) 322 648 } 323 649 324 650 var out, errOut strings.Builder 325 651 exitCode := 0 326 652 327 - if longFormat { 653 + if opts.longFormat { 328 654 for _, m := range displayPaths { 329 655 full := resolvePath(ec, m) 330 656 info, statErr := ec.FS.Stat(full) ··· 334 660 continue 335 661 } 336 662 suffix := "" 337 - if classifyFiles { 663 + if opts.classifyFiles { 338 664 if li, lerr := lstatFS(ec.FS, full); lerr == nil { 339 665 suffix = classifySuffix(li) 340 666 } 667 + } else if opts.slashDirs && info.IsDir() { 668 + suffix = "/" 341 669 } else if info.IsDir() { 342 670 suffix = "/" 343 671 } 344 - out.WriteString(longFormatLine(info, m, suffix, humanReadable)) 672 + out.WriteString(inodePrefix(opts)) 673 + out.WriteString(blockPrefix(info, opts)) 674 + out.WriteString(longFormatLine(info, m, suffix, opts)) 345 675 out.WriteString("\n") 346 676 } 347 677 return out.String(), errOut.String(), exitCode 348 678 } 349 679 350 - if classifyFiles { 351 - classified := make([]string, len(displayPaths)) 352 - for i, m := range displayPaths { 353 - full := resolvePath(ec, m) 354 - suffix := "" 355 - if li, lerr := lstatFS(ec.FS, full); lerr == nil { 356 - suffix = classifySuffix(li) 357 - } 358 - classified[i] = m + suffix 680 + // Build display lines: each entry gets optional inode/block prefix and 681 + // classify/slash suffix. 682 + formatted := make([]string, 0, len(displayPaths)) 683 + for _, m := range displayPaths { 684 + full := resolvePath(ec, m) 685 + info, _ := ec.FS.Stat(full) 686 + li, _ := lstatFS(ec.FS, full) 687 + if li == nil { 688 + li = info 359 689 } 360 - return strings.Join(classified, "\n") + "\n", "", 0 690 + var line strings.Builder 691 + line.WriteString(inodePrefix(opts)) 692 + line.WriteString(blockPrefix(info, opts)) 693 + line.WriteString(formatNameForOutput(m, li, opts)) 694 + formatted = append(formatted, line.String()) 361 695 } 362 696 363 - return strings.Join(displayPaths, "\n") + "\n", "", 0 697 + if opts.commaList { 698 + return strings.Join(formatted, ", ") + "\n", "", 0 699 + } 700 + return strings.Join(formatted, "\n") + "\n", "", 0 364 701 } 365 702 366 - func listPath(ctx context.Context, ec *command.ExecContext, p string, showAll, showAlmostAll, longFormat, recursive, showHeader, reverse, humanReadable, sortBySize, classifyFiles bool) (string, string, int) { 367 - showHidden := showAll || showAlmostAll 703 + func listPath(ctx context.Context, ec *command.ExecContext, p string, opts lsOptions) (string, string, int) { 704 + showHidden := opts.showAll || opts.showAlmostAll 368 705 full := resolvePath(ec, p) 369 706 370 707 info, err := ec.FS.Stat(full) ··· 373 710 } 374 711 375 712 if !info.IsDir() { 376 - suffix := "" 377 - if classifyFiles { 378 - if li, lerr := lstatFS(ec.FS, full); lerr == nil { 713 + li, _ := lstatFS(ec.FS, full) 714 + if li == nil { 715 + li = info 716 + } 717 + if opts.longFormat { 718 + suffix := "" 719 + if opts.classifyFiles { 379 720 suffix = classifySuffix(li) 380 721 } 722 + var prefix strings.Builder 723 + prefix.WriteString(inodePrefix(opts)) 724 + prefix.WriteString(blockPrefix(info, opts)) 725 + return prefix.String() + longFormatLine(info, p, suffix, opts) + "\n", "", 0 381 726 } 382 - if longFormat { 383 - return longFormatLine(info, p, suffix, humanReadable) + "\n", "", 0 384 - } 385 - return p + suffix + "\n", "", 0 727 + var line strings.Builder 728 + line.WriteString(inodePrefix(opts)) 729 + line.WriteString(blockPrefix(info, opts)) 730 + line.WriteString(formatNameForOutput(p, li, opts)) 731 + line.WriteString("\n") 732 + return line.String(), "", 0 386 733 } 387 734 388 735 dirEntries, err := ec.FS.ReadDir(full) ··· 401 748 entryByName[name] = e 402 749 } 403 750 404 - if sortBySize { 751 + switch { 752 + case opts.noSort: 753 + // -f: leave directory order as returned by the filesystem. 754 + case opts.sortByTime: 755 + mtimes := make(map[string]time.Time, len(names)) 756 + for _, name := range names { 757 + if einfo, e := ec.FS.Stat(path.Join(full, name)); e == nil { 758 + mtimes[name] = einfo.ModTime() 759 + } 760 + } 761 + sort.SliceStable(names, func(i, j int) bool { 762 + return mtimes[names[i]].After(mtimes[names[j]]) 763 + }) 764 + case opts.sortBySize: 405 765 sizes := make(map[string]int64, len(names)) 406 766 for _, name := range names { 407 767 if einfo, e := ec.FS.Stat(path.Join(full, name)); e == nil { ··· 411 771 sort.SliceStable(names, func(i, j int) bool { 412 772 return sizes[names[i]] > sizes[names[j]] 413 773 }) 414 - } else { 774 + default: 415 775 sort.Strings(names) 416 776 } 417 777 418 - if showAll { 778 + if opts.showAll { 419 779 names = append([]string{".", ".."}, names...) 420 780 } 421 781 422 - if reverse { 782 + if opts.reverse { 423 783 reverseStrings(names) 424 784 } 425 785 426 786 var out, errOut strings.Builder 427 787 exitCode := 0 428 788 429 - if recursive || showHeader { 789 + if opts.recursive || opts.showHeader { 430 790 out.WriteString(p) 431 791 out.WriteString(":\n") 432 792 } 433 793 434 794 switch { 435 - case longFormat: 436 - fmt.Fprintf(&out, "total %d\n", len(names)) 795 + case opts.longFormat: 796 + // Long format prints "total N" header where N is the sum of blocks 797 + // for the listed entries, in 512-byte (or 1024 with -k) units. 798 + total := totalBlocks(ec, full, names, opts) 799 + fmt.Fprintf(&out, "total %d\n", total) 437 800 for _, name := range names { 438 801 var entryPath string 439 802 switch name { ··· 454 817 continue 455 818 } 456 819 suffix := "" 457 - if classifyFiles { 820 + if opts.classifyFiles { 458 821 if li, lerr := lstatFS(ec.FS, entryPath); lerr == nil { 459 822 suffix = classifySuffix(li) 460 823 } 461 824 } else if einfo.IsDir() { 462 825 suffix = "/" 463 826 } 464 - out.WriteString(longFormatLine(einfo, name, suffix, humanReadable)) 827 + out.WriteString(inodePrefix(opts)) 828 + out.WriteString(blockPrefix(einfo, opts)) 829 + out.WriteString(longFormatLine(einfo, name, suffix, opts)) 465 830 out.WriteString("\n") 466 831 } 467 832 468 - case classifyFiles: 469 - classified := make([]string, 0, len(names)) 833 + default: 834 + formatted := make([]string, 0, len(names)) 470 835 for _, name := range names { 836 + var entryPath string 837 + switch name { 838 + case ".": 839 + entryPath = full 840 + case "..": 841 + entryPath = path.Dir(full) 842 + if entryPath == "" { 843 + entryPath = "." 844 + } 845 + default: 846 + entryPath = path.Join(full, name) 847 + } 848 + einfo, _ := ec.FS.Stat(entryPath) 849 + li, _ := lstatFS(ec.FS, entryPath) 850 + if li == nil { 851 + li = einfo 852 + } 853 + // "." and ".." are always directories. 471 854 if name == "." || name == ".." { 472 - classified = append(classified, name+"/") 855 + display := name 856 + if opts.hideControl { 857 + display = hideControlChars(display) 858 + } 859 + suffix := "" 860 + if opts.classifyFiles || opts.slashDirs { 861 + suffix = "/" 862 + } 863 + var line strings.Builder 864 + line.WriteString(inodePrefix(opts)) 865 + line.WriteString(blockPrefix(einfo, opts)) 866 + line.WriteString(display) 867 + line.WriteString(suffix) 868 + formatted = append(formatted, line.String()) 473 869 continue 474 870 } 475 - entryPath := path.Join(full, name) 476 - suffix := "" 477 - if li, lerr := lstatFS(ec.FS, entryPath); lerr == nil { 478 - suffix = classifySuffix(li) 479 - } 480 - classified = append(classified, name+suffix) 871 + var line strings.Builder 872 + line.WriteString(inodePrefix(opts)) 873 + line.WriteString(blockPrefix(einfo, opts)) 874 + line.WriteString(formatNameForOutput(name, li, opts)) 875 + formatted = append(formatted, line.String()) 481 876 } 482 - out.WriteString(strings.Join(classified, "\n")) 483 - if len(classified) > 0 { 484 - out.WriteString("\n") 877 + 878 + separator := "\n" 879 + if opts.commaList { 880 + separator = ", " 485 881 } 486 - 487 - default: 488 - out.WriteString(strings.Join(names, "\n")) 489 - if len(names) > 0 { 882 + out.WriteString(strings.Join(formatted, separator)) 883 + if len(formatted) > 0 { 490 884 out.WriteString("\n") 491 885 } 492 886 } 493 887 494 - if recursive { 888 + if opts.recursive { 495 889 var subdirs []string 496 890 for _, name := range names { 497 891 if name == "." || name == ".." { ··· 513 907 } 514 908 } 515 909 sort.Strings(subdirs) 516 - if reverse { 910 + if opts.reverse { 517 911 reverseStrings(subdirs) 518 912 } 519 913 914 + subOpts := opts 915 + subOpts.showHeader = false 520 916 for _, name := range subdirs { 521 917 var subPath string 522 918 if p == "." { ··· 524 920 } else { 525 921 subPath = p + "/" + name 526 922 } 527 - subOut, subErr, subCode := listPath(ctx, ec, subPath, showAll, showAlmostAll, longFormat, recursive, false, reverse, humanReadable, sortBySize, classifyFiles) 923 + subOut, subErr, subCode := listPath(ctx, ec, subPath, subOpts) 528 924 out.WriteString("\n") 529 925 out.WriteString(subOut) 530 926 errOut.WriteString(subErr)
+591 -12
command/internal/ls/ls_test.go
··· 12 12 "tangled.org/xeiaso.net/kefka/command" 13 13 ) 14 14 15 + // lsUsageText is the expected ls usage block (everything that comes after the 16 + // program-name prefix in the unknown-option line). Keeping this as one 17 + // string keeps the help/usage tests in sync with the implementation. 18 + const lsUsageText = "Usage: ls [OPTION]... [FILE]...\n" + 19 + "list directory contents\n\n" + 20 + " -a, --all do not ignore entries starting with .\n" + 21 + " -A, --almost-all do not list . and ..\n" + 22 + " -c sort by ctime (best-effort; falls back to mtime)\n" + 23 + " -d, --directory list directories themselves, not their contents\n" + 24 + " -f do not sort, enable -a, disable -l\n" + 25 + " -F, --classify append indicator (one of */=>@) to entries\n" + 26 + " -h, --human-readable with -l, print sizes like 1K 234M 2G etc.\n" + 27 + " -i, --inode print the index number of each file (always '?' here)\n" + 28 + " -k with -s, use 1024-byte blocks\n" + 29 + " -l use a long listing format\n" + 30 + " -m fill width with a comma separated list of entries\n" + 31 + " -n, --numeric-uid-gid like -l but list numeric user and group IDs\n" + 32 + " -o like -l, but do not list group information\n" + 33 + " -p append / indicator to directories\n" + 34 + " -q, --hide-control-chars print ? instead of nongraphic characters\n" + 35 + " -r, --reverse reverse order while sorting\n" + 36 + " -R, --recursive list subdirectories recursively\n" + 37 + " -s, --size print the allocated size of each file, in blocks\n" + 38 + " -S sort by file size, largest first\n" + 39 + " -t sort by time, newest first\n" + 40 + " -u sort by atime (best-effort; falls back to mtime)\n" + 41 + " -1 list one file per line\n" + 42 + " --help display this help and exit\n" 43 + 15 44 func newTestFS() billy.Filesystem { 16 45 fs := memfs.New() 17 46 write := func(name string, data []byte, perm os.FileMode) { ··· 82 111 { 83 112 name: "directory only long uses real stat time", 84 113 args: []string{"-dl", "sub"}, 85 - wantStdout: "drwxr-xr-x 1 user user 0 Jan 15 2020 sub/\n", 114 + wantStdout: "drw-r--r-- 1 user user 0 Jan 15 2020 sub/\n", 86 115 }, 87 116 { 88 117 name: "classify suffixes for dir and executable", ··· 92 121 { 93 122 name: "long format", 94 123 args: []string{"-l"}, 95 - wantStdout: "total 6\n" + 124 + wantStdout: "total 7\n" + 96 125 "-rw-r--r-- 1 user user 4 Jan 15 2020 alpha.txt\n" + 97 126 "-rw-r--r-- 1 user user 8 Jan 15 2020 beta.txt\n" + 98 127 "-rw-r--r-- 1 user user 2 Jan 15 2020 gamma.txt\n" + 99 128 "-rw-r--r-- 1 user user 1500 Jan 15 2020 huge.bin\n" + 100 - "-rw-r--r-- 1 user user 2 Jan 15 2020 script.sh\n" + 101 - "drwxr-xr-x 1 user user 0 Jan 15 2020 sub/\n", 129 + "-rwxr-xr-x 1 user user 2 Jan 15 2020 script.sh\n" + 130 + "drw-r--r-- 1 user user 0 Jan 15 2020 sub/\n", 102 131 }, 103 132 { 104 133 name: "long human readable", 105 134 args: []string{"-lh"}, 106 - wantStdout: "total 6\n" + 135 + wantStdout: "total 7\n" + 107 136 "-rw-r--r-- 1 user user 4 Jan 15 2020 alpha.txt\n" + 108 137 "-rw-r--r-- 1 user user 8 Jan 15 2020 beta.txt\n" + 109 138 "-rw-r--r-- 1 user user 2 Jan 15 2020 gamma.txt\n" + 110 139 "-rw-r--r-- 1 user user 1.5K Jan 15 2020 huge.bin\n" + 111 - "-rw-r--r-- 1 user user 2 Jan 15 2020 script.sh\n" + 112 - "drwxr-xr-x 1 user user 0 Jan 15 2020 sub/\n", 140 + "-rwxr-xr-x 1 user user 2 Jan 15 2020 script.sh\n" + 141 + "drw-r--r-- 1 user user 0 Jan 15 2020 sub/\n", 113 142 }, 114 143 { 115 144 name: "long all uses real stat times for dot entries", 116 145 args: []string{"-la"}, 117 - wantStdout: "total 9\n" + 146 + wantStdout: "total 8\n" + 118 147 "drwxr-xr-x 1 user user 0 Jan 15 2020 ./\n" + 119 148 "drwxr-xr-x 1 user user 0 Jan 15 2020 ../\n" + 120 149 "-rw-r--r-- 1 user user 1 Jan 15 2020 .hidden\n" + ··· 122 151 "-rw-r--r-- 1 user user 8 Jan 15 2020 beta.txt\n" + 123 152 "-rw-r--r-- 1 user user 2 Jan 15 2020 gamma.txt\n" + 124 153 "-rw-r--r-- 1 user user 1500 Jan 15 2020 huge.bin\n" + 125 - "-rw-r--r-- 1 user user 2 Jan 15 2020 script.sh\n" + 126 - "drwxr-xr-x 1 user user 0 Jan 15 2020 sub/\n", 154 + "-rwxr-xr-x 1 user user 2 Jan 15 2020 script.sh\n" + 155 + "drw-r--r-- 1 user user 0 Jan 15 2020 sub/\n", 127 156 }, 128 157 { 129 158 name: "recursive descends into subdirectories", ··· 186 215 { 187 216 name: "unknown flag returns error", 188 217 args: []string{"--no-such-flag"}, 189 - wantStderr: "ls: unknown option: --no-such-flag\nUsage: ls [OPTION]... [FILE]...\nlist directory contents\n\n -a, --all do not ignore entries starting with .\n -A, --almost-all do not list . and ..\n -d, --directory list directories themselves, not their contents\n -F, --classify append indicator (one of */=>@) to entries\n -h, --human-readable with -l, print sizes like 1K 234M 2G etc.\n -l use a long listing format\n -r, --reverse reverse order while sorting\n -R, --recursive list subdirectories recursively\n -S sort by file size, largest first\n -t sort by time, newest first\n -1 list one file per line\n --help display this help and exit\n", 218 + wantStderr: "ls: unknown option: --no-such-flag\n" + lsUsageText, 190 219 wantErr: true, 191 220 }, 192 221 { 193 222 name: "help prints usage to stderr", 194 223 args: []string{"--help"}, 195 - wantStderr: "Usage: ls [OPTION]... [FILE]...\nlist directory contents\n\n -a, --all do not ignore entries starting with .\n -A, --almost-all do not list . and ..\n -d, --directory list directories themselves, not their contents\n -F, --classify append indicator (one of */=>@) to entries\n -h, --human-readable with -l, print sizes like 1K 234M 2G etc.\n -l use a long listing format\n -r, --reverse reverse order while sorting\n -R, --recursive list subdirectories recursively\n -S sort by file size, largest first\n -t sort by time, newest first\n -1 list one file per line\n --help display this help and exit\n", 224 + wantStderr: lsUsageText, 196 225 }, 197 226 } 198 227 ··· 291 320 } 292 321 } 293 322 323 + // statOverlayFS wraps a billy.Filesystem and lets tests override the mode 324 + // or mtime that Stat returns for specific paths. memfs ignores modes after 325 + // creation and does not implement Chtimes, so we simulate both via overlay. 326 + type statOverlayFS struct { 327 + billy.Filesystem 328 + modes map[string]os.FileMode 329 + mtimes map[string]time.Time 330 + } 331 + 332 + func newOverlay(inner billy.Filesystem) *statOverlayFS { 333 + return &statOverlayFS{ 334 + Filesystem: inner, 335 + modes: map[string]os.FileMode{}, 336 + mtimes: map[string]time.Time{}, 337 + } 338 + } 339 + 340 + func (s *statOverlayFS) Stat(name string) (os.FileInfo, error) { 341 + info, err := s.Filesystem.Stat(name) 342 + if err != nil { 343 + return nil, err 344 + } 345 + mode, hasMode := s.modes[name] 346 + mt, hasMt := s.mtimes[name] 347 + if !hasMode && !hasMt { 348 + return info, nil 349 + } 350 + if !hasMode { 351 + mode = info.Mode() 352 + } 353 + if !hasMt { 354 + mt = info.ModTime() 355 + } 356 + return &overlayInfo{FileInfo: info, mode: mode, mtime: mt}, nil 357 + } 358 + 359 + type overlayInfo struct { 360 + os.FileInfo 361 + mode os.FileMode 362 + mtime time.Time 363 + } 364 + 365 + func (o *overlayInfo) Mode() os.FileMode { return o.mode } 366 + func (o *overlayInfo) ModTime() time.Time { return o.mtime } 367 + 368 + func newSortByTimeFS() *statOverlayFS { 369 + fs := memfs.New() 370 + for _, name := range []string{"a.txt", "b.txt", "c.txt"} { 371 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 372 + if err != nil { 373 + panic(err) 374 + } 375 + f.Close() 376 + } 377 + o := newOverlay(fs) 378 + o.mtimes["a.txt"] = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) 379 + o.mtimes["b.txt"] = time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) 380 + o.mtimes["c.txt"] = time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) 381 + return o 382 + } 383 + 384 + func TestExec_SortByTime(t *testing.T) { 385 + withFixedDate(t) 386 + var stdout, stderr bytes.Buffer 387 + ec := &command.ExecContext{ 388 + Stdout: &stdout, 389 + Stderr: &stderr, 390 + Dir: ".", 391 + FS: newSortByTimeFS(), 392 + } 393 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-t"}); err != nil { 394 + t.Fatalf("unexpected error: %v", err) 395 + } 396 + want := "b.txt\nc.txt\na.txt\n" 397 + if got := stdout.String(); got != want { 398 + t.Errorf("ls -t stdout = %q, want %q", got, want) 399 + } 400 + } 401 + 402 + func TestExec_SortByTimeReverse(t *testing.T) { 403 + withFixedDate(t) 404 + var stdout, stderr bytes.Buffer 405 + ec := &command.ExecContext{ 406 + Stdout: &stdout, 407 + Stderr: &stderr, 408 + Dir: ".", 409 + FS: newSortByTimeFS(), 410 + } 411 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-tr"}); err != nil { 412 + t.Fatalf("unexpected error: %v", err) 413 + } 414 + want := "a.txt\nc.txt\nb.txt\n" 415 + if got := stdout.String(); got != want { 416 + t.Errorf("ls -tr stdout = %q, want %q", got, want) 417 + } 418 + } 419 + 420 + func TestExec_OnePerLine(t *testing.T) { 421 + withFixedDate(t) 422 + var stdout, stderr bytes.Buffer 423 + ec := &command.ExecContext{ 424 + Stdout: &stdout, 425 + Stderr: &stderr, 426 + Dir: ".", 427 + FS: newTestFS(), 428 + } 429 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-1"}); err != nil { 430 + t.Fatalf("unexpected error: %v", err) 431 + } 432 + want := "alpha.txt\nbeta.txt\ngamma.txt\nhuge.bin\nscript.sh\nsub\n" 433 + if got := stdout.String(); got != want { 434 + t.Errorf("ls -1 stdout = %q, want %q", got, want) 435 + } 436 + } 437 + 438 + func TestExec_LongFormatSuid(t *testing.T) { 439 + withFixedDate(t) 440 + fs := memfs.New() 441 + f, err := fs.OpenFile("suidfile", os.O_CREATE|os.O_WRONLY, 0o644) 442 + if err != nil { 443 + t.Fatal(err) 444 + } 445 + f.Close() 446 + o := newOverlay(fs) 447 + // 0o4755 with the os.ModeSetuid bit set -> "-rwsr-xr-x" 448 + o.modes["suidfile"] = 0o755 | os.ModeSetuid 449 + 450 + var stdout, stderr bytes.Buffer 451 + ec := &command.ExecContext{ 452 + Stdout: &stdout, 453 + Stderr: &stderr, 454 + Dir: ".", 455 + FS: o, 456 + } 457 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-l", "suidfile"}); err != nil { 458 + t.Fatalf("unexpected error: %v", err) 459 + } 460 + want := "-rwsr-xr-x 1 user user 0 Jan 15 2020 suidfile\n" 461 + if got := stdout.String(); got != want { 462 + t.Errorf("ls -l suidfile stdout = %q, want %q", got, want) 463 + } 464 + } 465 + 466 + func TestExec_LongFormatSuidNoExec(t *testing.T) { 467 + withFixedDate(t) 468 + fs := memfs.New() 469 + f, err := fs.OpenFile("suidfile", os.O_CREATE|os.O_WRONLY, 0o644) 470 + if err != nil { 471 + t.Fatal(err) 472 + } 473 + f.Close() 474 + o := newOverlay(fs) 475 + // setuid set, but owner-x not set -> capital S 476 + o.modes["suidfile"] = 0o644 | os.ModeSetuid 477 + 478 + var stdout, stderr bytes.Buffer 479 + ec := &command.ExecContext{ 480 + Stdout: &stdout, 481 + Stderr: &stderr, 482 + Dir: ".", 483 + FS: o, 484 + } 485 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-l", "suidfile"}); err != nil { 486 + t.Fatalf("unexpected error: %v", err) 487 + } 488 + want := "-rwSr--r-- 1 user user 0 Jan 15 2020 suidfile\n" 489 + if got := stdout.String(); got != want { 490 + t.Errorf("ls -l suidfile (no x) stdout = %q, want %q", got, want) 491 + } 492 + } 493 + 494 + func TestExec_LongFormatSgid(t *testing.T) { 495 + withFixedDate(t) 496 + fs := memfs.New() 497 + f, err := fs.OpenFile("sgidfile", os.O_CREATE|os.O_WRONLY, 0o644) 498 + if err != nil { 499 + t.Fatal(err) 500 + } 501 + f.Close() 502 + o := newOverlay(fs) 503 + o.modes["sgidfile"] = 0o2755 | os.ModeSetgid 504 + 505 + var stdout, stderr bytes.Buffer 506 + ec := &command.ExecContext{ 507 + Stdout: &stdout, 508 + Stderr: &stderr, 509 + Dir: ".", 510 + FS: o, 511 + } 512 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-l", "sgidfile"}); err != nil { 513 + t.Fatalf("unexpected error: %v", err) 514 + } 515 + want := "-rwxr-sr-x 1 user user 0 Jan 15 2020 sgidfile\n" 516 + if got := stdout.String(); got != want { 517 + t.Errorf("ls -l sgidfile stdout = %q, want %q", got, want) 518 + } 519 + } 520 + 521 + func TestExec_LongFormatStickyDir(t *testing.T) { 522 + withFixedDate(t) 523 + fs := memfs.New() 524 + f, err := fs.OpenFile("stickydir/keep", os.O_CREATE|os.O_WRONLY, 0o644) 525 + if err != nil { 526 + t.Fatal(err) 527 + } 528 + f.Close() 529 + o := newOverlay(fs) 530 + // 1777 with ModeDir|ModeSticky -> "drwxrwxrwt" 531 + o.modes["stickydir"] = os.ModeDir | os.ModeSticky | 0o777 532 + 533 + var stdout, stderr bytes.Buffer 534 + ec := &command.ExecContext{ 535 + Stdout: &stdout, 536 + Stderr: &stderr, 537 + Dir: ".", 538 + FS: o, 539 + } 540 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-dl", "stickydir"}); err != nil { 541 + t.Fatalf("unexpected error: %v", err) 542 + } 543 + want := "drwxrwxrwt 1 user user 0 Jan 15 2020 stickydir/\n" 544 + if got := stdout.String(); got != want { 545 + t.Errorf("ls -dl stickydir stdout = %q, want %q", got, want) 546 + } 547 + } 548 + 549 + func TestExec_LongFormatStickyDirNoOtherExec(t *testing.T) { 550 + withFixedDate(t) 551 + fs := memfs.New() 552 + f, err := fs.OpenFile("stickydir/keep", os.O_CREATE|os.O_WRONLY, 0o644) 553 + if err != nil { 554 + t.Fatal(err) 555 + } 556 + f.Close() 557 + o := newOverlay(fs) 558 + // sticky bit but no other-exec -> "T" 559 + o.modes["stickydir"] = os.ModeDir | os.ModeSticky | 0o644 560 + 561 + var stdout, stderr bytes.Buffer 562 + ec := &command.ExecContext{ 563 + Stdout: &stdout, 564 + Stderr: &stderr, 565 + Dir: ".", 566 + FS: o, 567 + } 568 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-dl", "stickydir"}); err != nil { 569 + t.Fatalf("unexpected error: %v", err) 570 + } 571 + want := "drw-r--r-T 1 user user 0 Jan 15 2020 stickydir/\n" 572 + if got := stdout.String(); got != want { 573 + t.Errorf("ls -dl stickydir (no other-x) stdout = %q, want %q", got, want) 574 + } 575 + } 576 + 577 + func TestRealFormatDate_Recent(t *testing.T) { 578 + recent := time.Now().Add(-3 * 24 * time.Hour) 579 + got := realFormatDate(recent) 580 + if len(got) != len("Jan 15 12:34") { 581 + t.Errorf("realFormatDate(recent) = %q, want length 12 (Mon DD HH:MM)", got) 582 + } 583 + month := recent.Month().String()[:3] 584 + if got[:3] != month { 585 + t.Errorf("realFormatDate(recent) = %q, want month prefix %q", got, month) 586 + } 587 + // Recent format must contain a colon (HH:MM). 588 + if !bytes.Contains([]byte(got), []byte(":")) { 589 + t.Errorf("realFormatDate(recent) = %q, expected HH:MM form with colon", got) 590 + } 591 + } 592 + 593 + func TestRealFormatDate_Old(t *testing.T) { 594 + old := time.Now().Add(-365 * 24 * time.Hour) 595 + got := realFormatDate(old) 596 + if len(got) != len("Jan 15 2020") { 597 + t.Errorf("realFormatDate(old) = %q, want length 12 (Mon DD YYYY)", got) 598 + } 599 + // Old format must not contain a colon (year, not HH:MM). 600 + if bytes.Contains([]byte(got), []byte(":")) { 601 + t.Errorf("realFormatDate(old) = %q, expected YYYY form without colon", got) 602 + } 603 + } 604 + 605 + func TestFormatMode(t *testing.T) { 606 + tests := []struct { 607 + name string 608 + mode os.FileMode 609 + want string 610 + }{ 611 + {"plain file 0644", 0o644, "-rw-r--r--"}, 612 + {"plain file 0755", 0o755, "-rwxr-xr-x"}, 613 + {"directory 0755", os.ModeDir | 0o755, "drwxr-xr-x"}, 614 + {"symlink 0777", os.ModeSymlink | 0o777, "lrwxrwxrwx"}, 615 + {"setuid 4755", os.ModeSetuid | 0o755, "-rwsr-xr-x"}, 616 + {"setuid no exec", os.ModeSetuid | 0o644, "-rwSr--r--"}, 617 + {"setgid 2755", os.ModeSetgid | 0o755, "-rwxr-sr-x"}, 618 + {"setgid no group exec", os.ModeSetgid | 0o744, "-rwxr-Sr--"}, 619 + {"sticky dir 1777", os.ModeDir | os.ModeSticky | 0o777, "drwxrwxrwt"}, 620 + {"sticky no other exec", os.ModeDir | os.ModeSticky | 0o644, "drw-r--r-T"}, 621 + {"all special bits", os.ModeDir | os.ModeSetuid | os.ModeSetgid | os.ModeSticky | 0o777, "drwsrwsrwt"}, 622 + } 623 + for _, tc := range tests { 624 + t.Run(tc.name, func(t *testing.T) { 625 + if got := formatMode(tc.mode); got != tc.want { 626 + t.Errorf("formatMode(%v) = %q, want %q", tc.mode, got, tc.want) 627 + } 628 + }) 629 + } 630 + } 631 + 294 632 func TestResolvePath(t *testing.T) { 295 633 tests := []struct { 296 634 dir, in, want string ··· 311 649 } 312 650 } 313 651 } 652 + 653 + // TestExec_NewFlags table-tests the recently wired flags (-m, -p, -i, -s, 654 + // -k, -n, -o, -q, -c, -u) against the standard test FS. These are kept as 655 + // one big table to keep the file scrollable; each case is a single 656 + // invocation with a stable expected stdout. 657 + func TestExec_NewFlags(t *testing.T) { 658 + tests := []struct { 659 + name string 660 + args []string 661 + wantStdout string 662 + }{ 663 + { 664 + name: "comma list", 665 + args: []string{"-m"}, 666 + wantStdout: "alpha.txt, beta.txt, gamma.txt, huge.bin, script.sh, sub\n", 667 + }, 668 + { 669 + name: "one-per-line overrides comma list", 670 + args: []string{"-m1"}, 671 + wantStdout: "alpha.txt\nbeta.txt\ngamma.txt\nhuge.bin\nscript.sh\nsub\n", 672 + }, 673 + { 674 + name: "slash dirs", 675 + args: []string{"-p"}, 676 + wantStdout: "alpha.txt\nbeta.txt\ngamma.txt\nhuge.bin\nscript.sh\nsub/\n", 677 + }, 678 + { 679 + name: "inode column", 680 + args: []string{"-i"}, 681 + wantStdout: "? alpha.txt\n? beta.txt\n? gamma.txt\n? huge.bin\n? script.sh\n? sub\n", 682 + }, 683 + { 684 + name: "block column 512-byte units", 685 + args: []string{"-s"}, 686 + // 4 -> 1, 8 -> 1, 2 -> 1, 1500 -> 3, 2 -> 1, dir -> 0 687 + wantStdout: "1 alpha.txt\n1 beta.txt\n1 gamma.txt\n3 huge.bin\n1 script.sh\n0 sub\n", 688 + }, 689 + { 690 + name: "block column with -k uses 1024-byte units", 691 + args: []string{"-sk"}, 692 + // 4 -> 1, 8 -> 1, 2 -> 1, 1500 -> 2 (ceil(1500/1024)), 2 -> 1, dir -> 0 693 + wantStdout: "1 alpha.txt\n1 beta.txt\n1 gamma.txt\n2 huge.bin\n1 script.sh\n0 sub\n", 694 + }, 695 + { 696 + name: "numeric uid/gid implies -l", 697 + args: []string{"-n"}, 698 + wantStdout: "total 7\n" + 699 + "-rw-r--r-- 1 0 0 4 Jan 15 2020 alpha.txt\n" + 700 + "-rw-r--r-- 1 0 0 8 Jan 15 2020 beta.txt\n" + 701 + "-rw-r--r-- 1 0 0 2 Jan 15 2020 gamma.txt\n" + 702 + "-rw-r--r-- 1 0 0 1500 Jan 15 2020 huge.bin\n" + 703 + "-rwxr-xr-x 1 0 0 2 Jan 15 2020 script.sh\n" + 704 + "drw-r--r-- 1 0 0 0 Jan 15 2020 sub/\n", 705 + }, 706 + { 707 + name: "long without group implies -l", 708 + args: []string{"-o"}, 709 + wantStdout: "total 7\n" + 710 + "-rw-r--r-- 1 user 4 Jan 15 2020 alpha.txt\n" + 711 + "-rw-r--r-- 1 user 8 Jan 15 2020 beta.txt\n" + 712 + "-rw-r--r-- 1 user 2 Jan 15 2020 gamma.txt\n" + 713 + "-rw-r--r-- 1 user 1500 Jan 15 2020 huge.bin\n" + 714 + "-rwxr-xr-x 1 user 2 Jan 15 2020 script.sh\n" + 715 + "drw-r--r-- 1 user 0 Jan 15 2020 sub/\n", 716 + }, 717 + { 718 + name: "ctime falls back to mtime ordering", 719 + args: []string{"-c"}, 720 + wantStdout: "alpha.txt\nbeta.txt\ngamma.txt\nhuge.bin\nscript.sh\nsub\n", 721 + }, 722 + { 723 + name: "atime falls back to mtime ordering", 724 + args: []string{"-u"}, 725 + wantStdout: "alpha.txt\nbeta.txt\ngamma.txt\nhuge.bin\nscript.sh\nsub\n", 726 + }, 727 + } 728 + for _, tc := range tests { 729 + t.Run(tc.name, func(t *testing.T) { 730 + withFixedDate(t) 731 + var stdout, stderr bytes.Buffer 732 + ec := &command.ExecContext{ 733 + Stdout: &stdout, 734 + Stderr: &stderr, 735 + Dir: ".", 736 + FS: newTestFS(), 737 + } 738 + if err := (Impl{}).Exec(context.Background(), ec, tc.args); err != nil { 739 + t.Fatalf("unexpected error: %v", err) 740 + } 741 + if got := stdout.String(); got != tc.wantStdout { 742 + t.Errorf("stdout = %q, want %q", got, tc.wantStdout) 743 + } 744 + }) 745 + } 746 + } 747 + 748 + // TestHideControlChars verifies that -q replaces non-printable bytes with 749 + // '?'. We feed a name with a literal newline and tab; both are non-printable 750 + // in the spec sense and should become '?'. 751 + func TestHideControlChars(t *testing.T) { 752 + tests := []struct { 753 + in, want string 754 + }{ 755 + {"plain.txt", "plain.txt"}, 756 + {"a\tb", "a?b"}, 757 + {"a\nb", "a?b"}, 758 + {"a\x00b", "a?b"}, 759 + {"a\x7fb", "a?b"}, 760 + {" spaces ok ", " spaces ok "}, 761 + } 762 + for _, tc := range tests { 763 + t.Run(tc.in, func(t *testing.T) { 764 + if got := hideControlChars(tc.in); got != tc.want { 765 + t.Errorf("hideControlChars(%q) = %q, want %q", tc.in, got, tc.want) 766 + } 767 + }) 768 + } 769 + } 770 + 771 + // TestBlocksFor exercises the 512/1024 byte rounding in lsOptions. 772 + func TestBlocksFor(t *testing.T) { 773 + tests := []struct { 774 + name string 775 + size int64 776 + k bool 777 + want int64 778 + }{ 779 + {"empty 512", 0, false, 0}, 780 + {"1 byte 512", 1, false, 1}, 781 + {"512 bytes 512", 512, false, 1}, 782 + {"513 bytes 512", 513, false, 2}, 783 + {"1500 bytes 512", 1500, false, 3}, 784 + {"empty 1024", 0, true, 0}, 785 + {"1 byte 1024", 1, true, 1}, 786 + {"1024 bytes 1024", 1024, true, 1}, 787 + {"1025 bytes 1024", 1025, true, 2}, 788 + {"1500 bytes 1024", 1500, true, 2}, 789 + } 790 + for _, tc := range tests { 791 + t.Run(tc.name, func(t *testing.T) { 792 + opts := lsOptions{kBytes: tc.k} 793 + if got := opts.blocksFor(tc.size); got != tc.want { 794 + t.Errorf("blocksFor(size=%d, k=%v) = %d, want %d", tc.size, tc.k, got, tc.want) 795 + } 796 + }) 797 + } 798 + } 799 + 800 + // TestExec_NoSortFlag exercises -f: disables sorting, enables -a, and 801 + // suppresses long format. The exact order is whatever the filesystem 802 + // returns, so we assert that all entries (including dot files) are 803 + // present rather than pinning to one ordering. 804 + func TestExec_NoSortFlag(t *testing.T) { 805 + withFixedDate(t) 806 + var stdout, stderr bytes.Buffer 807 + ec := &command.ExecContext{ 808 + Stdout: &stdout, 809 + Stderr: &stderr, 810 + Dir: ".", 811 + FS: newTestFS(), 812 + } 813 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-f"}); err != nil { 814 + t.Fatalf("unexpected error: %v", err) 815 + } 816 + out := stdout.String() 817 + // -f implies -a, so .hidden must be in the output even though we did 818 + // not pass -a explicitly. 819 + for _, name := range []string{".hidden", "alpha.txt", "beta.txt", "gamma.txt", "huge.bin", "script.sh", "sub"} { 820 + if !bytes.Contains([]byte(out), []byte(name+"\n")) { 821 + t.Errorf("ls -f stdout missing %q\nfull output:\n%s", name, out) 822 + } 823 + } 824 + } 825 + 826 + // TestExec_NoSortSuppressesLong verifies that -f overrides -l, matching 827 + // GNU ls's documented behavior of forcing short format under -f. 828 + func TestExec_NoSortSuppressesLong(t *testing.T) { 829 + withFixedDate(t) 830 + var stdout, stderr bytes.Buffer 831 + ec := &command.ExecContext{ 832 + Stdout: &stdout, 833 + Stderr: &stderr, 834 + Dir: ".", 835 + FS: newTestFS(), 836 + } 837 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-fl"}); err != nil { 838 + t.Fatalf("unexpected error: %v", err) 839 + } 840 + out := stdout.String() 841 + // "total " is only printed in long mode; -f must have suppressed it. 842 + if bytes.Contains([]byte(out), []byte("total ")) { 843 + t.Errorf("ls -fl should not produce a 'total' line, got:\n%s", out) 844 + } 845 + // And mode strings like "-rw-r--r--" should be absent. 846 + if bytes.Contains([]byte(out), []byte("-rw-r--r--")) { 847 + t.Errorf("ls -fl should not produce long-format mode strings, got:\n%s", out) 848 + } 849 + } 850 + 851 + // TestExec_NoSortOverridesT verifies -f beats -t (sort by time). 852 + func TestExec_NoSortOverridesT(t *testing.T) { 853 + withFixedDate(t) 854 + var stdout, stderr bytes.Buffer 855 + ec := &command.ExecContext{ 856 + Stdout: &stdout, 857 + Stderr: &stderr, 858 + Dir: ".", 859 + FS: newSortByTimeFS(), 860 + } 861 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-ft"}); err != nil { 862 + t.Fatalf("unexpected error: %v", err) 863 + } 864 + // All three names must appear; we don't assert ordering since -f 865 + // disables sorting and uses fs-native order. 866 + out := stdout.String() 867 + for _, name := range []string{"a.txt", "b.txt", "c.txt"} { 868 + if !bytes.Contains([]byte(out), []byte(name)) { 869 + t.Errorf("ls -ft stdout missing %q\nfull output:\n%s", name, out) 870 + } 871 + } 872 + } 873 + 874 + // TestExec_LongInodeAndBlocks combines -lis to confirm the inode and block 875 + // columns appear before the long-format line for each entry. 876 + func TestExec_LongInodeAndBlocks(t *testing.T) { 877 + withFixedDate(t) 878 + var stdout, stderr bytes.Buffer 879 + ec := &command.ExecContext{ 880 + Stdout: &stdout, 881 + Stderr: &stderr, 882 + Dir: ".", 883 + FS: newTestFS(), 884 + } 885 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-lis", "alpha.txt"}); err != nil { 886 + t.Fatalf("unexpected error: %v", err) 887 + } 888 + want := "? 1 -rw-r--r-- 1 user user 4 Jan 15 2020 alpha.txt\n" 889 + if got := stdout.String(); got != want { 890 + t.Errorf("ls -lis alpha.txt stdout = %q, want %q", got, want) 891 + } 892 + }