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 gunzip from just-bash

Standalone decompression command that always decompresses .gz files.
Supports stdout output with -c, file management with -f/-k, integrity
testing with -t, listing with -l, verbose output, custom suffixes with
-S, and recursive directory processing with -r.

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

Xe Iaso a61fb36f eaf6e972

+702
+368
command/internal/gunzip/gunzip.go
··· 1 + package gunzip 2 + 3 + import ( 4 + "bytes" 5 + gzlib "compress/gzip" 6 + "context" 7 + "errors" 8 + "fmt" 9 + "io" 10 + "os" 11 + "path" 12 + "strings" 13 + 14 + "github.com/pborman/getopt/v2" 15 + "mvdan.cc/sh/v3/interp" 16 + "tangled.org/xeiaso.net/kefka/command" 17 + ) 18 + 19 + type Impl struct{} 20 + 21 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 22 + if ec == nil { 23 + return errors.New("gunzip: nil ExecContext") 24 + } 25 + 26 + stdout := ec.Stdout 27 + if stdout == nil { 28 + stdout = io.Discard 29 + } 30 + stderr := ec.Stderr 31 + if stderr == nil { 32 + stderr = io.Discard 33 + } 34 + 35 + set := getopt.New() 36 + set.SetProgram("gunzip") 37 + set.SetParameters("[FILE]...") 38 + 39 + usage := func() { 40 + fmt.Fprint(stderr, "Usage: gunzip [OPTION]... [FILE]...\n") 41 + fmt.Fprint(stderr, "Decompress FILEs (by default, in-place).\n\n") 42 + fmt.Fprint(stderr, "When no FILE is given, or when FILE is -, read from standard input.\n\n") 43 + fmt.Fprint(stderr, " -c, --stdout write to standard output, keep original files\n") 44 + fmt.Fprint(stderr, " -f, --force force overwrite of output file\n") 45 + fmt.Fprint(stderr, " -k, --keep keep (don't delete) input files\n") 46 + fmt.Fprint(stderr, " -l, --list list compressed file contents\n") 47 + fmt.Fprint(stderr, " -n, --no-name do not restore the original name and timestamp\n") 48 + fmt.Fprint(stderr, " -N, --name restore the original file name and timestamp\n") 49 + fmt.Fprint(stderr, " -q, --quiet suppress all warnings\n") 50 + fmt.Fprint(stderr, " -r, --recursive operate recursively on directories\n") 51 + fmt.Fprint(stderr, " -S, --suffix=SUF use suffix SUF on compressed files (default: .gz)\n") 52 + fmt.Fprint(stderr, " -t, --test test compressed file integrity\n") 53 + fmt.Fprint(stderr, " -v, --verbose verbose mode\n") 54 + fmt.Fprint(stderr, " --help display this help and exit\n") 55 + } 56 + set.SetUsage(usage) 57 + 58 + stdoutFlag := set.BoolLong("stdout", 'c', "write to standard output, keep original files") 59 + forceFlag := set.BoolLong("force", 'f', "force overwrite of output file") 60 + keepFlag := set.BoolLong("keep", 'k', "keep (don't delete) input files") 61 + listFlag := set.BoolLong("list", 'l', "list compressed file contents") 62 + noNameFlag := set.BoolLong("no-name", 'n', "do not restore the original name and timestamp") 63 + nameFlag := set.BoolLong("name", 'N', "restore the original file name and timestamp") 64 + quietFlag := set.BoolLong("quiet", 'q', "suppress all warnings") 65 + recursiveFlag := set.BoolLong("recursive", 'r', "operate recursively on directories") 66 + suffixFlag := set.StringLong("suffix", 'S', ".gz", "use suffix SUF on compressed files (default: .gz)") 67 + testFlag := set.BoolLong("test", 't', "test compressed file integrity") 68 + verboseFlag := set.BoolLong("verbose", 'v', "verbose mode") 69 + helpFlag := set.BoolLong("help", 0, "display this help and exit") 70 + 71 + if err := set.Getopt(append([]string{"gunzip"}, args...), nil); err != nil { 72 + fmt.Fprintf(stderr, "gunzip: %s\n", err) 73 + usage() 74 + return interp.ExitStatus(1) 75 + } 76 + 77 + if *helpFlag { 78 + usage() 79 + return nil 80 + } 81 + 82 + files := set.Args() 83 + suffix := *suffixFlag 84 + toStdout := *stdoutFlag 85 + 86 + if *listFlag { 87 + return listFiles(ec, files, suffix, *quietFlag, stderr) 88 + } 89 + 90 + if *testFlag { 91 + return testFiles(ec, files, *verboseFlag, stderr) 92 + } 93 + 94 + return decompressFiles(ec, files, suffix, toStdout, *forceFlag, *keepFlag, *recursiveFlag, *quietFlag, *verboseFlag, *noNameFlag, *nameFlag, stderr) 95 + } 96 + 97 + func listFiles(ec *command.ExecContext, files []string, suffix string, quiet bool, stderr io.Writer) error { 98 + fmt.Fprintf(stderr, " compressed uncompressed ratio uncompressed_name\n") 99 + 100 + toProcess, err := collectFiles(ec, files, suffix, false, quiet, stderr) 101 + if err != nil { 102 + return err 103 + } 104 + 105 + for _, file := range toProcess { 106 + data, err := readFile(ec, file) 107 + if err != nil { 108 + if !quiet { 109 + fmt.Fprintf(stderr, "gunzip: %s\n", err) 110 + } 111 + return err 112 + } 113 + 114 + if !isGzip(data) { 115 + if !quiet { 116 + fmt.Fprintf(stderr, "gunzip: %s: not in gzip format\n", file) 117 + } 118 + return interp.ExitStatus(1) 119 + } 120 + 121 + compressed := len(data) 122 + uncompressed := uncompressedSize(data) 123 + 124 + ratio := "0.0" 125 + if uncompressed > 0 { 126 + ratio = fmt.Sprintf("%.1f", (1.0-float64(compressed)/float64(uncompressed))*100.0) 127 + } 128 + 129 + name := strings.TrimSuffix(file, suffix) 130 + fmt.Fprintf(stderr, "%10d %10d %5s%% %s\n", compressed, uncompressed, ratio, name) 131 + } 132 + 133 + return nil 134 + } 135 + 136 + func testFiles(ec *command.ExecContext, files []string, verbose bool, stderr io.Writer) error { 137 + toProcess, err := collectFiles(ec, files, ".gz", false, false, stderr) 138 + if err != nil { 139 + return err 140 + } 141 + 142 + for _, file := range toProcess { 143 + data, err := readFile(ec, file) 144 + if err != nil { 145 + fmt.Fprintf(stderr, "gunzip: %s: %v\n", file, err) 146 + return err 147 + } 148 + 149 + if !isGzip(data) { 150 + fmt.Fprintf(stderr, "gunzip: %s: not in gzip format\n", file) 151 + return interp.ExitStatus(1) 152 + } 153 + 154 + _, err = decompressData(data) 155 + if err != nil { 156 + fmt.Fprintf(stderr, "gunzip: %s: %v\n", file, err) 157 + return interp.ExitStatus(1) 158 + } 159 + 160 + if verbose { 161 + fmt.Fprintf(stderr, "%s:\tOK\n", file) 162 + } 163 + } 164 + 165 + return nil 166 + } 167 + 168 + func decompressFiles(ec *command.ExecContext, files []string, suffix string, toStdout, force, keep, recursive, quiet, verbose, _ /* noName */, _ /* name */ bool, stderr io.Writer) error { 169 + if len(files) == 0 || (len(files) == 1 && files[0] == "-") { 170 + return decompressStdin(ec, stderr) 171 + } 172 + 173 + toProcess, err := collectFiles(ec, files, suffix, recursive, quiet, stderr) 174 + if err != nil { 175 + return err 176 + } 177 + 178 + for _, file := range toProcess { 179 + data, err := readFile(ec, file) 180 + if err != nil { 181 + if !quiet { 182 + fmt.Fprintf(stderr, "gunzip: %s: %v\n", file, err) 183 + } 184 + return err 185 + } 186 + 187 + if !isGzip(data) { 188 + if !quiet { 189 + fmt.Fprintf(stderr, "gunzip: %s: not in gzip format\n", file) 190 + } 191 + return interp.ExitStatus(1) 192 + } 193 + 194 + decompressed, err := decompressData(data) 195 + if err != nil { 196 + if !quiet { 197 + fmt.Fprintf(stderr, "gunzip: %s: %v\n", file, err) 198 + } 199 + return err 200 + } 201 + 202 + if toStdout { 203 + ec.Stdout.Write(decompressed) 204 + if verbose { 205 + ratio := "0.0" 206 + if len(decompressed) > 0 { 207 + ratio = fmt.Sprintf("%.1f", (1.0-float64(len(data))/float64(len(decompressed)))*100.0) 208 + } 209 + fmt.Fprintf(stderr, "%s:\t%s%% -- written to stdout\n", file, ratio) 210 + } 211 + continue 212 + } 213 + 214 + outPath := strings.TrimSuffix(file, suffix) 215 + 216 + if outPath == file { 217 + if !quiet { 218 + fmt.Fprintf(stderr, "gunzip: %s: unknown suffix -- ignored\n", file) 219 + } 220 + continue 221 + } 222 + 223 + _, err = ec.FS.Stat(outPath) 224 + if err == nil && !force { 225 + if !quiet { 226 + fmt.Fprintf(stderr, "gunzip: %s already exists; not overwritten\n", outPath) 227 + } 228 + continue 229 + } 230 + 231 + f, err := ec.FS.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) 232 + if err != nil { 233 + if !quiet { 234 + fmt.Fprintf(stderr, "gunzip: %s: %v\n", outPath, err) 235 + } 236 + return err 237 + } 238 + f.Write(decompressed) 239 + f.Close() 240 + 241 + if verbose { 242 + ratio := "0.0" 243 + if len(decompressed) > 0 { 244 + ratio = fmt.Sprintf("%.1f", (1.0-float64(len(data))/float64(len(decompressed)))*100.0) 245 + } 246 + fmt.Fprintf(stderr, "%s:\t%s%% -- replaced with %s\n", file, ratio, outPath) 247 + } 248 + 249 + if !keep { 250 + ec.FS.Remove(resolvePath(ec, file)) 251 + } 252 + } 253 + 254 + return nil 255 + } 256 + 257 + func decompressStdin(ec *command.ExecContext, stderr io.Writer) error { 258 + data, err := io.ReadAll(ec.Stdin) 259 + if err != nil { 260 + fmt.Fprintf(stderr, "gunzip: %v\n", err) 261 + return err 262 + } 263 + 264 + if !isGzip(data) { 265 + fmt.Fprintf(stderr, "gunzip: stdin: not in gzip format\n") 266 + return interp.ExitStatus(1) 267 + } 268 + 269 + decompressed, err := decompressData(data) 270 + if err != nil { 271 + fmt.Fprintf(stderr, "gunzip: %v\n", err) 272 + return err 273 + } 274 + 275 + ec.Stdout.Write(decompressed) 276 + return nil 277 + } 278 + 279 + func collectFiles(ec *command.ExecContext, files []string, suffix string, recursive, quiet bool, stderr io.Writer) ([]string, error) { 280 + var result []string 281 + 282 + for _, file := range files { 283 + full := resolvePath(ec, file) 284 + 285 + info, err := ec.FS.Stat(full) 286 + if err != nil { 287 + fmt.Fprintf(stderr, "gunzip: %s: No such file or directory\n", file) 288 + return nil, interp.ExitStatus(1) 289 + } 290 + 291 + if info.IsDir() { 292 + if recursive { 293 + dirFiles, err := ec.FS.ReadDir(full) 294 + if err != nil { 295 + if !quiet { 296 + fmt.Fprintf(stderr, "gunzip: %s: %v\n", file, err) 297 + } 298 + return nil, err 299 + } 300 + for _, df := range dirFiles { 301 + if !df.IsDir() && strings.HasSuffix(df.Name(), suffix) { 302 + result = append(result, path.Join(full, df.Name())) 303 + } 304 + } 305 + } else { 306 + if !quiet { 307 + fmt.Fprintf(stderr, "gunzip: %s: is a directory -- ignored\n", file) 308 + } 309 + } 310 + continue 311 + } 312 + 313 + result = append(result, file) 314 + } 315 + 316 + return result, nil 317 + } 318 + 319 + func readFile(ec *command.ExecContext, file string) ([]byte, error) { 320 + full := resolvePath(ec, file) 321 + f, err := ec.FS.Open(full) 322 + if err != nil { 323 + return nil, fmt.Errorf("%s: No such file or directory", file) 324 + } 325 + defer f.Close() 326 + 327 + return io.ReadAll(f) 328 + } 329 + 330 + func decompressData(data []byte) ([]byte, error) { 331 + r, err := gzlib.NewReader(bytes.NewReader(data)) 332 + if err != nil { 333 + return nil, err 334 + } 335 + defer r.Close() 336 + return io.ReadAll(r) 337 + } 338 + 339 + func isGzip(data []byte) bool { 340 + return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b 341 + } 342 + 343 + func uncompressedSize(data []byte) int { 344 + if len(data) < 4 { 345 + return 0 346 + } 347 + n := len(data) 348 + return int(data[n-4]) | int(data[n-3])<<8 | int(data[n-2])<<16 | int(data[n-1])<<24 349 + } 350 + 351 + func resolvePath(ec *command.ExecContext, p string) string { 352 + dir := ec.Dir 353 + if dir == "" { 354 + dir = "." 355 + } 356 + if path.IsAbs(p) { 357 + p = strings.TrimPrefix(p, "/") 358 + if p == "" { 359 + return "." 360 + } 361 + return path.Clean(p) 362 + } 363 + joined := path.Join(dir, p) 364 + if joined == "" { 365 + return "." 366 + } 367 + return joined 368 + }
+334
command/internal/gunzip/gunzip_test.go
··· 1 + package gunzip 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 + gzlib "compress/gzip" 14 + "tangled.org/xeiaso.net/kefka/command" 15 + ) 16 + 17 + func newFS(t *testing.T) billy.Filesystem { 18 + t.Helper() 19 + fs := memfs.New() 20 + write := func(name string, data []byte) { 21 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 22 + if err != nil { 23 + t.Fatal(err) 24 + } 25 + f.Write(data) 26 + f.Close() 27 + } 28 + write("hello.txt.gz", gzipData(t, []byte("hello world"))) 29 + write("notgz.txt", []byte("not compressed")) 30 + fs.MkdirAll("subdir", 0o755) 31 + return fs 32 + } 33 + 34 + func gzipData(t *testing.T, data []byte) []byte { 35 + t.Helper() 36 + var buf bytes.Buffer 37 + w := gzlib.NewWriter(&buf) 38 + w.Write(data) 39 + w.Close() 40 + return buf.Bytes() 41 + } 42 + 43 + func run(t *testing.T, args []string, stdin []byte, fs billy.Filesystem) (stdout, stderr []byte, err error) { 44 + t.Helper() 45 + var outb, errb bytes.Buffer 46 + ec := &command.ExecContext{ 47 + Stdin: bytes.NewReader(stdin), 48 + Stdout: &outb, 49 + Stderr: &errb, 50 + Dir: ".", 51 + FS: fs, 52 + } 53 + err = Impl{}.Exec(context.Background(), ec, args) 54 + return outb.Bytes(), errb.Bytes(), err 55 + } 56 + 57 + func TestDecompressStdin(t *testing.T) { 58 + fs := newFS(t) 59 + stdout, stderr, err := run(t, nil, gzipData(t, []byte("hello")), fs) 60 + if err != nil { 61 + t.Fatalf("unexpected error: %v", err) 62 + } 63 + if string(stderr) != "" { 64 + t.Fatalf("unexpected stderr: %q", stderr) 65 + } 66 + if string(stdout) != "hello" { 67 + t.Errorf("stdout = %q, want \"hello\"", stdout) 68 + } 69 + } 70 + 71 + func TestDecompressFile(t *testing.T) { 72 + fs := newFS(t) 73 + stdout, stderr, err := run(t, []string{"hello.txt.gz"}, nil, fs) 74 + if err != nil { 75 + t.Fatalf("unexpected error: %v", err) 76 + } 77 + if string(stdout) != "" { 78 + t.Errorf("stdout should be empty, got %q", stdout) 79 + } 80 + if string(stderr) != "" { 81 + t.Errorf("stderr should be empty, got %q", stderr) 82 + } 83 + f, err := fs.Open("hello.txt") 84 + if err != nil { 85 + t.Fatalf("hello.txt not created: %v", err) 86 + } 87 + defer f.Close() 88 + data, _ := io.ReadAll(f) 89 + if string(data) != "hello world" { 90 + t.Errorf("file content = %q, want \"hello world\"", data) 91 + } 92 + } 93 + 94 + func TestDecompressToStdout(t *testing.T) { 95 + fs := newFS(t) 96 + stdout, stderr, err := run(t, []string{"-c", "hello.txt.gz"}, nil, fs) 97 + if err != nil { 98 + t.Fatalf("unexpected error: %v", err) 99 + } 100 + if string(stdout) != "hello world" { 101 + t.Errorf("stdout = %q, want \"hello world\"", stdout) 102 + } 103 + _, err = fs.Stat("hello.txt.gz") 104 + if err != nil { 105 + t.Errorf("original .gz file should still exist") 106 + } 107 + _, err = fs.Stat("hello.txt") 108 + if err == nil { 109 + t.Errorf("hello.txt should not exist when using -c") 110 + } 111 + if string(stderr) != "" { 112 + t.Errorf("stderr should be empty, got %q", stderr) 113 + } 114 + } 115 + 116 + func TestKeepOriginal(t *testing.T) { 117 + fs := newFS(t) 118 + _, _, err := run(t, []string{"-k", "hello.txt.gz"}, nil, fs) 119 + if err != nil { 120 + t.Fatalf("unexpected error: %v", err) 121 + } 122 + _, err = fs.Stat("hello.txt.gz") 123 + if err != nil { 124 + t.Errorf("original .gz file should still exist with -k") 125 + } 126 + _, err = fs.Stat("hello.txt") 127 + if err != nil { 128 + t.Errorf("hello.txt should exist") 129 + } 130 + } 131 + 132 + func TestForceOverwrite(t *testing.T) { 133 + fs := newFS(t) 134 + f, _ := fs.OpenFile("hello.txt", os.O_CREATE|os.O_WRONLY, 0o644) 135 + f.Write([]byte("old content")) 136 + f.Close() 137 + 138 + _, _, err := run(t, []string{"-f", "hello.txt.gz"}, nil, fs) 139 + if err != nil { 140 + t.Fatalf("unexpected error: %v", err) 141 + } 142 + f, err = fs.Open("hello.txt") 143 + if err != nil { 144 + t.Fatalf("hello.txt not created: %v", err) 145 + } 146 + defer f.Close() 147 + data, _ := io.ReadAll(f) 148 + if string(data) != "hello world" { 149 + t.Errorf("file content = %q, want \"hello world\"", data) 150 + } 151 + } 152 + 153 + func TestList(t *testing.T) { 154 + fs := newFS(t) 155 + stdout, stderr, err := run(t, []string{"-l", "hello.txt.gz"}, nil, fs) 156 + if err != nil { 157 + t.Fatalf("unexpected error: %v", err) 158 + } 159 + if string(stdout) != "" { 160 + t.Errorf("stdout should be empty, got %q", stdout) 161 + } 162 + stderrStr := string(stderr) 163 + if !strings.Contains(stderrStr, "compressed") || !strings.Contains(stderrStr, "uncompressed") { 164 + t.Errorf("list output should contain headers, got: %q", stderrStr) 165 + } 166 + if !strings.Contains(stderrStr, "hello.txt") { 167 + t.Errorf("list output should contain file name, got: %q", stderrStr) 168 + } 169 + } 170 + 171 + func TestTestValid(t *testing.T) { 172 + fs := newFS(t) 173 + stdout, stderr, err := run(t, []string{"-t", "hello.txt.gz"}, nil, fs) 174 + if err != nil { 175 + t.Fatalf("unexpected error: %v", err) 176 + } 177 + if string(stdout) != "" || string(stderr) != "" { 178 + t.Errorf("output should be empty, got stdout=%q, stderr=%q", stdout, stderr) 179 + } 180 + } 181 + 182 + func TestTestInvalid(t *testing.T) { 183 + fs := newFS(t) 184 + _, stderr, err := run(t, []string{"-t", "notgz.txt"}, nil, fs) 185 + if err == nil { 186 + t.Fatal("expected error for non-gzip file") 187 + } 188 + if !strings.Contains(string(stderr), "not in gzip format") { 189 + t.Errorf("stderr should mention not in gzip format, got: %q", stderr) 190 + } 191 + } 192 + 193 + func TestVerbose(t *testing.T) { 194 + fs := newFS(t) 195 + stdout, stderr, err := run(t, []string{"-v", "hello.txt.gz"}, nil, fs) 196 + if err != nil { 197 + t.Fatalf("unexpected error: %v", err) 198 + } 199 + if string(stdout) != "" { 200 + t.Errorf("stdout should be empty, got %q", stdout) 201 + } 202 + if !strings.Contains(string(stderr), "%") { 203 + t.Errorf("stderr should contain ratio, got: %q", stderr) 204 + } 205 + } 206 + 207 + func TestSuffix(t *testing.T) { 208 + fs := memfs.New() 209 + gzData := gzipData(t, []byte("custom content")) 210 + f, _ := fs.OpenFile("file.custom", os.O_CREATE|os.O_WRONLY, 0o644) 211 + f.Write(gzData) 212 + f.Close() 213 + 214 + _, _, err := run(t, []string{"-S", ".custom", "file.custom"}, nil, fs) 215 + if err != nil { 216 + t.Fatalf("unexpected error: %v", err) 217 + } 218 + f, err = fs.Open("file") 219 + if err != nil { 220 + t.Fatalf("file not created: %v", err) 221 + } 222 + defer f.Close() 223 + data, _ := io.ReadAll(f) 224 + if string(data) != "custom content" { 225 + t.Errorf("file content = %q, want \"custom content\"", data) 226 + } 227 + } 228 + 229 + func TestEmptyGzipStdin(t *testing.T) { 230 + fs := newFS(t) 231 + stdout, stderr, err := run(t, nil, gzipData(t, []byte{}), fs) 232 + if err != nil { 233 + t.Fatalf("unexpected error: %v", err) 234 + } 235 + if string(stdout) != "" { 236 + t.Errorf("stdout should be empty, got %q", stdout) 237 + } 238 + if string(stderr) != "" { 239 + t.Errorf("stderr should be empty, got %q", stderr) 240 + } 241 + } 242 + 243 + func TestMissingFile(t *testing.T) { 244 + fs := newFS(t) 245 + _, stderr, err := run(t, []string{"nope.gz"}, nil, fs) 246 + if err == nil { 247 + t.Fatal("expected error for missing file") 248 + } 249 + if !strings.Contains(string(stderr), "No such file or directory") { 250 + t.Errorf("stderr should mention missing file, got: %q", stderr) 251 + } 252 + } 253 + 254 + func TestNotGzipFormat(t *testing.T) { 255 + fs := newFS(t) 256 + _, stderr, err := run(t, []string{"notgz.txt"}, nil, fs) 257 + if err == nil { 258 + t.Fatal("expected error for non-gzip file") 259 + } 260 + stderrStr := string(stderr) 261 + if !strings.Contains(stderrStr, "not in gzip format") { 262 + t.Errorf("stderr should mention not in gzip format, got: %q", stderrStr) 263 + } 264 + } 265 + 266 + func TestUnknownSuffix(t *testing.T) { 267 + fs := memfs.New() 268 + f, _ := fs.OpenFile("file.txt", os.O_CREATE|os.O_WRONLY, 0o644) 269 + f.Write(gzipData(t, []byte("test"))) 270 + f.Close() 271 + 272 + stdout, stderr, err := run(t, []string{"file.txt"}, nil, fs) 273 + if err != nil { 274 + t.Fatalf("unexpected error: %v", err) 275 + } 276 + if string(stdout) != "" { 277 + t.Errorf("stdout should be empty, got %q", stdout) 278 + } 279 + if !strings.Contains(string(stderr), "unknown suffix") { 280 + t.Errorf("stderr should mention unknown suffix, got: %q", stderr) 281 + } 282 + } 283 + 284 + func TestHelp(t *testing.T) { 285 + fs := newFS(t) 286 + _, stderr, err := run(t, []string{"--help"}, nil, fs) 287 + if err != nil { 288 + t.Fatalf("unexpected error: %v", err) 289 + } 290 + if !strings.Contains(string(stderr), "Usage: gunzip") { 291 + t.Errorf("help should contain usage, got: %q", stderr) 292 + } 293 + } 294 + 295 + func TestUnknownFlag(t *testing.T) { 296 + fs := newFS(t) 297 + _, _, err := run(t, []string{"--nope"}, nil, fs) 298 + if err == nil { 299 + t.Fatal("expected error for unknown flag") 300 + } 301 + } 302 + 303 + func TestDirectoryWithoutRecursive(t *testing.T) { 304 + fs := newFS(t) 305 + _, stderr, err := run(t, []string{"subdir"}, nil, fs) 306 + if err != nil { 307 + t.Fatalf("unexpected error: %v", err) 308 + } 309 + if !strings.Contains(string(stderr), "is a directory") { 310 + t.Errorf("stderr should mention directory, got: %q", stderr) 311 + } 312 + } 313 + 314 + func TestRecursive(t *testing.T) { 315 + fs := memfs.New() 316 + fs.MkdirAll("subdir", 0o755) 317 + f, _ := fs.OpenFile("subdir/file.gz", os.O_CREATE|os.O_WRONLY, 0o644) 318 + f.Write(gzipData(t, []byte("recursive content"))) 319 + f.Close() 320 + 321 + _, _, err := run(t, []string{"-r", "subdir"}, nil, fs) 322 + if err != nil { 323 + t.Fatalf("unexpected error: %v", err) 324 + } 325 + f, err = fs.Open("subdir/file") 326 + if err != nil { 327 + t.Fatalf("subdir/file not created: %v", err) 328 + } 329 + defer f.Close() 330 + data, _ := io.ReadAll(f) 331 + if string(data) != "recursive content" { 332 + t.Errorf("file content = %q, want \"recursive content\"", data) 333 + } 334 + }