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(cp): add -i/-f/-H/-L/-P, wire -p, fix mode and same-file

Implements GNU coreutils compatibility for cp:

- -i interactive prompt before overwrite (last-flag-wins
with -f and -n)
- -f force unlink-and-retry on permission failure
- -H/-L/--dereference/-P/--no-dereference symlink modes
via billy.Symlink capability with graceful degradation
- -p now actually preserves source mode and mtime via
billy.Chmod / billy.Change capability checks
- copyFile uses source mode bits, not hardcoded 0o644
- Same-file detection emits diagnostic and exits non-zero

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

Xe Iaso 70482602 58a4bc05

+679 -20
+197 -17
command/internal/cp/cp.go
··· 1 1 package cp 2 2 3 3 import ( 4 + "bufio" 4 5 "context" 5 6 "errors" 6 7 "fmt" ··· 17 18 18 19 type Impl struct{} 19 20 21 + type promptMode int 22 + 23 + const ( 24 + promptDefault promptMode = iota 25 + promptForce 26 + promptInteractive 27 + promptNoClobber 28 + ) 29 + 30 + // symlinkMode controls how cp treats symbolic links in source operands. 31 + // The default differs by recursion: non-recursive cp follows command-line 32 + // symlinks (effectively -L for top-level operands), while recursive cp 33 + // preserves them in-tree (-P). -H/-L/-P override this. 34 + type symlinkMode int 35 + 36 + const ( 37 + symlinkDefault symlinkMode = iota 38 + symlinkFollowCmdLine // -H 39 + symlinkFollowAll // -L 40 + symlinkNoFollow // -P 41 + ) 42 + 20 43 func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 21 44 if ec == nil { 22 45 return errors.New("cp: nil ExecContext") ··· 41 64 usage := func() { 42 65 fmt.Fprint(stderr, "Usage: cp [OPTION]... SOURCE... DEST\n") 43 66 fmt.Fprint(stderr, "Copy files and directories.\n\n") 44 - fmt.Fprint(stderr, " -r, -R, --recursive copy directories recursively\n") 67 + fmt.Fprint(stderr, " -f, --force do not prompt before overwriting\n") 68 + fmt.Fprint(stderr, " -H follow command-line symbolic links in SOURCE\n") 69 + fmt.Fprint(stderr, " -i, --interactive prompt before overwrite\n") 70 + fmt.Fprint(stderr, " -L, --dereference always follow symbolic links in SOURCE\n") 45 71 fmt.Fprint(stderr, " -n, --no-clobber do not overwrite an existing file\n") 72 + fmt.Fprint(stderr, " -P, --no-dereference never follow symbolic links in SOURCE\n") 46 73 fmt.Fprint(stderr, " -p, --preserve preserve file attributes\n") 74 + fmt.Fprint(stderr, " -r, -R, --recursive copy directories recursively\n") 47 75 fmt.Fprint(stderr, " -v, --verbose explain what is being done\n") 48 76 fmt.Fprint(stderr, " --help display this help and exit\n") 49 77 } ··· 51 79 52 80 recursive := set.BoolLong("recursive", 'r', "copy directories recursively") 53 81 recursiveUpper := set.Bool('R', "copy directories recursively") 54 - noClobber := set.BoolLong("no-clobber", 'n', "do not overwrite an existing file") 55 - _ = set.BoolLong("preserve", 'p', "preserve file attributes") 82 + _ = set.BoolLong("force", 'f', "do not prompt before overwriting") 83 + _ = set.BoolLong("interactive", 'i', "prompt before overwrite") 84 + _ = set.BoolLong("no-clobber", 'n', "do not overwrite an existing file") 85 + preserve := set.BoolLong("preserve", 'p', "preserve file attributes") 56 86 verbose := set.BoolLong("verbose", 'v', "explain what is being done") 87 + _ = set.Bool('H', "follow command-line symbolic links in SOURCE") 88 + _ = set.BoolLong("dereference", 'L', "always follow symbolic links in SOURCE") 89 + _ = set.BoolLong("no-dereference", 'P', "never follow symbolic links in SOURCE") 57 90 help := set.BoolLong("help", 0, "display this help and exit") 58 91 59 - if err := set.Getopt(append([]string{"cp"}, args...), nil); err != nil { 92 + mode := promptDefault 93 + symMode := symlinkDefault 94 + cb := func(opt getopt.Option) bool { 95 + switch opt.LongName() { 96 + case "force": 97 + mode = promptForce 98 + case "interactive": 99 + mode = promptInteractive 100 + case "no-clobber": 101 + mode = promptNoClobber 102 + case "dereference": 103 + symMode = symlinkFollowAll 104 + case "no-dereference": 105 + symMode = symlinkNoFollow 106 + } 107 + switch opt.ShortName() { 108 + case "H": 109 + symMode = symlinkFollowCmdLine 110 + case "L": 111 + symMode = symlinkFollowAll 112 + case "P": 113 + symMode = symlinkNoFollow 114 + } 115 + return true 116 + } 117 + 118 + if err := set.Getopt(append([]string{"cp"}, args...), cb); err != nil { 60 119 fmt.Fprintf(stderr, "cp: %s\n", err) 61 120 usage() 62 121 return interp.ExitStatus(1) ··· 86 145 if len(sources) > 1 && !destIsDir { 87 146 fmt.Fprintf(stderr, "cp: target '%s' is not a directory\n", dest) 88 147 return interp.ExitStatus(1) 148 + } 149 + 150 + stdinReader := bufio.NewReader(stdinOrEmpty(ec.Stdin)) 151 + 152 + // Effective symlink mode: GNU's defaults are -P with -R (preserve links 153 + // in the tree, but follow command-line ones), and -L without -R (follow 154 + // links anywhere). Explicit -H/-L/-P always wins. 155 + effSym := symMode 156 + if effSym == symlinkDefault { 157 + if isRecursive { 158 + effSym = symlinkFollowCmdLine 159 + } else { 160 + effSym = symlinkFollowAll 161 + } 89 162 } 90 163 91 164 exitCode := 0 92 165 for _, src := range sources { 93 166 srcPath := resolvePath(ec, src) 94 - srcInfo, err := ec.FS.Stat(srcPath) 167 + // Top-level operands: -L and -H follow the link given on the 168 + // command line. -P does not. statSrc resolves accordingly. 169 + followTop := effSym == symlinkFollowAll || effSym == symlinkFollowCmdLine 170 + srcInfo, err := statSrc(ec.FS, srcPath, followTop) 95 171 if err != nil { 96 172 fmt.Fprintf(stderr, "cp: cannot stat '%s': No such file or directory\n", src) 97 173 exitCode = 1 ··· 110 186 } 111 187 } 112 188 189 + if srcPath == targetPath { 190 + fmt.Fprintf(stderr, "cp: '%s' and '%s' are the same file\n", src, targetDisplay) 191 + exitCode = 1 192 + continue 193 + } 194 + 113 195 if srcInfo.IsDir() && !isRecursive { 114 196 fmt.Fprintf(stderr, "cp: -r not specified; omitting directory '%s'\n", src) 115 197 exitCode = 1 116 198 continue 117 199 } 118 200 119 - if *noClobber { 201 + if !srcInfo.IsDir() && srcInfo.Mode()&os.ModeSymlink == 0 { 120 202 if _, err := ec.FS.Stat(targetPath); err == nil { 121 - continue 203 + switch mode { 204 + case promptNoClobber: 205 + continue 206 + case promptInteractive: 207 + fmt.Fprintf(stderr, "cp: overwrite '%s'? ", targetDisplay) 208 + line, _ := stdinReader.ReadString('\n') 209 + line = strings.TrimRight(line, "\r\n") 210 + if line == "" || (line[0] != 'y' && line[0] != 'Y') { 211 + continue 212 + } 213 + } 122 214 } 123 215 } 124 216 125 - if err := copyTree(ec.FS, srcPath, targetPath); err != nil { 217 + if err := copyTree(ec.FS, srcPath, targetPath, srcInfo, *preserve, mode == promptForce, effSym, true); err != nil { 126 218 fmt.Fprintf(stderr, "cp: cannot copy '%s': %v\n", src, err) 127 219 exitCode = 1 128 220 continue ··· 139 231 return nil 140 232 } 141 233 142 - func copyTree(fs billy.Filesystem, src, dst string) error { 143 - info, err := fs.Stat(src) 144 - if err != nil { 145 - return err 234 + func copyTree(fs billy.Filesystem, src, dst string, info os.FileInfo, preserve, force bool, sym symlinkMode, isCmdLine bool) error { 235 + if info == nil { 236 + // During recursion, decide whether to follow links based on the mode. 237 + // -L follows everything; -H follows only command-line links; -P never. 238 + follow := sym == symlinkFollowAll || (sym == symlinkFollowCmdLine && isCmdLine) 239 + i, err := statSrc(fs, src, follow) 240 + if err != nil { 241 + return err 242 + } 243 + info = i 244 + } 245 + 246 + // Symlink that we are not following: recreate it at the destination. 247 + if info.Mode()&os.ModeSymlink != 0 { 248 + return copySymlink(fs, src, dst, force) 146 249 } 250 + 147 251 if info.IsDir() { 148 252 if err := fs.MkdirAll(dst, info.Mode().Perm()); err != nil { 149 253 return err ··· 153 257 return err 154 258 } 155 259 for _, e := range entries { 156 - if err := copyTree(fs, path.Join(src, e.Name()), path.Join(dst, e.Name())); err != nil { 260 + // Children are never command-line operands. 261 + if err := copyTree(fs, path.Join(src, e.Name()), path.Join(dst, e.Name()), nil, preserve, force, sym, false); err != nil { 157 262 return err 158 263 } 264 + } 265 + if preserve { 266 + applyPreserve(fs, dst, info) 159 267 } 160 268 return nil 161 269 } 162 - return copyFile(fs, src, dst) 270 + if err := copyFile(fs, src, dst, info, force); err != nil { 271 + return err 272 + } 273 + if preserve { 274 + applyPreserve(fs, dst, info) 275 + } 276 + return nil 277 + } 278 + 279 + // statSrc returns FileInfo for src. When follow is true it uses Stat 280 + // (resolving symlinks). When follow is false it tries Lstat via the 281 + // billy.Symlink capability; if the underlying filesystem doesn't expose 282 + // Lstat, behavior degrades to Stat (links are always followed). That 283 + // limitation is inherent to the billy abstraction. 284 + func statSrc(fsys billy.Filesystem, src string, follow bool) (os.FileInfo, error) { 285 + if follow { 286 + return fsys.Stat(src) 287 + } 288 + if sl, ok := fsys.(billy.Symlink); ok { 289 + return sl.Lstat(src) 290 + } 291 + return fsys.Stat(src) 292 + } 293 + 294 + // copySymlink recreates the symlink at dst pointing to the same target as 295 + // src. If the underlying filesystem does not support symlinks, it returns 296 + // a clear "not supported" diagnostic. 297 + func copySymlink(fs billy.Filesystem, src, dst string, force bool) error { 298 + sl, ok := fs.(billy.Symlink) 299 + if !ok { 300 + return fmt.Errorf("symbolic links not supported by this filesystem") 301 + } 302 + target, err := sl.Readlink(src) 303 + if err != nil { 304 + return err 305 + } 306 + // Best-effort overwrite: remove an existing destination so that 307 + // Symlink doesn't fail with EEXIST. -f makes this aggressive; without 308 + // it we still try once because billy has no atomic replace. 309 + if _, statErr := fs.Stat(dst); statErr == nil { 310 + if rmErr := fs.Remove(dst); rmErr != nil && force { 311 + return rmErr 312 + } 313 + } 314 + return sl.Symlink(target, dst) 163 315 } 164 316 165 - func copyFile(fs billy.Filesystem, src, dst string) error { 317 + func copyFile(fs billy.Filesystem, src, dst string, srcInfo os.FileInfo, force bool) error { 166 318 in, err := fs.Open(src) 167 319 if err != nil { 168 320 return err 169 321 } 170 322 defer in.Close() 171 - out, err := fs.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) 323 + out, err := fs.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode().Perm()) 172 324 if err != nil { 173 - return err 325 + // Spec -f: if a file descriptor for the destination cannot be 326 + // obtained, attempt to unlink the destination and try again. 327 + if force { 328 + if _, statErr := fs.Stat(dst); statErr == nil { 329 + if rmErr := fs.Remove(dst); rmErr == nil { 330 + out, err = fs.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode().Perm()) 331 + } 332 + } 333 + } 334 + if err != nil { 335 + return err 336 + } 174 337 } 175 338 defer out.Close() 176 339 _, err = io.Copy(out, in) 177 340 return err 341 + } 342 + 343 + func applyPreserve(fs billy.Filesystem, dst string, srcInfo os.FileInfo) { 344 + if chm, ok := fs.(billy.Chmod); ok { 345 + _ = chm.Chmod(dst, srcInfo.Mode().Perm()) 346 + } 347 + if ch, ok := fs.(billy.Change); ok { 348 + mt := srcInfo.ModTime() 349 + _ = ch.Chtimes(dst, mt, mt) 350 + } 351 + } 352 + 353 + func stdinOrEmpty(r io.Reader) io.Reader { 354 + if r == nil { 355 + return strings.NewReader("") 356 + } 357 + return r 178 358 } 179 359 180 360 func resolvePath(ec *command.ExecContext, p string) string {
+482 -3
command/internal/cp/cp_test.go
··· 6 6 "io" 7 7 "os" 8 8 "strings" 9 + "sync" 9 10 "testing" 11 + "time" 10 12 11 13 "github.com/go-git/go-billy/v5" 12 14 "github.com/go-git/go-billy/v5/memfs" 13 15 "tangled.org/xeiaso.net/kefka/command" 14 16 ) 15 17 18 + // timedFS wraps a billy.Filesystem with persistent atime/mtime storage and 19 + // implements billy.Change so cp -p can round-trip times. memfs alone does 20 + // not store ModTime nor implement billy.Change. 21 + type timedFS struct { 22 + billy.Filesystem 23 + mu sync.Mutex 24 + atimes map[string]time.Time 25 + mtimes map[string]time.Time 26 + } 27 + 28 + func newTimedFS(inner billy.Filesystem) *timedFS { 29 + return &timedFS{ 30 + Filesystem: inner, 31 + atimes: map[string]time.Time{}, 32 + mtimes: map[string]time.Time{}, 33 + } 34 + } 35 + 36 + func (t *timedFS) Stat(name string) (os.FileInfo, error) { 37 + info, err := t.Filesystem.Stat(name) 38 + if err != nil { 39 + return nil, err 40 + } 41 + t.mu.Lock() 42 + mt, ok := t.mtimes[name] 43 + t.mu.Unlock() 44 + if !ok { 45 + return info, nil 46 + } 47 + return &timedInfo{FileInfo: info, mtime: mt}, nil 48 + } 49 + 50 + func (t *timedFS) Chmod(name string, mode os.FileMode) error { 51 + if c, ok := t.Filesystem.(billy.Chmod); ok { 52 + return c.Chmod(name, mode) 53 + } 54 + return billy.ErrNotSupported 55 + } 56 + 57 + func (t *timedFS) Lchown(name string, uid, gid int) error { 58 + return billy.ErrNotSupported 59 + } 60 + 61 + func (t *timedFS) Chown(name string, uid, gid int) error { 62 + return billy.ErrNotSupported 63 + } 64 + 65 + func (t *timedFS) Chtimes(name string, atime, mtime time.Time) error { 66 + if _, err := t.Filesystem.Stat(name); err != nil { 67 + return err 68 + } 69 + t.mu.Lock() 70 + t.atimes[name] = atime 71 + t.mtimes[name] = mtime 72 + t.mu.Unlock() 73 + return nil 74 + } 75 + 76 + type timedInfo struct { 77 + os.FileInfo 78 + mtime time.Time 79 + } 80 + 81 + func (t *timedInfo) ModTime() time.Time { return t.mtime } 82 + 16 83 func newFS(t *testing.T) billy.Filesystem { 17 84 t.Helper() 18 85 fs := memfs.New() ··· 41 108 return fs 42 109 } 43 110 44 - func run(t *testing.T, args []string, fs billy.Filesystem) (string, string, error) { 111 + func runWithStdin(t *testing.T, args []string, fs billy.Filesystem, stdin string) (string, string, error) { 45 112 t.Helper() 46 113 var stdout, stderr bytes.Buffer 47 114 ec := &command.ExecContext{ 48 - Stdin: strings.NewReader(""), 115 + Stdin: strings.NewReader(stdin), 49 116 Stdout: &stdout, 50 117 Stderr: &stderr, 51 118 Dir: ".", ··· 53 120 } 54 121 err := Impl{}.Exec(context.Background(), ec, args) 55 122 return stdout.String(), stderr.String(), err 123 + } 124 + 125 + func run(t *testing.T, args []string, fs billy.Filesystem) (string, string, error) { 126 + t.Helper() 127 + return runWithStdin(t, args, fs, "") 56 128 } 57 129 58 130 func readFile(t *testing.T, fs billy.Filesystem, name string) string { ··· 73 145 tests := []struct { 74 146 name string 75 147 args []string 148 + stdin string 76 149 wantStdout string 77 150 wantErrSub string 78 151 wantErr bool ··· 241 314 args: []string{"--no-such-flag", "hello.txt", "out.txt"}, 242 315 wantErr: true, 243 316 }, 317 + { 318 + name: "interactive y overwrites", 319 + args: []string{"-i", "hello.txt", "existing/keep.txt"}, 320 + stdin: "y\n", 321 + wantErrSub: "overwrite 'existing/keep.txt'?", 322 + check: func(t *testing.T, fs billy.Filesystem) { 323 + if got := readFile(t, fs, "existing/keep.txt"); got != "hello\n" { 324 + t.Errorf("existing/keep.txt = %q, want %q", got, "hello\n") 325 + } 326 + }, 327 + }, 328 + { 329 + name: "interactive n keeps target", 330 + args: []string{"-i", "hello.txt", "existing/keep.txt"}, 331 + stdin: "n\n", 332 + wantErrSub: "overwrite 'existing/keep.txt'?", 333 + check: func(t *testing.T, fs billy.Filesystem) { 334 + if got := readFile(t, fs, "existing/keep.txt"); got != "keep\n" { 335 + t.Errorf("existing/keep.txt = %q, want %q (untouched)", got, "keep\n") 336 + } 337 + }, 338 + }, 339 + { 340 + name: "i then f: force wins, no prompt", 341 + args: []string{"-if", "hello.txt", "existing/keep.txt"}, 342 + stdin: "", 343 + check: func(t *testing.T, fs billy.Filesystem) { 344 + if got := readFile(t, fs, "existing/keep.txt"); got != "hello\n" { 345 + t.Errorf("existing/keep.txt = %q, want %q", got, "hello\n") 346 + } 347 + }, 348 + }, 349 + { 350 + name: "f then i: interactive wins, prompts", 351 + args: []string{"-fi", "hello.txt", "existing/keep.txt"}, 352 + stdin: "n\n", 353 + wantErrSub: "overwrite", 354 + check: func(t *testing.T, fs billy.Filesystem) { 355 + if got := readFile(t, fs, "existing/keep.txt"); got != "keep\n" { 356 + t.Errorf("existing/keep.txt = %q, want %q (untouched)", got, "keep\n") 357 + } 358 + }, 359 + }, 360 + { 361 + name: "same file diagnostic", 362 + args: []string{"hello.txt", "hello.txt"}, 363 + wantErrSub: "'hello.txt' and 'hello.txt' are the same file", 364 + wantErr: true, 365 + }, 366 + { 367 + name: "double dash with dash-prefixed source", 368 + args: []string{"--", "-src", "outname"}, 369 + check: func(t *testing.T, fs billy.Filesystem) { 370 + if got := readFile(t, fs, "outname"); got != "dashy\n" { 371 + t.Errorf("outname = %q, want %q", got, "dashy\n") 372 + } 373 + }, 374 + }, 244 375 } 245 376 246 377 for _, tt := range tests { 247 378 t.Run(tt.name, func(t *testing.T) { 248 379 fs := newFS(t) 249 - stdout, stderr, err := run(t, tt.args, fs) 380 + // Seed for the dash-prefixed source case. 381 + if tt.name == "double dash with dash-prefixed source" { 382 + f, err := fs.OpenFile("-src", os.O_CREATE|os.O_WRONLY, 0o644) 383 + if err != nil { 384 + t.Fatal(err) 385 + } 386 + f.Write([]byte("dashy\n")) 387 + f.Close() 388 + } 389 + stdout, stderr, err := runWithStdin(t, tt.args, fs, tt.stdin) 250 390 if tt.wantErr { 251 391 if err == nil { 252 392 t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) ··· 267 407 } 268 408 } 269 409 410 + func chmod(t *testing.T, fs billy.Filesystem, name string, mode os.FileMode) { 411 + t.Helper() 412 + c, ok := fs.(billy.Chmod) 413 + if !ok { 414 + t.Fatalf("filesystem does not support Chmod") 415 + } 416 + if err := c.Chmod(name, mode); err != nil { 417 + t.Fatalf("chmod %s: %v", name, err) 418 + } 419 + } 420 + 421 + func TestPreserveModeAndMtime(t *testing.T) { 422 + mem := memfs.New() 423 + fs := newTimedFS(mem) 424 + 425 + f, err := fs.OpenFile("src.txt", os.O_CREATE|os.O_WRONLY, 0o644) 426 + if err != nil { 427 + t.Fatal(err) 428 + } 429 + f.Write([]byte("payload\n")) 430 + f.Close() 431 + 432 + chmod(t, mem, "src.txt", 0o600) 433 + srcMtime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) 434 + if err := fs.Chtimes("src.txt", srcMtime, srcMtime); err != nil { 435 + t.Fatalf("chtimes: %v", err) 436 + } 437 + 438 + var stdout, stderr bytes.Buffer 439 + ec := &command.ExecContext{ 440 + Stdin: strings.NewReader(""), 441 + Stdout: &stdout, 442 + Stderr: &stderr, 443 + Dir: ".", 444 + FS: fs, 445 + } 446 + if err := (Impl{}).Exec(context.Background(), ec, []string{"-p", "src.txt", "dst.txt"}); err != nil { 447 + t.Fatalf("cp -p: %v; stderr=%q", err, stderr.String()) 448 + } 449 + 450 + info, err := fs.Stat("dst.txt") 451 + if err != nil { 452 + t.Fatalf("stat dst.txt: %v", err) 453 + } 454 + if got := info.Mode().Perm(); got != 0o600 { 455 + t.Errorf("mode = %v, want 0o600", got) 456 + } 457 + if got := info.ModTime(); !got.Equal(srcMtime) { 458 + t.Errorf("mtime = %v, want %v", got, srcMtime) 459 + } 460 + } 461 + 462 + func TestCopyPreservesSourceMode(t *testing.T) { 463 + // Without -p, GNU cp 9.x still creates the destination using the 464 + // source's permission bits (subject to umask). On memfs the umask is 465 + // effectively 0 so the source mode round-trips exactly. 466 + mem := memfs.New() 467 + 468 + f, err := mem.OpenFile("src.txt", os.O_CREATE|os.O_WRONLY, 0o644) 469 + if err != nil { 470 + t.Fatal(err) 471 + } 472 + f.Write([]byte("data\n")) 473 + f.Close() 474 + chmod(t, mem, "src.txt", 0o600) 475 + 476 + stdout, stderr, err := run(t, []string{"src.txt", "dst.txt"}, mem) 477 + if err != nil { 478 + t.Fatalf("cp: %v; stdout=%q stderr=%q", err, stdout, stderr) 479 + } 480 + 481 + info, err := mem.Stat("dst.txt") 482 + if err != nil { 483 + t.Fatalf("stat dst.txt: %v", err) 484 + } 485 + if got := info.Mode().Perm(); got != 0o600 { 486 + t.Errorf("mode = %v, want 0o600", got) 487 + } 488 + } 489 + 490 + func TestRecursivePreservesFileModes(t *testing.T) { 491 + mem := memfs.New() 492 + if err := mem.MkdirAll("d", 0o755); err != nil { 493 + t.Fatal(err) 494 + } 495 + f, err := mem.OpenFile("d/secret.txt", os.O_CREATE|os.O_WRONLY, 0o600) 496 + if err != nil { 497 + t.Fatal(err) 498 + } 499 + f.Write([]byte("x\n")) 500 + f.Close() 501 + chmod(t, mem, "d/secret.txt", 0o600) 502 + 503 + if _, _, err := run(t, []string{"-R", "d", "d2"}, mem); err != nil { 504 + t.Fatalf("cp -R: %v", err) 505 + } 506 + 507 + info, err := mem.Stat("d2/secret.txt") 508 + if err != nil { 509 + t.Fatalf("stat d2/secret.txt: %v", err) 510 + } 511 + if got := info.Mode().Perm(); got != 0o600 { 512 + t.Errorf("mode = %v, want 0o600", got) 513 + } 514 + } 515 + 516 + // lockingFS wraps a billy.Filesystem and refuses to OpenFile any path in 517 + // `locked` for writing until it has been Removed. This simulates a write- 518 + // protected destination so we can exercise cp -f's unlink-and-retry path. 519 + type lockingFS struct { 520 + billy.Filesystem 521 + locked map[string]bool 522 + } 523 + 524 + func (l *lockingFS) OpenFile(name string, flag int, perm os.FileMode) (billy.File, error) { 525 + if l.locked[name] && (flag&os.O_WRONLY != 0 || flag&os.O_RDWR != 0) { 526 + return nil, os.ErrPermission 527 + } 528 + return l.Filesystem.OpenFile(name, flag, perm) 529 + } 530 + 531 + func (l *lockingFS) Remove(name string) error { 532 + delete(l.locked, name) 533 + return l.Filesystem.Remove(name) 534 + } 535 + 536 + func TestForceUnlinksOnWriteFailure(t *testing.T) { 537 + mem := memfs.New() 538 + fs := &lockingFS{Filesystem: mem, locked: map[string]bool{"dst.txt": true}} 539 + 540 + write := func(name, data string) { 541 + f, err := mem.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 542 + if err != nil { 543 + t.Fatal(err) 544 + } 545 + f.Write([]byte(data)) 546 + f.Close() 547 + } 548 + write("src.txt", "fresh\n") 549 + write("dst.txt", "stale\n") 550 + 551 + // Without -f, cp should fail because the destination is locked. 552 + stdout, stderr, err := run(t, []string{"src.txt", "dst.txt"}, fs) 553 + if err == nil { 554 + t.Fatalf("expected error without -f; stdout=%q stderr=%q", stdout, stderr) 555 + } 556 + if got := readFile(t, mem, "dst.txt"); got != "stale\n" { 557 + t.Errorf("dst.txt should still be stale, got %q", got) 558 + } 559 + 560 + // With -f, cp should unlink the destination and retry, succeeding. 561 + stdout, stderr, err = run(t, []string{"-f", "src.txt", "dst.txt"}, fs) 562 + if err != nil { 563 + t.Fatalf("cp -f failed: %v; stdout=%q stderr=%q", err, stdout, stderr) 564 + } 565 + if got := readFile(t, mem, "dst.txt"); got != "fresh\n" { 566 + t.Errorf("dst.txt = %q, want %q", got, "fresh\n") 567 + } 568 + } 569 + 570 + func TestSameFileNormalizedPaths(t *testing.T) { 571 + // path.Clean should normalize "./hello.txt" and "hello.txt" into the 572 + // same internal target; cp must detect this as same-file. 573 + fs := newFS(t) 574 + _, stderr, err := run(t, []string{"./hello.txt", "hello.txt"}, fs) 575 + if err == nil { 576 + t.Fatal("expected error for same-file copy") 577 + } 578 + if !strings.Contains(stderr, "are the same file") { 579 + t.Errorf("stderr should mention same file, got %q", stderr) 580 + } 581 + } 582 + 583 + func TestForceLastWinsOverNoClobber(t *testing.T) { 584 + // GNU honors the last-specified of -f / -n / -i. -nf should overwrite. 585 + fs := newFS(t) 586 + _, stderr, err := run(t, []string{"-nf", "hello.txt", "existing/keep.txt"}, fs) 587 + if err != nil { 588 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 589 + } 590 + if got := readFile(t, fs, "existing/keep.txt"); got != "hello\n" { 591 + t.Errorf("-nf should overwrite, got %q", got) 592 + } 593 + } 594 + 595 + func TestNoClobberLastWinsOverForce(t *testing.T) { 596 + // -fn should NOT overwrite — -n was specified last. 597 + fs := newFS(t) 598 + _, stderr, err := run(t, []string{"-fn", "hello.txt", "existing/keep.txt"}, fs) 599 + if err != nil { 600 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 601 + } 602 + if got := readFile(t, fs, "existing/keep.txt"); got != "keep\n" { 603 + t.Errorf("-fn should not overwrite, got %q", got) 604 + } 605 + } 606 + 270 607 func TestHelp(t *testing.T) { 271 608 stdout, stderr, err := run(t, []string{"--help"}, newFS(t)) 272 609 if err != nil { ··· 280 617 } 281 618 if !strings.Contains(stderr, "-r, -R, --recursive") { 282 619 t.Errorf("recursive flag missing from help: %q", stderr) 620 + } 621 + if !strings.Contains(stderr, "-i, --interactive") { 622 + t.Errorf("interactive flag missing from help: %q", stderr) 623 + } 624 + if !strings.Contains(stderr, "-f, --force") { 625 + t.Errorf("force flag missing from help: %q", stderr) 626 + } 627 + for _, want := range []string{"-H", "-L, --dereference", "-P, --no-dereference"} { 628 + if !strings.Contains(stderr, want) { 629 + t.Errorf("symlink flag %q missing from help: %q", want, stderr) 630 + } 631 + } 632 + } 633 + 634 + // newSymlinkFS builds a memfs containing a target file and a symlink to it. 635 + // 636 + // target.txt (content "linked\n") 637 + // link -> target.txt 638 + // dir/inner.txt (content "inner\n") 639 + // dir/dlink -> ../target.txt 640 + func newSymlinkFS(t *testing.T) billy.Filesystem { 641 + t.Helper() 642 + fs := memfs.New() 643 + write := func(name, data string) { 644 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 645 + if err != nil { 646 + t.Fatal(err) 647 + } 648 + f.Write([]byte(data)) 649 + f.Close() 650 + } 651 + if err := fs.MkdirAll("dir", 0o755); err != nil { 652 + t.Fatal(err) 653 + } 654 + write("target.txt", "linked\n") 655 + write("dir/inner.txt", "inner\n") 656 + sl, ok := fs.(billy.Symlink) 657 + if !ok { 658 + t.Skip("memfs does not support symlinks") 659 + } 660 + if err := sl.Symlink("target.txt", "link"); err != nil { 661 + t.Fatalf("symlink: %v", err) 662 + } 663 + if err := sl.Symlink("../target.txt", "dir/dlink"); err != nil { 664 + t.Fatalf("symlink dir/dlink: %v", err) 665 + } 666 + return fs 667 + } 668 + 669 + func TestSymlinkDefaultFollows(t *testing.T) { 670 + // Without -R, GNU cp dereferences command-line symlinks (-L semantics). 671 + // "cp link out" copies the contents of target.txt to out. 672 + fs := newSymlinkFS(t) 673 + if _, _, err := run(t, []string{"link", "out.txt"}, fs); err != nil { 674 + t.Fatalf("cp: %v", err) 675 + } 676 + if got := readFile(t, fs, "out.txt"); got != "linked\n" { 677 + t.Errorf("out.txt = %q, want %q", got, "linked\n") 678 + } 679 + } 680 + 681 + func TestSymlinkP_PreservesLink(t *testing.T) { 682 + fs := newSymlinkFS(t) 683 + if _, _, err := run(t, []string{"-P", "link", "out"}, fs); err != nil { 684 + t.Fatalf("cp -P: %v", err) 685 + } 686 + sl := fs.(billy.Symlink) 687 + info, err := sl.Lstat("out") 688 + if err != nil { 689 + t.Fatalf("lstat out: %v", err) 690 + } 691 + if info.Mode()&os.ModeSymlink == 0 { 692 + t.Errorf("out is not a symlink: mode=%v", info.Mode()) 693 + } 694 + got, err := sl.Readlink("out") 695 + if err != nil { 696 + t.Fatalf("readlink: %v", err) 697 + } 698 + if got != "target.txt" { 699 + t.Errorf("readlink out = %q, want %q", got, "target.txt") 700 + } 701 + } 702 + 703 + func TestSymlinkRecursive_DefaultPreservesInTree(t *testing.T) { 704 + // With -R and no explicit symlink mode, GNU defaults to -P for in-tree 705 + // links (the link is recreated, not dereferenced). 706 + fs := newSymlinkFS(t) 707 + if _, _, err := run(t, []string{"-R", "dir", "dir2"}, fs); err != nil { 708 + t.Fatalf("cp -R: %v", err) 709 + } 710 + sl := fs.(billy.Symlink) 711 + info, err := sl.Lstat("dir2/dlink") 712 + if err != nil { 713 + t.Fatalf("lstat dir2/dlink: %v", err) 714 + } 715 + if info.Mode()&os.ModeSymlink == 0 { 716 + t.Errorf("dir2/dlink should be a symlink, got mode=%v", info.Mode()) 717 + } 718 + if got := readFile(t, fs, "dir2/inner.txt"); got != "inner\n" { 719 + t.Errorf("dir2/inner.txt = %q, want %q", got, "inner\n") 720 + } 721 + } 722 + 723 + func TestSymlinkRecursiveL_DereferencesAll(t *testing.T) { 724 + // -RL dereferences every symlink in the source tree. 725 + fs := newSymlinkFS(t) 726 + if _, _, err := run(t, []string{"-RL", "dir", "dir2"}, fs); err != nil { 727 + t.Fatalf("cp -RL: %v", err) 728 + } 729 + // dir2/dlink should be a regular file containing target.txt's contents. 730 + sl := fs.(billy.Symlink) 731 + info, err := sl.Lstat("dir2/dlink") 732 + if err != nil { 733 + t.Fatalf("lstat dir2/dlink: %v", err) 734 + } 735 + if info.Mode()&os.ModeSymlink != 0 { 736 + t.Errorf("dir2/dlink should not be a symlink under -L: mode=%v", info.Mode()) 737 + } 738 + if got := readFile(t, fs, "dir2/dlink"); got != "linked\n" { 739 + t.Errorf("dir2/dlink = %q, want %q", got, "linked\n") 740 + } 741 + } 742 + 743 + func TestSymlinkNotSupportedDiagnostic(t *testing.T) { 744 + // When the backend lacks symlink support, -P on a non-symlink still 745 + // works (it falls through to plain copy). The "not supported" path is 746 + // only reached when we actually need to recreate a symlink, which 747 + // requires Lstat to detect one in the first place — so on a pure 748 + // non-symlink FS the user gets normal copy semantics. This test 749 + // verifies we don't crash and produce a sensible result. 750 + mem := memfs.New() 751 + f, err := mem.OpenFile("x.txt", os.O_CREATE|os.O_WRONLY, 0o644) 752 + if err != nil { 753 + t.Fatal(err) 754 + } 755 + f.Write([]byte("plain\n")) 756 + f.Close() 757 + if _, _, err := run(t, []string{"-P", "x.txt", "y.txt"}, mem); err != nil { 758 + t.Fatalf("cp -P on regular file: %v", err) 759 + } 760 + if got := readFile(t, mem, "y.txt"); got != "plain\n" { 761 + t.Errorf("y.txt = %q, want %q", got, "plain\n") 283 762 } 284 763 } 285 764