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(command): port cp from just-bash

Copy files and directories. Mirrors GNU cp semantics as implemented
by just-bash: dest-as-directory dispatch, multi-source-requires-dir
guard, omit-directory-without-recursive, no-clobber, and verbose
output. The preserve flag is accepted but a no-op since the billy
filesystem doesn't track timestamps.

Signed-off-by: Xe Iaso <me@xeiaso.net>
Assisted-by: Claude Opus 4.7 via Claude Code

Xe Iaso bd4cf2b4 7c870165

+496
+197
command/internal/cp/cp.go
··· 1 + package cp 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "os" 9 + "path" 10 + "strings" 11 + 12 + "github.com/go-git/go-billy/v5" 13 + "github.com/pborman/getopt/v2" 14 + "mvdan.cc/sh/v3/interp" 15 + "tangled.org/xeiaso.net/kefka/command" 16 + ) 17 + 18 + type Impl struct{} 19 + 20 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 21 + if ec == nil { 22 + return errors.New("cp: nil ExecContext") 23 + } 24 + if ec.FS == nil { 25 + return errors.New("cp: ExecContext has no filesystem") 26 + } 27 + 28 + stdout := ec.Stdout 29 + if stdout == nil { 30 + stdout = io.Discard 31 + } 32 + stderr := ec.Stderr 33 + if stderr == nil { 34 + stderr = io.Discard 35 + } 36 + 37 + set := getopt.New() 38 + set.SetProgram("cp") 39 + set.SetParameters("SOURCE... DEST") 40 + 41 + usage := func() { 42 + fmt.Fprint(stderr, "Usage: cp [OPTION]... SOURCE... DEST\n") 43 + fmt.Fprint(stderr, "Copy files and directories.\n\n") 44 + fmt.Fprint(stderr, " -r, -R, --recursive copy directories recursively\n") 45 + fmt.Fprint(stderr, " -n, --no-clobber do not overwrite an existing file\n") 46 + fmt.Fprint(stderr, " -p, --preserve preserve file attributes\n") 47 + fmt.Fprint(stderr, " -v, --verbose explain what is being done\n") 48 + fmt.Fprint(stderr, " --help display this help and exit\n") 49 + } 50 + set.SetUsage(usage) 51 + 52 + recursive := set.BoolLong("recursive", 'r', "copy directories recursively") 53 + 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") 56 + verbose := set.BoolLong("verbose", 'v', "explain what is being done") 57 + help := set.BoolLong("help", 0, "display this help and exit") 58 + 59 + if err := set.Getopt(append([]string{"cp"}, args...), nil); err != nil { 60 + fmt.Fprintf(stderr, "cp: %s\n", err) 61 + usage() 62 + return interp.ExitStatus(1) 63 + } 64 + if *help { 65 + usage() 66 + return nil 67 + } 68 + 69 + isRecursive := *recursive || *recursiveUpper 70 + paths := set.Args() 71 + 72 + if len(paths) < 2 { 73 + fmt.Fprint(stderr, "cp: missing destination file operand\n") 74 + return interp.ExitStatus(1) 75 + } 76 + 77 + dest := paths[len(paths)-1] 78 + sources := paths[:len(paths)-1] 79 + destPath := resolvePath(ec, dest) 80 + 81 + destIsDir := false 82 + if info, err := ec.FS.Stat(destPath); err == nil { 83 + destIsDir = info.IsDir() 84 + } 85 + 86 + if len(sources) > 1 && !destIsDir { 87 + fmt.Fprintf(stderr, "cp: target '%s' is not a directory\n", dest) 88 + return interp.ExitStatus(1) 89 + } 90 + 91 + exitCode := 0 92 + for _, src := range sources { 93 + srcPath := resolvePath(ec, src) 94 + srcInfo, err := ec.FS.Stat(srcPath) 95 + if err != nil { 96 + fmt.Fprintf(stderr, "cp: cannot stat '%s': No such file or directory\n", src) 97 + exitCode = 1 98 + continue 99 + } 100 + 101 + targetPath := destPath 102 + targetDisplay := dest 103 + if destIsDir { 104 + b := path.Base(src) 105 + targetPath = path.Join(destPath, b) 106 + if dest == "/" { 107 + targetDisplay = "/" + b 108 + } else { 109 + targetDisplay = strings.TrimSuffix(dest, "/") + "/" + b 110 + } 111 + } 112 + 113 + if srcInfo.IsDir() && !isRecursive { 114 + fmt.Fprintf(stderr, "cp: -r not specified; omitting directory '%s'\n", src) 115 + exitCode = 1 116 + continue 117 + } 118 + 119 + if *noClobber { 120 + if _, err := ec.FS.Stat(targetPath); err == nil { 121 + continue 122 + } 123 + } 124 + 125 + if err := copyTree(ec.FS, srcPath, targetPath); err != nil { 126 + fmt.Fprintf(stderr, "cp: cannot copy '%s': %v\n", src, err) 127 + exitCode = 1 128 + continue 129 + } 130 + 131 + if *verbose { 132 + fmt.Fprintf(stdout, "'%s' -> '%s'\n", src, targetDisplay) 133 + } 134 + } 135 + 136 + if exitCode != 0 { 137 + return interp.ExitStatus(uint8(exitCode)) 138 + } 139 + return nil 140 + } 141 + 142 + func copyTree(fs billy.Filesystem, src, dst string) error { 143 + info, err := fs.Stat(src) 144 + if err != nil { 145 + return err 146 + } 147 + if info.IsDir() { 148 + if err := fs.MkdirAll(dst, info.Mode().Perm()); err != nil { 149 + return err 150 + } 151 + entries, err := fs.ReadDir(src) 152 + if err != nil { 153 + return err 154 + } 155 + for _, e := range entries { 156 + if err := copyTree(fs, path.Join(src, e.Name()), path.Join(dst, e.Name())); err != nil { 157 + return err 158 + } 159 + } 160 + return nil 161 + } 162 + return copyFile(fs, src, dst) 163 + } 164 + 165 + func copyFile(fs billy.Filesystem, src, dst string) error { 166 + in, err := fs.Open(src) 167 + if err != nil { 168 + return err 169 + } 170 + defer in.Close() 171 + out, err := fs.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) 172 + if err != nil { 173 + return err 174 + } 175 + defer out.Close() 176 + _, err = io.Copy(out, in) 177 + return err 178 + } 179 + 180 + func resolvePath(ec *command.ExecContext, p string) string { 181 + dir := ec.Dir 182 + if dir == "" { 183 + dir = "." 184 + } 185 + if path.IsAbs(p) { 186 + p = strings.TrimPrefix(p, "/") 187 + if p == "" { 188 + return "." 189 + } 190 + return path.Clean(p) 191 + } 192 + joined := path.Join(dir, p) 193 + if joined == "" { 194 + return "." 195 + } 196 + return joined 197 + }
+297
command/internal/cp/cp_test.go
··· 1 + package cp 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "io" 7 + "os" 8 + "strings" 9 + "testing" 10 + 11 + "github.com/go-git/go-billy/v5" 12 + "github.com/go-git/go-billy/v5/memfs" 13 + "tangled.org/xeiaso.net/kefka/command" 14 + ) 15 + 16 + func newFS(t *testing.T) billy.Filesystem { 17 + t.Helper() 18 + fs := memfs.New() 19 + write := func(name string, data []byte) { 20 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 21 + if err != nil { 22 + t.Fatal(err) 23 + } 24 + f.Write(data) 25 + f.Close() 26 + } 27 + if err := fs.MkdirAll("dir", 0o755); err != nil { 28 + t.Fatal(err) 29 + } 30 + if err := fs.MkdirAll("src/inner", 0o755); err != nil { 31 + t.Fatal(err) 32 + } 33 + if err := fs.MkdirAll("existing", 0o755); err != nil { 34 + t.Fatal(err) 35 + } 36 + write("hello.txt", []byte("hello\n")) 37 + write("two.txt", []byte("world\n")) 38 + write("existing/keep.txt", []byte("keep\n")) 39 + write("src/a.txt", []byte("a\n")) 40 + write("src/inner/b.txt", []byte("b\n")) 41 + return fs 42 + } 43 + 44 + func run(t *testing.T, args []string, fs billy.Filesystem) (string, string, error) { 45 + t.Helper() 46 + var stdout, stderr bytes.Buffer 47 + ec := &command.ExecContext{ 48 + Stdin: strings.NewReader(""), 49 + Stdout: &stdout, 50 + Stderr: &stderr, 51 + Dir: ".", 52 + FS: fs, 53 + } 54 + err := Impl{}.Exec(context.Background(), ec, args) 55 + return stdout.String(), stderr.String(), err 56 + } 57 + 58 + func readFile(t *testing.T, fs billy.Filesystem, name string) string { 59 + t.Helper() 60 + f, err := fs.Open(name) 61 + if err != nil { 62 + t.Fatalf("open %s: %v", name, err) 63 + } 64 + defer f.Close() 65 + data, err := io.ReadAll(f) 66 + if err != nil { 67 + t.Fatalf("read %s: %v", name, err) 68 + } 69 + return string(data) 70 + } 71 + 72 + func TestCp(t *testing.T) { 73 + tests := []struct { 74 + name string 75 + args []string 76 + wantStdout string 77 + wantErrSub string 78 + wantErr bool 79 + check func(t *testing.T, fs billy.Filesystem) 80 + }{ 81 + { 82 + name: "single file to new file", 83 + args: []string{"hello.txt", "copy.txt"}, 84 + check: func(t *testing.T, fs billy.Filesystem) { 85 + if got := readFile(t, fs, "copy.txt"); got != "hello\n" { 86 + t.Errorf("copy.txt = %q, want %q", got, "hello\n") 87 + } 88 + }, 89 + }, 90 + { 91 + name: "single file into existing directory", 92 + args: []string{"hello.txt", "dir"}, 93 + check: func(t *testing.T, fs billy.Filesystem) { 94 + if got := readFile(t, fs, "dir/hello.txt"); got != "hello\n" { 95 + t.Errorf("dir/hello.txt = %q, want %q", got, "hello\n") 96 + } 97 + }, 98 + }, 99 + { 100 + name: "multiple sources into directory", 101 + args: []string{"hello.txt", "two.txt", "dir"}, 102 + check: func(t *testing.T, fs billy.Filesystem) { 103 + if got := readFile(t, fs, "dir/hello.txt"); got != "hello\n" { 104 + t.Errorf("dir/hello.txt = %q, want %q", got, "hello\n") 105 + } 106 + if got := readFile(t, fs, "dir/two.txt"); got != "world\n" { 107 + t.Errorf("dir/two.txt = %q, want %q", got, "world\n") 108 + } 109 + }, 110 + }, 111 + { 112 + name: "missing destination operand", 113 + args: []string{"hello.txt"}, 114 + wantErrSub: "missing destination file operand", 115 + wantErr: true, 116 + }, 117 + { 118 + name: "no args", 119 + args: nil, 120 + wantErrSub: "missing destination file operand", 121 + wantErr: true, 122 + }, 123 + { 124 + name: "multiple sources but dest is not directory", 125 + args: []string{"hello.txt", "two.txt", "newfile"}, 126 + wantErrSub: "is not a directory", 127 + wantErr: true, 128 + }, 129 + { 130 + name: "missing source", 131 + args: []string{"nope.txt", "out.txt"}, 132 + wantErrSub: "cannot stat 'nope.txt'", 133 + wantErr: true, 134 + }, 135 + { 136 + name: "directory without recursive flag", 137 + args: []string{"src", "dest"}, 138 + wantErrSub: "-r not specified; omitting directory 'src'", 139 + wantErr: true, 140 + }, 141 + { 142 + name: "recursive short flag copies directory", 143 + args: []string{"-r", "src", "newdir"}, 144 + check: func(t *testing.T, fs billy.Filesystem) { 145 + if got := readFile(t, fs, "newdir/a.txt"); got != "a\n" { 146 + t.Errorf("newdir/a.txt = %q, want %q", got, "a\n") 147 + } 148 + if got := readFile(t, fs, "newdir/inner/b.txt"); got != "b\n" { 149 + t.Errorf("newdir/inner/b.txt = %q, want %q", got, "b\n") 150 + } 151 + }, 152 + }, 153 + { 154 + name: "recursive uppercase R flag", 155 + args: []string{"-R", "src", "uppdir"}, 156 + check: func(t *testing.T, fs billy.Filesystem) { 157 + if got := readFile(t, fs, "uppdir/inner/b.txt"); got != "b\n" { 158 + t.Errorf("uppdir/inner/b.txt = %q, want %q", got, "b\n") 159 + } 160 + }, 161 + }, 162 + { 163 + name: "recursive long flag into existing dir", 164 + args: []string{"--recursive", "src", "existing"}, 165 + check: func(t *testing.T, fs billy.Filesystem) { 166 + if got := readFile(t, fs, "existing/src/a.txt"); got != "a\n" { 167 + t.Errorf("existing/src/a.txt = %q, want %q", got, "a\n") 168 + } 169 + if got := readFile(t, fs, "existing/keep.txt"); got != "keep\n" { 170 + t.Errorf("existing/keep.txt should be untouched, got %q", got) 171 + } 172 + }, 173 + }, 174 + { 175 + name: "no-clobber skips existing target", 176 + args: []string{"-n", "hello.txt", "existing/keep.txt"}, 177 + check: func(t *testing.T, fs billy.Filesystem) { 178 + if got := readFile(t, fs, "existing/keep.txt"); got != "keep\n" { 179 + t.Errorf("existing/keep.txt = %q, want %q (untouched)", got, "keep\n") 180 + } 181 + }, 182 + }, 183 + { 184 + name: "no-clobber long flag still copies missing target", 185 + args: []string{"--no-clobber", "hello.txt", "fresh.txt"}, 186 + check: func(t *testing.T, fs billy.Filesystem) { 187 + if got := readFile(t, fs, "fresh.txt"); got != "hello\n" { 188 + t.Errorf("fresh.txt = %q, want %q", got, "hello\n") 189 + } 190 + }, 191 + }, 192 + { 193 + name: "verbose short flag prints copy lines", 194 + args: []string{"-v", "hello.txt", "v1.txt"}, 195 + wantStdout: "'hello.txt' -> 'v1.txt'\n", 196 + check: func(t *testing.T, fs billy.Filesystem) { 197 + if got := readFile(t, fs, "v1.txt"); got != "hello\n" { 198 + t.Errorf("v1.txt = %q, want %q", got, "hello\n") 199 + } 200 + }, 201 + }, 202 + { 203 + name: "verbose long flag with directory destination", 204 + args: []string{"--verbose", "hello.txt", "dir"}, 205 + wantStdout: "'hello.txt' -> 'dir/hello.txt'\n", 206 + }, 207 + { 208 + name: "verbose multiple sources", 209 + args: []string{"-v", "hello.txt", "two.txt", "dir"}, 210 + wantStdout: "'hello.txt' -> 'dir/hello.txt'\n'two.txt' -> 'dir/two.txt'\n", 211 + }, 212 + { 213 + name: "preserve flag accepted", 214 + args: []string{"-p", "hello.txt", "p.txt"}, 215 + check: func(t *testing.T, fs billy.Filesystem) { 216 + if got := readFile(t, fs, "p.txt"); got != "hello\n" { 217 + t.Errorf("p.txt = %q, want %q", got, "hello\n") 218 + } 219 + }, 220 + }, 221 + { 222 + name: "overwrite existing file by default", 223 + args: []string{"hello.txt", "existing/keep.txt"}, 224 + check: func(t *testing.T, fs billy.Filesystem) { 225 + if got := readFile(t, fs, "existing/keep.txt"); got != "hello\n" { 226 + t.Errorf("existing/keep.txt = %q, want %q", got, "hello\n") 227 + } 228 + }, 229 + }, 230 + { 231 + name: "double dash terminator", 232 + args: []string{"--", "hello.txt", "term.txt"}, 233 + check: func(t *testing.T, fs billy.Filesystem) { 234 + if got := readFile(t, fs, "term.txt"); got != "hello\n" { 235 + t.Errorf("term.txt = %q, want %q", got, "hello\n") 236 + } 237 + }, 238 + }, 239 + { 240 + name: "unknown flag", 241 + args: []string{"--no-such-flag", "hello.txt", "out.txt"}, 242 + wantErr: true, 243 + }, 244 + } 245 + 246 + for _, tt := range tests { 247 + t.Run(tt.name, func(t *testing.T) { 248 + fs := newFS(t) 249 + stdout, stderr, err := run(t, tt.args, fs) 250 + if tt.wantErr { 251 + if err == nil { 252 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 253 + } 254 + } else if err != nil { 255 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 256 + } 257 + if tt.wantStdout != "" && stdout != tt.wantStdout { 258 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 259 + } 260 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 261 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 262 + } 263 + if tt.check != nil { 264 + tt.check(t, fs) 265 + } 266 + }) 267 + } 268 + } 269 + 270 + func TestHelp(t *testing.T) { 271 + stdout, stderr, err := run(t, []string{"--help"}, newFS(t)) 272 + if err != nil { 273 + t.Fatalf("unexpected error: %v", err) 274 + } 275 + if stdout != "" { 276 + t.Errorf("expected empty stdout, got %q", stdout) 277 + } 278 + if !strings.Contains(stderr, "Usage: cp [OPTION]... SOURCE... DEST") { 279 + t.Errorf("usage line missing from stderr: %q", stderr) 280 + } 281 + if !strings.Contains(stderr, "-r, -R, --recursive") { 282 + t.Errorf("recursive flag missing from help: %q", stderr) 283 + } 284 + } 285 + 286 + func TestNilContext(t *testing.T) { 287 + if err := (Impl{}).Exec(context.Background(), nil, nil); err == nil { 288 + t.Fatal("expected error for nil ExecContext") 289 + } 290 + } 291 + 292 + func TestNilFilesystem(t *testing.T) { 293 + ec := &command.ExecContext{Dir: "."} 294 + if err := (Impl{}).Exec(context.Background(), ec, []string{"a", "b"}); err == nil { 295 + t.Fatal("expected error when filesystem is nil") 296 + } 297 + }
+2
command/registry/coreutils/coreutils.go
··· 6 6 "tangled.org/xeiaso.net/kefka/command/internal/cat" 7 7 "tangled.org/xeiaso.net/kefka/command/internal/clear" 8 8 "tangled.org/xeiaso.net/kefka/command/internal/column" 9 + "tangled.org/xeiaso.net/kefka/command/internal/cp" 9 10 "tangled.org/xeiaso.net/kefka/command/internal/falsecmd" 10 11 "tangled.org/xeiaso.net/kefka/command/internal/hostname" 11 12 "tangled.org/xeiaso.net/kefka/command/internal/ls" ··· 19 20 reg.Register("cat", cat.Impl{}) 20 21 reg.Register("clear", clear.Impl{}) 21 22 reg.Register("column", column.Impl{}) 23 + reg.Register("cp", cp.Impl{}) 22 24 reg.Register("false", falsecmd.Impl{}) 23 25 reg.Register("hostname", hostname.Impl{}) 24 26 reg.Register("ls", ls.Impl{})