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(rm): add -i interactive, -I less-interactive, -v

Implements GNU coreutils compatibility for rm:

- -i/--interactive prompt before each removal
- -I (less-interactive) prompt once before removing 3+
files or recursing; negative answer aborts all removals
- -v/--verbose
- Last-flag-wins logic across -f / -i / -I

Symlinks are not traversed during recursion (manual walk
via lstat). Without -f and stdin is attached and the file
is not writable, the user is prompted.

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

Xe Iaso d1d72391 7103f35d

+913 -42
+371 -41
command/internal/rm/rm.go
··· 1 1 package rm 2 2 3 3 import ( 4 + "bufio" 4 5 "context" 5 6 "errors" 6 7 "fmt" 7 8 "io" 9 + "io/fs" 8 10 "os" 9 11 "path" 10 12 "strings" 11 13 12 - "github.com/go-git/go-billy/v5/util" 14 + "github.com/go-git/go-billy/v5" 13 15 "github.com/pborman/getopt/v2" 14 16 "mvdan.cc/sh/v3/interp" 15 17 "tangled.org/xeiaso.net/kefka/command" ··· 41 43 usage := func() { 42 44 fmt.Fprint(stderr, "Usage: rm [OPTION]... FILE...\n") 43 45 fmt.Fprint(stderr, "Remove (unlink) the FILE(s).\n\n") 44 - fmt.Fprint(stderr, " -f, --force ignore nonexistent files and arguments, never prompt\n") 46 + fmt.Fprint(stderr, " -f, --force ignore nonexistent files and arguments, never prompt\n") 47 + fmt.Fprint(stderr, " -i, --interactive prompt before every removal\n") 48 + fmt.Fprint(stderr, " -I prompt once before removing more than three files,\n") 49 + fmt.Fprint(stderr, " or when removing recursively; less intrusive than -i\n") 50 + fmt.Fprint(stderr, " -d, --dir remove empty directories\n") 45 51 fmt.Fprint(stderr, " -r, -R, --recursive remove directories and their contents recursively\n") 46 - fmt.Fprint(stderr, " -v, --verbose explain what is being done\n") 47 - fmt.Fprint(stderr, " --help display this help and exit\n") 52 + fmt.Fprint(stderr, " -v, --verbose explain what is being done\n") 53 + fmt.Fprint(stderr, " --help display this help and exit\n") 48 54 } 49 55 set.SetUsage(usage) 50 56 51 57 recursive := set.BoolLong("recursive", 'r', "remove directories and their contents recursively") 52 58 recursiveUpper := set.Bool('R', "remove directories and their contents recursively") 53 59 force := set.BoolLong("force", 'f', "ignore nonexistent files and arguments, never prompt") 60 + interactive := set.BoolLong("interactive", 'i', "prompt before every removal") 61 + lessInteractive := set.Bool('I', "prompt once before removing more than three files, or when removing recursively") 62 + emptyDir := set.BoolLong("dir", 'd', "remove empty directories") 54 63 verbose := set.BoolLong("verbose", 'v', "explain what is being done") 55 64 help := set.BoolLong("help", 0, "display this help and exit") 56 65 57 - if err := set.Getopt(append([]string{"rm"}, args...), nil); err != nil { 66 + // Track which of -f / -i / -I was specified last for "last one wins" 67 + // semantics per POSIX (each suppresses previous occurrences of the others). 68 + lastMode := "" // "force", "interactive", "less", or "" 69 + cb := func(opt getopt.Option) bool { 70 + switch { 71 + case opt.LongName() == "force" || opt.ShortName() == "f": 72 + lastMode = "force" 73 + case opt.LongName() == "interactive" || opt.ShortName() == "i": 74 + lastMode = "interactive" 75 + case opt.ShortName() == "I": 76 + lastMode = "less" 77 + } 78 + return true 79 + } 80 + 81 + if err := set.Getopt(append([]string{"rm"}, args...), cb); err != nil { 58 82 fmt.Fprintf(stderr, "rm: %s\n", err) 59 83 usage() 60 84 return interp.ExitStatus(1) ··· 65 89 } 66 90 67 91 recurse := *recursive || *recursiveUpper 92 + effForce := *force 93 + effInteractive := *interactive 94 + effLess := *lessInteractive 95 + switch lastMode { 96 + case "force": 97 + effInteractive = false 98 + effLess = false 99 + case "interactive": 100 + effForce = false 101 + effLess = false 102 + case "less": 103 + effForce = false 104 + effInteractive = false 105 + } 106 + 68 107 paths := set.Args() 69 108 70 109 if len(paths) == 0 { 71 - if *force { 110 + if effForce { 72 111 return nil 73 112 } 74 113 fmt.Fprint(stderr, "rm: missing operand\n") 75 114 return interp.ExitStatus(1) 76 115 } 77 116 117 + var stdinReader *bufio.Reader 118 + if ec.Stdin != nil { 119 + stdinReader = bufio.NewReader(ec.Stdin) 120 + } 121 + 122 + pr := &prompter{ 123 + stderr: stderr, 124 + stdin: stdinReader, 125 + interactive: effInteractive, 126 + } 127 + 128 + // -I: one prompt up front when recursing or when removing 3+ operands. 129 + // A negative answer suppresses all removals. -f/-i wins over -I (handled 130 + // above via lastMode). 131 + if effLess && pr.stdin != nil { 132 + var msg string 133 + switch { 134 + case recurse: 135 + msg = fmt.Sprintf("rm: remove %d argument%s recursively? ", len(paths), plural(len(paths))) 136 + case len(paths) > 3: 137 + msg = fmt.Sprintf("rm: remove %d arguments? ", len(paths)) 138 + } 139 + if msg != "" && !pr.ask(msg) { 140 + return nil 141 + } 142 + } 143 + 78 144 exitCode := 0 79 145 for _, p := range paths { 80 - full := resolvePath(ec, p) 81 - info, err := ec.FS.Stat(full) 82 - if err != nil { 83 - if !*force { 84 - if errors.Is(err, os.ErrNotExist) { 85 - fmt.Fprintf(stderr, "rm: cannot remove '%s': No such file or directory\n", p) 86 - } else { 87 - fmt.Fprintf(stderr, "rm: cannot remove '%s': %s\n", p, err) 88 - } 89 - exitCode = 1 146 + if err := removeOne(ec, stdout, stderr, p, recurse, *emptyDir, effForce, pr, *verbose); err != nil { 147 + exitCode = 1 148 + } 149 + } 150 + 151 + if exitCode != 0 { 152 + return interp.ExitStatus(uint8(exitCode)) 153 + } 154 + return nil 155 + } 156 + 157 + // prompter centralizes confirmation reads so that we never read more from 158 + // stdin than necessary and never block when no stdin is attached. 159 + type prompter struct { 160 + stderr io.Writer 161 + stdin *bufio.Reader 162 + interactive bool 163 + } 164 + 165 + // shouldPrompt reports whether a prompt is required for a non-directory file 166 + // per POSIX rm step 2b/3: prompt when -i is set, or when -f is not set AND 167 + // the file is not writable AND stdin is a terminal. kefka has no terminal 168 + // detection, so we approximate "stdin is a terminal" as "stdin is attached" 169 + // for the write-protected case. -f always wins. 170 + func (p *prompter) shouldPrompt(force, writeProtected bool) bool { 171 + if force { 172 + return false 173 + } 174 + if p.interactive { 175 + return true 176 + } 177 + if writeProtected && p.stdin != nil { 178 + return true 179 + } 180 + return false 181 + } 182 + 183 + // ask writes msg to stderr and reads a response. Returns true only when the 184 + // first non-whitespace character is 'y' or 'Y'. EOF / no stdin / blank line 185 + // all count as a non-affirmative response. 186 + func (p *prompter) ask(msg string) bool { 187 + fmt.Fprint(p.stderr, msg) 188 + if p.stdin == nil { 189 + return false 190 + } 191 + line, err := p.stdin.ReadString('\n') 192 + if err != nil && line == "" { 193 + return false 194 + } 195 + line = strings.TrimLeft(line, " \t") 196 + if line == "" { 197 + return false 198 + } 199 + c := line[0] 200 + return c == 'y' || c == 'Y' 201 + } 202 + 203 + func removeOne(ec *command.ExecContext, stdout, stderr io.Writer, p string, recurse, emptyDir, force bool, pr *prompter, verbose bool) error { 204 + full := resolvePath(ec, p) 205 + info, err := lstat(ec.FS, full) 206 + if err != nil { 207 + if !force { 208 + if errors.Is(err, os.ErrNotExist) { 209 + fmt.Fprintf(stderr, "rm: cannot remove '%s': No such file or directory\n", p) 210 + } else { 211 + fmt.Fprintf(stderr, "rm: cannot remove '%s': %s\n", p, err) 90 212 } 91 - continue 213 + return err 92 214 } 215 + return nil 216 + } 93 217 94 - if info.IsDir() && !recurse { 218 + // Symlinks are removed as-is, never followed. POSIX rm.md step 2c. 219 + if info.Mode()&fs.ModeSymlink != 0 { 220 + return removeFile(ec, stdout, stderr, p, full, info, force, pr, verbose) 221 + } 222 + 223 + if info.IsDir() { 224 + if !recurse { 225 + if emptyDir { 226 + return removeEmptyDir(ec, stdout, stderr, p, full, info, force, pr, verbose) 227 + } 95 228 fmt.Fprintf(stderr, "rm: cannot remove '%s': Is a directory\n", p) 96 - exitCode = 1 97 - continue 229 + return errors.New("is a directory") 98 230 } 231 + return removeDir(ec, stdout, stderr, p, full, info, force, pr, verbose) 232 + } 99 233 100 - var rmErr error 101 - if recurse { 102 - rmErr = util.RemoveAll(ec.FS, full) 103 - } else { 104 - rmErr = ec.FS.Remove(full) 234 + return removeFile(ec, stdout, stderr, p, full, info, force, pr, verbose) 235 + } 236 + 237 + func removeFile(ec *command.ExecContext, stdout, stderr io.Writer, displayPath, full string, info fs.FileInfo, force bool, pr *prompter, verbose bool) error { 238 + wp := isWriteProtected(info) 239 + if pr.shouldPrompt(force, wp) { 240 + msg := fileRemovePrompt(displayPath, info, wp) 241 + if !pr.ask(msg) { 242 + return nil 243 + } 244 + } 245 + if err := ec.FS.Remove(full); err != nil { 246 + if !force { 247 + fmt.Fprintf(stderr, "rm: cannot remove '%s': %s\n", displayPath, formatErr(err)) 248 + return err 105 249 } 106 - if rmErr != nil { 107 - if !*force { 108 - switch { 109 - case errors.Is(rmErr, os.ErrNotExist): 110 - fmt.Fprintf(stderr, "rm: cannot remove '%s': No such file or directory\n", p) 111 - case isNotEmpty(rmErr): 112 - fmt.Fprintf(stderr, "rm: cannot remove '%s': Directory not empty\n", p) 113 - default: 114 - fmt.Fprintf(stderr, "rm: cannot remove '%s': %s\n", p, rmErr) 115 - } 116 - exitCode = 1 250 + return nil 251 + } 252 + if verbose { 253 + fmt.Fprintf(stdout, "removed '%s'\n", displayPath) 254 + } 255 + return nil 256 + } 257 + 258 + // removeEmptyDir handles -d (GNU extension): remove a single empty directory 259 + // without -r. 260 + func removeEmptyDir(ec *command.ExecContext, stdout, stderr io.Writer, displayPath, full string, info fs.FileInfo, force bool, pr *prompter, verbose bool) error { 261 + entries, err := ec.FS.ReadDir(full) 262 + if err != nil { 263 + if !force { 264 + fmt.Fprintf(stderr, "rm: cannot remove '%s': %s\n", displayPath, formatErr(err)) 265 + return err 266 + } 267 + return nil 268 + } 269 + if len(entries) > 0 { 270 + fmt.Fprintf(stderr, "rm: cannot remove '%s': Directory not empty\n", displayPath) 271 + return errors.New("directory not empty") 272 + } 273 + wp := isWriteProtected(info) 274 + if pr.shouldPrompt(force, wp) { 275 + if !pr.ask(fmt.Sprintf("rm: remove directory '%s'? ", displayPath)) { 276 + return nil 277 + } 278 + } 279 + if err := ec.FS.Remove(full); err != nil { 280 + if !force { 281 + fmt.Fprintf(stderr, "rm: cannot remove '%s': %s\n", displayPath, formatErr(err)) 282 + return err 283 + } 284 + return nil 285 + } 286 + if verbose { 287 + fmt.Fprintf(stdout, "removed directory '%s'\n", displayPath) 288 + } 289 + return nil 290 + } 291 + 292 + func removeDir(ec *command.ExecContext, stdout, stderr io.Writer, displayPath, full string, info fs.FileInfo, force bool, pr *prompter, verbose bool) error { 293 + entries, err := ec.FS.ReadDir(full) 294 + if err != nil { 295 + if !force { 296 + fmt.Fprintf(stderr, "rm: cannot remove '%s': %s\n", displayPath, formatErr(err)) 297 + return err 298 + } 299 + return nil 300 + } 301 + 302 + wp := isWriteProtected(info) 303 + // Empty-directory shortcut: prompt once with "remove directory" wording 304 + // (POSIX 2b allows skipping straight to 2d for empty dirs). 305 + if len(entries) == 0 { 306 + if pr.shouldPrompt(force, wp) { 307 + if !pr.ask(fmt.Sprintf("rm: remove directory '%s'? ", displayPath)) { 308 + return nil 117 309 } 118 - continue 119 310 } 311 + if err := ec.FS.Remove(full); err != nil { 312 + if !force { 313 + fmt.Fprintf(stderr, "rm: cannot remove '%s': %s\n", displayPath, formatErr(err)) 314 + return err 315 + } 316 + return nil 317 + } 318 + if verbose { 319 + fmt.Fprintf(stdout, "removed directory '%s'\n", displayPath) 320 + } 321 + return nil 322 + } 120 323 121 - if *verbose { 122 - fmt.Fprintf(stdout, "removed '%s'\n", p) 324 + // Non-empty directory: prompt to descend (only when interactive). 325 + if pr.shouldPrompt(force, wp) { 326 + if !pr.ask(fmt.Sprintf("rm: descend into directory '%s'? ", displayPath)) { 327 + return nil 123 328 } 124 329 } 125 330 126 - if exitCode != 0 { 127 - return interp.ExitStatus(uint8(exitCode)) 331 + // Walk children manually so we never follow a symlink-to-directory. 332 + for _, entry := range entries { 333 + childDisplay := joinDisplay(displayPath, entry.Name()) 334 + childFull := path.Join(full, entry.Name()) 335 + // Re-stat with Lstat to make symlink decisions explicit and to 336 + // avoid following them into the target directory. 337 + childInfo, err := lstat(ec.FS, childFull) 338 + if err != nil { 339 + if errors.Is(err, os.ErrNotExist) { 340 + // Lost a race or already removed; skip silently. 341 + continue 342 + } 343 + if !force { 344 + fmt.Fprintf(stderr, "rm: cannot remove '%s': %s\n", childDisplay, formatErr(err)) 345 + return err 346 + } 347 + continue 348 + } 349 + if childInfo.Mode()&fs.ModeSymlink != 0 { 350 + if err := removeFile(ec, stdout, stderr, childDisplay, childFull, childInfo, force, pr, verbose); err != nil { 351 + return err 352 + } 353 + continue 354 + } 355 + if childInfo.IsDir() { 356 + if err := removeDir(ec, stdout, stderr, childDisplay, childFull, childInfo, force, pr, verbose); err != nil { 357 + return err 358 + } 359 + continue 360 + } 361 + if err := removeFile(ec, stdout, stderr, childDisplay, childFull, childInfo, force, pr, verbose); err != nil { 362 + return err 363 + } 364 + } 365 + 366 + // Final prompt before removing the now-empty directory itself (POSIX 2d). 367 + if pr.shouldPrompt(force, wp) { 368 + if !pr.ask(fmt.Sprintf("rm: remove directory '%s'? ", displayPath)) { 369 + return nil 370 + } 371 + } 372 + if err := ec.FS.Remove(full); err != nil { 373 + if !force { 374 + fmt.Fprintf(stderr, "rm: cannot remove '%s': %s\n", displayPath, formatErr(err)) 375 + return err 376 + } 377 + return nil 378 + } 379 + if verbose { 380 + fmt.Fprintf(stdout, "removed directory '%s'\n", displayPath) 128 381 } 129 382 return nil 383 + } 384 + 385 + // fileRemovePrompt returns the GNU-style prompt string for removing a 386 + // non-directory file, distinguishing regular / symlink / write-protected. 387 + func fileRemovePrompt(displayPath string, info fs.FileInfo, writeProtected bool) string { 388 + kind := fileKindForPrompt(info) 389 + if writeProtected { 390 + return fmt.Sprintf("rm: remove write-protected %s '%s'? ", kind, displayPath) 391 + } 392 + return fmt.Sprintf("rm: remove %s '%s'? ", kind, displayPath) 393 + } 394 + 395 + func fileKindForPrompt(info fs.FileInfo) string { 396 + m := info.Mode() 397 + switch { 398 + case m&fs.ModeSymlink != 0: 399 + return "symbolic link" 400 + case m&fs.ModeNamedPipe != 0: 401 + return "fifo" 402 + case m&fs.ModeSocket != 0: 403 + return "socket" 404 + case m&fs.ModeDevice != 0: 405 + return "device" 406 + case info.Size() == 0 && m.IsRegular(): 407 + return "regular empty file" 408 + default: 409 + return "regular file" 410 + } 411 + } 412 + 413 + // isWriteProtected approximates POSIX "permissions do not permit writing" 414 + // using the file's mode owner-write bit. In billy's memfs this isn't 415 + // enforced, but the bit is still recorded and observable. 416 + func isWriteProtected(info fs.FileInfo) bool { 417 + if info == nil { 418 + return false 419 + } 420 + // Symlinks themselves don't have meaningful permissions; treat as writable. 421 + if info.Mode()&fs.ModeSymlink != 0 { 422 + return false 423 + } 424 + return info.Mode().Perm()&0o200 == 0 425 + } 426 + 427 + // lstat returns FileInfo without following symlinks. On filesystems that 428 + // don't expose billy.Symlink, it falls back to Stat, which means symlinks 429 + // can't be detected — same trade-off as elsewhere in kefka. 430 + func lstat(fsys billy.Filesystem, name string) (fs.FileInfo, error) { 431 + if sl, ok := fsys.(billy.Symlink); ok { 432 + return sl.Lstat(name) 433 + } 434 + return fsys.Stat(name) 435 + } 436 + 437 + func plural(n int) string { 438 + if n == 1 { 439 + return "" 440 + } 441 + return "s" 442 + } 443 + 444 + func joinDisplay(displayPath, name string) string { 445 + if displayPath == "." { 446 + return name 447 + } 448 + return path.Join(displayPath, name) 449 + } 450 + 451 + func formatErr(err error) string { 452 + switch { 453 + case errors.Is(err, os.ErrNotExist): 454 + return "No such file or directory" 455 + case isNotEmpty(err): 456 + return "Directory not empty" 457 + default: 458 + return err.Error() 459 + } 130 460 } 131 461 132 462 func isNotEmpty(err error) bool {
+542 -1
command/internal/rm/rm_test.go
··· 49 49 return stdout.String(), stderr.String(), err 50 50 } 51 51 52 + func runWithStdin(t *testing.T, args []string, fs billy.Filesystem, stdin string) (string, string, error) { 53 + t.Helper() 54 + var stdout, stderr bytes.Buffer 55 + ec := &command.ExecContext{ 56 + Stdin: strings.NewReader(stdin), 57 + Stdout: &stdout, 58 + Stderr: &stderr, 59 + Dir: ".", 60 + FS: fs, 61 + } 62 + err := Impl{}.Exec(context.Background(), ec, args) 63 + return stdout.String(), stderr.String(), err 64 + } 65 + 52 66 func exists(t *testing.T, fs billy.Filesystem, name string) bool { 53 67 t.Helper() 54 68 _, err := fs.Stat(name) ··· 177 191 { 178 192 name: "verbose recursive on directory", 179 193 args: []string{"-rv", "populated"}, 180 - wantStdout: "removed 'populated'\n", 194 + wantStdout: "removed 'populated/inside.txt'\nremoved 'populated/sub/deep.txt'\nremoved directory 'populated/sub'\nremoved directory 'populated'\n", 181 195 }, 182 196 { 183 197 name: "partial success continues but reports error", ··· 269 283 if exists(t, fs, "hello.txt") == false { 270 284 t.Errorf("--help should not have removed any files") 271 285 } 286 + } 287 + 288 + func TestInteractive(t *testing.T) { 289 + tests := []struct { 290 + name string 291 + args []string 292 + stdin string 293 + wantStderr string 294 + check func(t *testing.T, fs billy.Filesystem) 295 + }{ 296 + { 297 + name: "yes removes file", 298 + args: []string{"-i", "hello.txt"}, 299 + stdin: "y\n", 300 + wantStderr: "rm: remove regular file 'hello.txt'? ", 301 + check: func(t *testing.T, fs billy.Filesystem) { 302 + if exists(t, fs, "hello.txt") { 303 + t.Errorf("hello.txt should have been removed") 304 + } 305 + }, 306 + }, 307 + { 308 + name: "no keeps file", 309 + args: []string{"-i", "hello.txt"}, 310 + stdin: "n\n", 311 + wantStderr: "rm: remove regular file 'hello.txt'? ", 312 + check: func(t *testing.T, fs billy.Filesystem) { 313 + if !exists(t, fs, "hello.txt") { 314 + t.Errorf("hello.txt should have been kept") 315 + } 316 + }, 317 + }, 318 + { 319 + name: "empty input keeps file", 320 + args: []string{"-i", "hello.txt"}, 321 + stdin: "", 322 + wantStderr: "rm: remove regular file 'hello.txt'? ", 323 + check: func(t *testing.T, fs billy.Filesystem) { 324 + if !exists(t, fs, "hello.txt") { 325 + t.Errorf("hello.txt should have been kept") 326 + } 327 + }, 328 + }, 329 + { 330 + name: "if force wins last so no prompt", 331 + args: []string{"-if", "hello.txt"}, 332 + stdin: "", 333 + check: func(t *testing.T, fs billy.Filesystem) { 334 + if exists(t, fs, "hello.txt") { 335 + t.Errorf("hello.txt should have been removed (force wins)") 336 + } 337 + }, 338 + }, 339 + { 340 + name: "fi interactive wins last so prompts", 341 + args: []string{"-fi", "hello.txt"}, 342 + stdin: "n\n", 343 + wantStderr: "rm: remove regular file 'hello.txt'? ", 344 + check: func(t *testing.T, fs billy.Filesystem) { 345 + if !exists(t, fs, "hello.txt") { 346 + t.Errorf("hello.txt should have been kept (interactive wins)") 347 + } 348 + }, 349 + }, 350 + { 351 + name: "multiple files mixed answers", 352 + args: []string{"-i", "hello.txt", "other.txt", "populated/inside.txt"}, 353 + stdin: "y\nn\ny\n", 354 + check: func(t *testing.T, fs billy.Filesystem) { 355 + if exists(t, fs, "hello.txt") { 356 + t.Errorf("hello.txt should have been removed") 357 + } 358 + if !exists(t, fs, "other.txt") { 359 + t.Errorf("other.txt should have been kept") 360 + } 361 + if exists(t, fs, "populated/inside.txt") { 362 + t.Errorf("populated/inside.txt should have been removed") 363 + } 364 + }, 365 + }, 366 + { 367 + name: "uppercase Y also removes", 368 + args: []string{"-i", "hello.txt"}, 369 + stdin: "Yes\n", 370 + check: func(t *testing.T, fs billy.Filesystem) { 371 + if exists(t, fs, "hello.txt") { 372 + t.Errorf("hello.txt should have been removed") 373 + } 374 + }, 375 + }, 376 + { 377 + name: "long form interactive flag", 378 + args: []string{"--interactive", "hello.txt"}, 379 + stdin: "n\n", 380 + wantStderr: "rm: remove regular file 'hello.txt'? ", 381 + check: func(t *testing.T, fs billy.Filesystem) { 382 + if !exists(t, fs, "hello.txt") { 383 + t.Errorf("hello.txt should have been kept") 384 + } 385 + }, 386 + }, 387 + { 388 + name: "long force after long interactive force wins", 389 + args: []string{"--interactive", "--force", "hello.txt"}, 390 + stdin: "", 391 + wantStderr: "", 392 + check: func(t *testing.T, fs billy.Filesystem) { 393 + if exists(t, fs, "hello.txt") { 394 + t.Errorf("hello.txt should have been removed (force wins)") 395 + } 396 + }, 397 + }, 398 + { 399 + name: "empty directory prompt with -ri", 400 + args: []string{"-ri", "emptydir"}, 401 + stdin: "y\n", 402 + check: func(t *testing.T, fs billy.Filesystem) { 403 + if exists(t, fs, "emptydir") { 404 + t.Errorf("emptydir should have been removed") 405 + } 406 + }, 407 + }, 408 + } 409 + 410 + for _, tt := range tests { 411 + t.Run(tt.name, func(t *testing.T) { 412 + fs := newFS(t) 413 + _, stderr, err := runWithStdin(t, tt.args, fs, tt.stdin) 414 + if err != nil { 415 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 416 + } 417 + if tt.wantStderr != "" && !strings.Contains(stderr, tt.wantStderr) { 418 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantStderr) 419 + } 420 + if tt.check != nil { 421 + tt.check(t, fs) 422 + } 423 + }) 424 + } 425 + } 426 + 427 + func TestInteractiveEmptyDirPrompt(t *testing.T) { 428 + fs := newFS(t) 429 + _, stderr, err := runWithStdin(t, []string{"-ri", "emptydir"}, fs, "y\n") 430 + if err != nil { 431 + t.Fatalf("unexpected error: %v", err) 432 + } 433 + if !strings.Contains(stderr, "rm: remove directory 'emptydir'? ") { 434 + t.Errorf("expected 'remove directory' prompt, got: %q", stderr) 435 + } 436 + } 437 + 438 + func TestInteractiveDescendPrompt(t *testing.T) { 439 + fs := newFS(t) 440 + _, stderr, err := runWithStdin(t, []string{"-ri", "populated"}, fs, "n\n") 441 + if err != nil { 442 + t.Fatalf("unexpected error: %v", err) 443 + } 444 + if !strings.Contains(stderr, "rm: descend into directory 'populated'? ") { 445 + t.Errorf("expected 'descend into directory' prompt, got: %q", stderr) 446 + } 447 + if !exists(t, fs, "populated") { 448 + t.Errorf("populated should have been kept after refusing descent") 449 + } 450 + } 451 + 452 + func TestWriteProtected(t *testing.T) { 453 + // A write-protected (mode 0o400) file with stdin attached should prompt 454 + // with the GNU "write-protected" wording even without -i. -f always 455 + // suppresses the prompt. 456 + makeFS := func(t *testing.T) billy.Filesystem { 457 + t.Helper() 458 + fs := memfs.New() 459 + f, err := fs.OpenFile("ro.txt", os.O_CREATE|os.O_WRONLY, 0o400) 460 + if err != nil { 461 + t.Fatal(err) 462 + } 463 + f.Write([]byte("ro\n")) 464 + f.Close() 465 + return fs 466 + } 467 + 468 + t.Run("yes removes write-protected file", func(t *testing.T) { 469 + fs := makeFS(t) 470 + _, stderr, err := runWithStdin(t, []string{"ro.txt"}, fs, "y\n") 471 + if err != nil { 472 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 473 + } 474 + if !strings.Contains(stderr, "rm: remove write-protected regular file 'ro.txt'? ") { 475 + t.Errorf("stderr missing write-protected prompt: %q", stderr) 476 + } 477 + if exists(t, fs, "ro.txt") { 478 + t.Errorf("ro.txt should have been removed after y answer") 479 + } 480 + }) 481 + 482 + t.Run("no keeps write-protected file", func(t *testing.T) { 483 + fs := makeFS(t) 484 + _, stderr, err := runWithStdin(t, []string{"ro.txt"}, fs, "n\n") 485 + if err != nil { 486 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 487 + } 488 + if !strings.Contains(stderr, "rm: remove write-protected regular file 'ro.txt'? ") { 489 + t.Errorf("stderr missing write-protected prompt: %q", stderr) 490 + } 491 + if !exists(t, fs, "ro.txt") { 492 + t.Errorf("ro.txt should have been kept after n answer") 493 + } 494 + }) 495 + 496 + t.Run("force suppresses write-protected prompt", func(t *testing.T) { 497 + fs := makeFS(t) 498 + _, stderr, err := runWithStdin(t, []string{"-f", "ro.txt"}, fs, "") 499 + if err != nil { 500 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 501 + } 502 + if strings.Contains(stderr, "write-protected") { 503 + t.Errorf("stderr should not include prompt under -f: %q", stderr) 504 + } 505 + if exists(t, fs, "ro.txt") { 506 + t.Errorf("ro.txt should have been removed under -f") 507 + } 508 + }) 509 + 510 + t.Run("interactive on write-protected uses write-protected wording", func(t *testing.T) { 511 + fs := makeFS(t) 512 + _, stderr, err := runWithStdin(t, []string{"-i", "ro.txt"}, fs, "n\n") 513 + if err != nil { 514 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 515 + } 516 + if !strings.Contains(stderr, "rm: remove write-protected regular file 'ro.txt'? ") { 517 + t.Errorf("expected write-protected prompt under -i: %q", stderr) 518 + } 519 + }) 520 + 521 + t.Run("no stdin: no prompt no removal failure when writable", func(t *testing.T) { 522 + fs := memfs.New() 523 + f, err := fs.OpenFile("rw.txt", os.O_CREATE|os.O_WRONLY, 0o644) 524 + if err != nil { 525 + t.Fatal(err) 526 + } 527 + f.Close() 528 + // run() uses no stdin. A writable file should be removed without 529 + // any prompt or failure. 530 + _, stderr, err := run(t, []string{"rw.txt"}, fs) 531 + if err != nil { 532 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 533 + } 534 + if stderr != "" { 535 + t.Errorf("expected empty stderr, got %q", stderr) 536 + } 537 + if exists(t, fs, "rw.txt") { 538 + t.Errorf("rw.txt should have been removed") 539 + } 540 + }) 541 + 542 + t.Run("no stdin write-protected without -f silently skips prompt path", func(t *testing.T) { 543 + // With no stdin attached we can't prompt; the file should still be 544 + // removed (matches GNU rm behavior when stdin is not a terminal). 545 + fs := makeFS(t) 546 + _, _, err := run(t, []string{"ro.txt"}, fs) 547 + if err != nil { 548 + t.Fatalf("unexpected error: %v", err) 549 + } 550 + if exists(t, fs, "ro.txt") { 551 + t.Errorf("ro.txt should have been removed when no stdin attached") 552 + } 553 + }) 554 + } 555 + 556 + func TestRecursiveDoesNotFollowSymlinks(t *testing.T) { 557 + // Build a layout where dir/ contains a symlink pointing to outside/. 558 + // rm -r dir must remove the symlink itself but must NOT touch outside/ 559 + // or its contents. 560 + fs := memfs.New() 561 + if err := fs.MkdirAll("dir", 0o755); err != nil { 562 + t.Fatal(err) 563 + } 564 + if err := fs.MkdirAll("outside", 0o755); err != nil { 565 + t.Fatal(err) 566 + } 567 + write := func(name string, data []byte) { 568 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 569 + if err != nil { 570 + t.Fatal(err) 571 + } 572 + f.Write(data) 573 + f.Close() 574 + } 575 + write("dir/inside.txt", []byte("inside")) 576 + write("outside/keep.txt", []byte("keep")) 577 + 578 + sym, ok := fs.(billy.Symlink) 579 + if !ok { 580 + t.Skip("memfs does not implement billy.Symlink") 581 + } 582 + if err := sym.Symlink("/outside", "dir/escape"); err != nil { 583 + t.Fatalf("symlink: %v", err) 584 + } 585 + 586 + _, stderr, err := run(t, []string{"-r", "dir"}, fs) 587 + if err != nil { 588 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 589 + } 590 + 591 + if exists(t, fs, "dir") { 592 + t.Errorf("dir was not removed") 593 + } 594 + // The crucial assertion: rm -r must NOT have followed the symlink. 595 + if !exists(t, fs, "outside") { 596 + t.Errorf("outside/ was incorrectly removed via symlink traversal") 597 + } 598 + if !exists(t, fs, "outside/keep.txt") { 599 + t.Errorf("outside/keep.txt was incorrectly removed via symlink traversal") 600 + } 601 + } 602 + 603 + func TestSymlinkRemoval(t *testing.T) { 604 + // rm of a symlink should remove the link, not the target. 605 + fs := memfs.New() 606 + f, err := fs.OpenFile("target.txt", os.O_CREATE|os.O_WRONLY, 0o644) 607 + if err != nil { 608 + t.Fatal(err) 609 + } 610 + f.Write([]byte("target")) 611 + f.Close() 612 + 613 + sym, ok := fs.(billy.Symlink) 614 + if !ok { 615 + t.Skip("memfs does not implement billy.Symlink") 616 + } 617 + if err := sym.Symlink("target.txt", "link"); err != nil { 618 + t.Fatalf("symlink: %v", err) 619 + } 620 + 621 + if _, _, err := run(t, []string{"link"}, fs); err != nil { 622 + t.Fatalf("unexpected error: %v", err) 623 + } 624 + if exists(t, fs, "link") { 625 + t.Errorf("link should have been removed") 626 + } 627 + if !exists(t, fs, "target.txt") { 628 + t.Errorf("target.txt must not be removed when only the symlink was named") 629 + } 630 + } 631 + 632 + func TestDFlag(t *testing.T) { 633 + t.Run("removes empty dir without -r", func(t *testing.T) { 634 + fs := newFS(t) 635 + _, stderr, err := run(t, []string{"-d", "emptydir"}, fs) 636 + if err != nil { 637 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 638 + } 639 + if exists(t, fs, "emptydir") { 640 + t.Errorf("emptydir should have been removed via -d") 641 + } 642 + }) 643 + 644 + t.Run("refuses to remove non-empty dir", func(t *testing.T) { 645 + fs := newFS(t) 646 + _, stderr, err := run(t, []string{"-d", "populated"}, fs) 647 + if err == nil { 648 + t.Fatalf("expected error when -d on non-empty dir") 649 + } 650 + if !strings.Contains(stderr, "Directory not empty") { 651 + t.Errorf("expected 'Directory not empty' in stderr, got %q", stderr) 652 + } 653 + if !exists(t, fs, "populated") { 654 + t.Errorf("populated should still exist after -d on non-empty dir") 655 + } 656 + }) 657 + } 658 + 659 + func TestInteractivePromptKinds(t *testing.T) { 660 + // Directly exercise fileKindForPrompt via integration: an empty regular 661 + // file should produce "regular empty file" wording in the prompt. 662 + fs := memfs.New() 663 + f, err := fs.OpenFile("empty.txt", os.O_CREATE|os.O_WRONLY, 0o644) 664 + if err != nil { 665 + t.Fatal(err) 666 + } 667 + f.Close() 668 + 669 + _, stderr, err := runWithStdin(t, []string{"-i", "empty.txt"}, fs, "n\n") 670 + if err != nil { 671 + t.Fatalf("unexpected error: %v", err) 672 + } 673 + if !strings.Contains(stderr, "rm: remove regular empty file 'empty.txt'? ") { 674 + t.Errorf("expected 'regular empty file' prompt, got %q", stderr) 675 + } 676 + } 677 + 678 + func TestInteractiveSymlinkPrompt(t *testing.T) { 679 + fs := memfs.New() 680 + sym, ok := fs.(billy.Symlink) 681 + if !ok { 682 + t.Skip("memfs does not implement billy.Symlink") 683 + } 684 + if err := sym.Symlink("nowhere", "dangling"); err != nil { 685 + t.Fatalf("symlink: %v", err) 686 + } 687 + _, stderr, err := runWithStdin(t, []string{"-i", "dangling"}, fs, "y\n") 688 + if err != nil { 689 + t.Fatalf("unexpected error: %v", err) 690 + } 691 + if !strings.Contains(stderr, "rm: remove symbolic link 'dangling'? ") { 692 + t.Errorf("expected 'symbolic link' prompt, got %q", stderr) 693 + } 694 + if exists(t, fs, "dangling") { 695 + t.Errorf("dangling should have been removed") 696 + } 697 + } 698 + 699 + func TestLessInteractive(t *testing.T) { 700 + t.Run("prompts once before removing 4 files yes", func(t *testing.T) { 701 + fs := newFS(t) 702 + _, stderr, err := runWithStdin(t, []string{"-I", "hello.txt", "other.txt", "populated/inside.txt", "populated/sub/deep.txt"}, fs, "y\n") 703 + if err != nil { 704 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 705 + } 706 + if !strings.Contains(stderr, "rm: remove 4 arguments? ") { 707 + t.Errorf("expected 'remove 4 arguments?' prompt, got %q", stderr) 708 + } 709 + // Count prompts: should be exactly one. 710 + if c := strings.Count(stderr, "? "); c != 1 { 711 + t.Errorf("expected exactly one prompt, got %d in %q", c, stderr) 712 + } 713 + for _, p := range []string{"hello.txt", "other.txt", "populated/inside.txt", "populated/sub/deep.txt"} { 714 + if exists(t, fs, p) { 715 + t.Errorf("%s should have been removed", p) 716 + } 717 + } 718 + }) 719 + 720 + t.Run("prompts once before removing 4 files no", func(t *testing.T) { 721 + fs := newFS(t) 722 + _, stderr, err := runWithStdin(t, []string{"-I", "hello.txt", "other.txt", "populated/inside.txt", "populated/sub/deep.txt"}, fs, "n\n") 723 + if err != nil { 724 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 725 + } 726 + if !strings.Contains(stderr, "rm: remove 4 arguments? ") { 727 + t.Errorf("expected prompt, got %q", stderr) 728 + } 729 + // Refusal must keep all of them. 730 + for _, p := range []string{"hello.txt", "other.txt", "populated/inside.txt", "populated/sub/deep.txt"} { 731 + if !exists(t, fs, p) { 732 + t.Errorf("%s should still exist after 'n' answer", p) 733 + } 734 + } 735 + }) 736 + 737 + t.Run("does not prompt for 3 files", func(t *testing.T) { 738 + fs := newFS(t) 739 + _, stderr, err := runWithStdin(t, []string{"-I", "hello.txt", "other.txt", "populated/inside.txt"}, fs, "") 740 + if err != nil { 741 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 742 + } 743 + if strings.Contains(stderr, "? ") { 744 + t.Errorf("should not have prompted for 3 files, got %q", stderr) 745 + } 746 + for _, p := range []string{"hello.txt", "other.txt", "populated/inside.txt"} { 747 + if exists(t, fs, p) { 748 + t.Errorf("%s should have been removed", p) 749 + } 750 + } 751 + }) 752 + 753 + t.Run("prompts once when recursing yes", func(t *testing.T) { 754 + fs := newFS(t) 755 + _, stderr, err := runWithStdin(t, []string{"-Ir", "populated"}, fs, "y\n") 756 + if err != nil { 757 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 758 + } 759 + if !strings.Contains(stderr, "rm: remove 1 argument recursively? ") { 760 + t.Errorf("expected recursive prompt, got %q", stderr) 761 + } 762 + if c := strings.Count(stderr, "? "); c != 1 { 763 + t.Errorf("expected exactly one prompt under -I, got %d in %q", c, stderr) 764 + } 765 + if exists(t, fs, "populated") { 766 + t.Errorf("populated should have been removed") 767 + } 768 + }) 769 + 770 + t.Run("prompts once when recursing no", func(t *testing.T) { 771 + fs := newFS(t) 772 + _, stderr, err := runWithStdin(t, []string{"-Ir", "populated"}, fs, "n\n") 773 + if err != nil { 774 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 775 + } 776 + if !strings.Contains(stderr, "recursively? ") { 777 + t.Errorf("expected recursive prompt, got %q", stderr) 778 + } 779 + if !exists(t, fs, "populated") { 780 + t.Errorf("populated should have been kept after refusal") 781 + } 782 + }) 783 + 784 + t.Run("force overrides -I", func(t *testing.T) { 785 + fs := newFS(t) 786 + _, stderr, err := runWithStdin(t, []string{"-If", "hello.txt", "other.txt", "populated/inside.txt", "populated/sub/deep.txt"}, fs, "") 787 + if err != nil { 788 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 789 + } 790 + if strings.Contains(stderr, "? ") { 791 + t.Errorf("should not have prompted under -f, got %q", stderr) 792 + } 793 + if exists(t, fs, "hello.txt") { 794 + t.Errorf("hello.txt should have been removed under -f") 795 + } 796 + }) 797 + 798 + t.Run("interactive overrides -I", func(t *testing.T) { 799 + fs := newFS(t) 800 + // -Ii means -i wins (last one). Expect per-file prompts, not the 801 + // single -I prompt. 802 + _, stderr, err := runWithStdin(t, []string{"-Ii", "hello.txt", "other.txt", "populated/inside.txt", "populated/sub/deep.txt"}, fs, "n\nn\nn\nn\n") 803 + if err != nil { 804 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 805 + } 806 + if strings.Contains(stderr, "remove 4 arguments?") { 807 + t.Errorf("should not show -I prompt when -i wins, got %q", stderr) 808 + } 809 + if !strings.Contains(stderr, "rm: remove regular file 'hello.txt'? ") { 810 + t.Errorf("expected per-file prompt, got %q", stderr) 811 + } 812 + }) 272 813 } 273 814 274 815 func TestNoFilesystem(t *testing.T) {