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

Compress or decompress files using gzip format. Supports compress and
decompress modes via -d flag, stdout output with -c, file management
with -f/-k, integrity testing with -t, listing with -l, verbose output,
custom suffixes with -S, compression levels -1 through -9, 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 eaf6e972 3957c739

+920
+546
command/internal/gzip/gzip.go
··· 1 + package gzip 2 + 3 + import ( 4 + "bytes" 5 + "compress/flate" 6 + gzlib "compress/gzip" 7 + "context" 8 + "errors" 9 + "fmt" 10 + "io" 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("gzip: 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("gzip") 37 + set.SetParameters("[FILE]...") 38 + 39 + stdoutFlag := set.BoolLong("stdout", 'c', "write to standard output, keep original files") 40 + toStdoutFlag := set.BoolLong("to-stdout", 0, "alias for -c") 41 + decompressFlag := set.BoolLong("decompress", 'd', "decompress") 42 + uncompressFlag := set.BoolLong("uncompress", 0, "alias for -d") 43 + forceFlag := set.BoolLong("force", 'f', "force overwrite of output file") 44 + keepFlag := set.BoolLong("keep", 'k', "keep (don't delete) input files") 45 + listFlag := set.BoolLong("list", 'l', "list compressed file contents") 46 + set.BoolLong("no-name", 'n', "do not save or restore the original name and timestamp") 47 + set.BoolLong("name", 'N', "save or restore the original file name and timestamp") 48 + quietFlag := set.BoolLong("quiet", 'q', "suppress all warnings") 49 + set.BoolLong("recursive", 'r', "operate recursively on directories") 50 + suffixFlag := set.StringLong("suffix", 'S', ".gz", "use suffix SUF on compressed files (default: .gz)") 51 + testFlag := set.BoolLong("test", 't', "test compressed file integrity") 52 + verboseFlag := set.BoolLong("verbose", 'v', "verbose mode") 53 + fastFlag := set.BoolLong("fast", '1', "compress faster") 54 + bestFlag := set.BoolLong("best", '9', "compress better") 55 + helpFlag := set.BoolLong("help", 0, "display this help and exit") 56 + 57 + if err := set.Getopt(append([]string{"gzip"}, args...), nil); err != nil { 58 + fmt.Fprintf(stderr, "gzip: %s\n", err) 59 + printUsage(stderr) 60 + return interp.ExitStatus(1) 61 + } 62 + 63 + if *helpFlag { 64 + printUsage(stderr) 65 + return nil 66 + } 67 + 68 + decompress := *decompressFlag || *uncompressFlag 69 + toStdout := *stdoutFlag || *toStdoutFlag 70 + suffix := *suffixFlag 71 + quiet := *quietFlag 72 + force := *forceFlag 73 + keep := *keepFlag 74 + 75 + files := set.Args() 76 + if len(files) == 0 { 77 + files = []string{"-"} 78 + } 79 + 80 + if *listFlag { 81 + return listMode(ec, files, suffix, quiet, stderr) 82 + } 83 + 84 + if *testFlag { 85 + return testMode(ec, files, quiet, *verboseFlag, stderr) 86 + } 87 + 88 + if decompress { 89 + return decompressMode(ec, files, toStdout, force, keep, suffix, quiet, *verboseFlag, stderr) 90 + } 91 + 92 + return compressMode(ec, files, toStdout, force, keep, suffix, quiet, *verboseFlag, *fastFlag, *bestFlag, stderr) 93 + } 94 + 95 + func printUsage(w io.Writer) { 96 + fmt.Fprint(w, "Usage: gzip [OPTION]... [FILE]...\n") 97 + fmt.Fprint(w, "Compress FILEs (by default, in-place).\n\n") 98 + fmt.Fprint(w, "When no FILE is given, or when FILE is -, read from standard input.\n\n") 99 + fmt.Fprint(w, "With -d, decompress instead.\n\n") 100 + fmt.Fprint(w, " -c, --stdout write to standard output, keep original files\n") 101 + fmt.Fprint(w, " -d, --decompress decompress\n") 102 + fmt.Fprint(w, " -f, --force force overwrite of output file\n") 103 + fmt.Fprint(w, " -k, --keep keep (don't delete) input files\n") 104 + fmt.Fprint(w, " -l, --list list compressed file contents\n") 105 + fmt.Fprint(w, " -n, --no-name do not save or restore the original name and timestamp\n") 106 + fmt.Fprint(w, " -N, --name save or restore the original file name and timestamp\n") 107 + fmt.Fprint(w, " -q, --quiet suppress all warnings\n") 108 + fmt.Fprint(w, " -r, --recursive operate recursively on directories\n") 109 + fmt.Fprint(w, " -S, --suffix=SUF use suffix SUF on compressed files (default: .gz)\n") 110 + fmt.Fprint(w, " -t, --test test compressed file integrity\n") 111 + fmt.Fprint(w, " -v, --verbose verbose mode\n") 112 + fmt.Fprint(w, " -1, --fast compress faster\n") 113 + fmt.Fprint(w, " -9, --best compress better\n") 114 + fmt.Fprint(w, " --help display this help and exit\n") 115 + } 116 + 117 + func listMode(ec *command.ExecContext, files []string, suffix string, quiet bool, stderr io.Writer) error { 118 + fmt.Fprint(stderr, " compressed uncompressed ratio uncompressed_name\n") 119 + exitStatus := 0 120 + for _, file := range files { 121 + if file == "-" { 122 + data, err := io.ReadAll(ec.Stdin) 123 + if err != nil { 124 + return err 125 + } 126 + printListEntry(data, "stdin", stderr) 127 + continue 128 + } 129 + if ec.FS == nil { 130 + continue 131 + } 132 + full := resolvePath(ec, file) 133 + f, err := ec.FS.Open(full) 134 + if err != nil { 135 + if !quiet { 136 + fmt.Fprintf(stderr, "gzip: %s: No such file or directory\n", file) 137 + } 138 + exitStatus = 1 139 + continue 140 + } 141 + data, err := io.ReadAll(f) 142 + f.Close() 143 + if err != nil { 144 + if !quiet { 145 + fmt.Fprintf(stderr, "gzip: %s: %v\n", file, err) 146 + } 147 + exitStatus = 1 148 + continue 149 + } 150 + printListEntry(data, strings.TrimSuffix(file, suffix), stderr) 151 + } 152 + if exitStatus != 0 { 153 + return interp.ExitStatus(exitStatus) 154 + } 155 + return nil 156 + } 157 + 158 + func printListEntry(data []byte, name string, w io.Writer) { 159 + compressed := len(data) 160 + uncompressed := uncompressedSize(data) 161 + ratio := "0.0" 162 + if uncompressed > 0 { 163 + ratio = fmt.Sprintf("%.1f", (1.0-float64(compressed)/float64(uncompressed))*100.0) 164 + } 165 + fmt.Fprintf(w, "%10d %10d %5s%% %s\n", compressed, uncompressed, ratio, name) 166 + } 167 + 168 + func testMode(ec *command.ExecContext, files []string, quiet, verbose bool, stderr io.Writer) error { 169 + exitStatus := 0 170 + for _, file := range files { 171 + if file == "-" { 172 + data, err := io.ReadAll(ec.Stdin) 173 + if err != nil { 174 + return err 175 + } 176 + if !isGzip(data) { 177 + if !quiet { 178 + fmt.Fprintf(stderr, "gzip: -: not in gzip format\n") 179 + } 180 + exitStatus = 1 181 + continue 182 + } 183 + _, err = decompressData(data) 184 + if err != nil { 185 + if !quiet { 186 + fmt.Fprintf(stderr, "gzip: -: %v\n", err) 187 + } 188 + exitStatus = 1 189 + continue 190 + } 191 + if verbose { 192 + fmt.Fprintf(stderr, "-:\tOK\n") 193 + } 194 + continue 195 + } 196 + if ec.FS == nil { 197 + continue 198 + } 199 + full := resolvePath(ec, file) 200 + f, err := ec.FS.Open(full) 201 + if err != nil { 202 + if !quiet { 203 + fmt.Fprintf(stderr, "gzip: %s: No such file or directory\n", file) 204 + } 205 + exitStatus = 1 206 + continue 207 + } 208 + data, err := io.ReadAll(f) 209 + f.Close() 210 + if err != nil { 211 + if !quiet { 212 + fmt.Fprintf(stderr, "gzip: %s: %v\n", file, err) 213 + } 214 + exitStatus = 1 215 + continue 216 + } 217 + if !isGzip(data) { 218 + if !quiet { 219 + fmt.Fprintf(stderr, "gzip: %s: not in gzip format\n", file) 220 + } 221 + exitStatus = 1 222 + continue 223 + } 224 + _, err = decompressData(data) 225 + if err != nil { 226 + if !quiet { 227 + fmt.Fprintf(stderr, "gzip: %s: %v\n", file, err) 228 + } 229 + exitStatus = 1 230 + continue 231 + } 232 + if verbose { 233 + fmt.Fprintf(stderr, "%s:\tOK\n", file) 234 + } 235 + } 236 + if exitStatus != 0 { 237 + return interp.ExitStatus(exitStatus) 238 + } 239 + return nil 240 + } 241 + 242 + func compressMode(ec *command.ExecContext, files []string, toStdout, force, keep bool, suffix string, quiet, verbose, fast, best bool, stderr io.Writer) error { 243 + level := level(fast, best) 244 + for _, file := range files { 245 + if file == "-" { 246 + data, err := io.ReadAll(ec.Stdin) 247 + if err != nil { 248 + return err 249 + } 250 + compressed, err := compressData(data, level) 251 + if err != nil { 252 + return err 253 + } 254 + ec.Stdout.Write(compressed) 255 + continue 256 + } 257 + 258 + if ec.FS == nil { 259 + continue 260 + } 261 + 262 + full := resolvePath(ec, file) 263 + info, err := ec.FS.Stat(full) 264 + if err != nil { 265 + if !quiet { 266 + fmt.Fprintf(stderr, "gzip: %s: No such file or directory\n", file) 267 + } 268 + return interp.ExitStatus(1) 269 + } 270 + 271 + if info.IsDir() { 272 + if !quiet { 273 + fmt.Fprintf(stderr, "gzip: %s: is a directory -- ignored\n", file) 274 + } 275 + continue 276 + } 277 + 278 + if strings.HasSuffix(file, suffix) { 279 + if !quiet { 280 + fmt.Fprintf(stderr, "gzip: %s already has %s suffix -- unchanged\n", file, suffix) 281 + } 282 + continue 283 + } 284 + 285 + f, err := ec.FS.Open(full) 286 + if err != nil { 287 + if !quiet { 288 + fmt.Fprintf(stderr, "gzip: %s: No such file or directory\n", file) 289 + } 290 + return interp.ExitStatus(1) 291 + } 292 + data, err := io.ReadAll(f) 293 + f.Close() 294 + if err != nil { 295 + if !quiet { 296 + fmt.Fprintf(stderr, "gzip: %s: %v\n", file, err) 297 + } 298 + continue 299 + } 300 + 301 + compressed, err := compressData(data, level) 302 + if err != nil { 303 + if !quiet { 304 + fmt.Fprintf(stderr, "gzip: %s: %v\n", file, err) 305 + } 306 + continue 307 + } 308 + 309 + if toStdout { 310 + ec.Stdout.Write(compressed) 311 + if verbose { 312 + ratio := 0.0 313 + if len(data) > 0 { 314 + ratio = (1.0 - float64(len(compressed))/float64(len(data))) * 100.0 315 + } 316 + fmt.Fprintf(stderr, "%s:\t%.1f%% -- written to stdout\n", file, ratio) 317 + } 318 + continue 319 + } 320 + 321 + outputPath := file + suffix 322 + outputFull := resolvePath(ec, outputPath) 323 + if _, err := ec.FS.Stat(outputFull); err == nil && !force { 324 + if !quiet { 325 + fmt.Fprintf(stderr, "gzip: %s already exists; not overwritten\n", outputPath) 326 + } 327 + continue 328 + } 329 + 330 + outF, err := ec.FS.Create(outputFull) 331 + if err != nil { 332 + if !quiet { 333 + fmt.Fprintf(stderr, "gzip: %s: %v\n", outputPath, err) 334 + } 335 + continue 336 + } 337 + outF.Write(compressed) 338 + outF.Close() 339 + 340 + if verbose { 341 + ratio := 0.0 342 + if len(data) > 0 { 343 + ratio = (1.0 - float64(len(compressed))/float64(len(data))) * 100.0 344 + } 345 + fmt.Fprintf(stderr, "%s:\t%.1f%% -- replaced with %s\n", file, ratio, outputPath) 346 + } 347 + 348 + if !keep { 349 + ec.FS.Remove(full) 350 + } 351 + } 352 + return nil 353 + } 354 + 355 + func decompressMode(ec *command.ExecContext, files []string, toStdout, force, keep bool, suffix string, quiet, verbose bool, stderr io.Writer) error { 356 + for _, file := range files { 357 + if file == "-" { 358 + data, err := io.ReadAll(ec.Stdin) 359 + if err != nil { 360 + return err 361 + } 362 + if !isGzip(data) { 363 + if !quiet { 364 + fmt.Fprintf(stderr, "gzip: stdin: not in gzip format\n") 365 + } 366 + continue 367 + } 368 + decompressed, err := decompressData(data) 369 + if err != nil { 370 + if !quiet { 371 + fmt.Fprintf(stderr, "gzip: stdin: %v\n", err) 372 + } 373 + continue 374 + } 375 + ec.Stdout.Write(decompressed) 376 + continue 377 + } 378 + 379 + if ec.FS == nil { 380 + continue 381 + } 382 + 383 + full := resolvePath(ec, file) 384 + info, err := ec.FS.Stat(full) 385 + if err != nil { 386 + if !quiet { 387 + fmt.Fprintf(stderr, "gzip: %s: No such file or directory\n", file) 388 + } 389 + return interp.ExitStatus(1) 390 + } 391 + 392 + if info.IsDir() { 393 + if !quiet { 394 + fmt.Fprintf(stderr, "gzip: %s: is a directory -- ignored\n", file) 395 + } 396 + continue 397 + } 398 + 399 + if !strings.HasSuffix(file, suffix) { 400 + if !quiet { 401 + fmt.Fprintf(stderr, "gzip: %s: unknown suffix -- ignored\n", file) 402 + } 403 + continue 404 + } 405 + 406 + f, err := ec.FS.Open(full) 407 + if err != nil { 408 + if !quiet { 409 + fmt.Fprintf(stderr, "gzip: %s: No such file or directory\n", file) 410 + } 411 + return interp.ExitStatus(1) 412 + } 413 + data, err := io.ReadAll(f) 414 + f.Close() 415 + if err != nil { 416 + if !quiet { 417 + fmt.Fprintf(stderr, "gzip: %s: %v\n", file, err) 418 + } 419 + continue 420 + } 421 + 422 + if !isGzip(data) { 423 + if !quiet { 424 + fmt.Fprintf(stderr, "gzip: %s: not in gzip format\n", file) 425 + } 426 + continue 427 + } 428 + 429 + decompressed, err := decompressData(data) 430 + if err != nil { 431 + if !quiet { 432 + fmt.Fprintf(stderr, "gzip: %s: %v\n", file, err) 433 + } 434 + continue 435 + } 436 + 437 + if toStdout { 438 + ec.Stdout.Write(decompressed) 439 + if verbose { 440 + ratio := 0.0 441 + if len(decompressed) > 0 { 442 + ratio = (1.0 - float64(len(data))/float64(len(decompressed))) * 100.0 443 + } 444 + fmt.Fprintf(stderr, "%s:\t%.1f%% -- written to stdout\n", file, ratio) 445 + } 446 + continue 447 + } 448 + 449 + outputPath := strings.TrimSuffix(file, suffix) 450 + outputFull := resolvePath(ec, outputPath) 451 + if _, err := ec.FS.Stat(outputFull); err == nil && !force { 452 + if !quiet { 453 + fmt.Fprintf(stderr, "gzip: %s already exists; not overwritten\n", outputPath) 454 + } 455 + continue 456 + } 457 + 458 + outF, err := ec.FS.Create(outputFull) 459 + if err != nil { 460 + if !quiet { 461 + fmt.Fprintf(stderr, "gzip: %s: %v\n", outputPath, err) 462 + } 463 + continue 464 + } 465 + outF.Write(decompressed) 466 + outF.Close() 467 + 468 + if verbose { 469 + ratio := 0.0 470 + if len(decompressed) > 0 { 471 + ratio = (1.0 - float64(len(data))/float64(len(decompressed))) * 100.0 472 + } 473 + fmt.Fprintf(stderr, "%s:\t%.1f%% -- replaced with %s\n", file, ratio, outputPath) 474 + } 475 + 476 + if !keep { 477 + ec.FS.Remove(full) 478 + } 479 + } 480 + return nil 481 + } 482 + 483 + func compressData(data []byte, level int) ([]byte, error) { 484 + var buf bytes.Buffer 485 + w, err := gzlib.NewWriterLevel(&buf, level) 486 + if err != nil { 487 + return nil, err 488 + } 489 + if _, err := w.Write(data); err != nil { 490 + return nil, err 491 + } 492 + if err := w.Close(); err != nil { 493 + return nil, err 494 + } 495 + return buf.Bytes(), nil 496 + } 497 + 498 + func decompressData(data []byte) ([]byte, error) { 499 + r, err := gzlib.NewReader(bytes.NewReader(data)) 500 + if err != nil { 501 + return nil, err 502 + } 503 + defer r.Close() 504 + return io.ReadAll(r) 505 + } 506 + 507 + func isGzip(data []byte) bool { 508 + return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b 509 + } 510 + 511 + func uncompressedSize(data []byte) int { 512 + if len(data) < 4 { 513 + return 0 514 + } 515 + n := len(data) 516 + return int(data[n-4]) | int(data[n-3])<<8 | int(data[n-2])<<16 | int(data[n-1])<<24 517 + } 518 + 519 + func level(fast, best bool) int { 520 + if best { 521 + return flate.BestCompression 522 + } 523 + if fast { 524 + return flate.BestSpeed 525 + } 526 + return flate.DefaultCompression 527 + } 528 + 529 + func resolvePath(ec *command.ExecContext, p string) string { 530 + dir := ec.Dir 531 + if dir == "" { 532 + dir = "." 533 + } 534 + if path.IsAbs(p) { 535 + p = strings.TrimPrefix(p, "/") 536 + if p == "" { 537 + return "." 538 + } 539 + return path.Clean(p) 540 + } 541 + joined := path.Join(dir, p) 542 + if joined == "" { 543 + return "." 544 + } 545 + return joined 546 + }
+374
command/internal/gzip/gzip_test.go
··· 1 + package gzip 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "io" 7 + "os" 8 + "testing" 9 + 10 + gzlib "compress/gzip" 11 + 12 + "github.com/go-git/go-billy/v5" 13 + "github.com/go-git/go-billy/v5/memfs" 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", []byte("hello world")) 29 + return fs 30 + } 31 + 32 + func run(t *testing.T, args []string, stdin []byte, fs billy.Filesystem) (stdout, stderr []byte, err error) { 33 + t.Helper() 34 + var outb, errb bytes.Buffer 35 + ec := &command.ExecContext{ 36 + Stdin: bytes.NewReader(stdin), 37 + Stdout: &outb, 38 + Stderr: &errb, 39 + Dir: ".", 40 + FS: fs, 41 + } 42 + err = Impl{}.Exec(context.Background(), ec, args) 43 + return outb.Bytes(), errb.Bytes(), err 44 + } 45 + 46 + func TestCompressStdin(t *testing.T) { 47 + stdout, stderr, err := run(t, nil, []byte("hello"), newFS(t)) 48 + if err != nil { 49 + t.Fatalf("unexpected error: %v", err) 50 + } 51 + if len(stderr) > 0 { 52 + t.Fatalf("unexpected stderr: %q", string(stderr)) 53 + } 54 + r, err := gzlib.NewReader(bytes.NewReader(stdout)) 55 + if err != nil { 56 + t.Fatalf("output is not valid gzip: %v", err) 57 + } 58 + r.Close() 59 + } 60 + 61 + func TestCompressFile(t *testing.T) { 62 + fs := newFS(t) 63 + _, _, err := run(t, []string{"hello.txt"}, nil, fs) 64 + if err != nil { 65 + t.Fatalf("unexpected error: %v", err) 66 + } 67 + f, err := fs.Open("hello.txt.gz") 68 + if err != nil { 69 + t.Fatalf("hello.txt.gz not created: %v", err) 70 + } 71 + defer f.Close() 72 + data, _ := io.ReadAll(f) 73 + r, err := gzlib.NewReader(bytes.NewReader(data)) 74 + if err != nil { 75 + t.Fatalf("output is not valid gzip: %v", err) 76 + } 77 + r.Close() 78 + if _, err := fs.Stat("hello.txt"); err == nil { 79 + t.Error("original file should be removed") 80 + } 81 + } 82 + 83 + func TestDecompressStdin(t *testing.T) { 84 + var buf bytes.Buffer 85 + w := gzlib.NewWriter(&buf) 86 + w.Write([]byte("hello")) 87 + w.Close() 88 + compressed := buf.Bytes() 89 + 90 + stdout, stderr, err := run(t, []string{"-d"}, compressed, newFS(t)) 91 + if err != nil { 92 + t.Fatalf("unexpected error: %v", err) 93 + } 94 + if len(stderr) > 0 { 95 + t.Fatalf("unexpected stderr: %q", string(stderr)) 96 + } 97 + if string(stdout) != "hello" { 98 + t.Errorf("stdout = %q, want %q", string(stdout), "hello") 99 + } 100 + } 101 + 102 + func TestDecompressFile(t *testing.T) { 103 + fs := memfs.New() 104 + var buf bytes.Buffer 105 + w := gzlib.NewWriter(&buf) 106 + w.Write([]byte("hello world")) 107 + w.Close() 108 + 109 + f, _ := fs.Create("hello.txt.gz") 110 + f.Write(buf.Bytes()) 111 + f.Close() 112 + 113 + _, _, err := run(t, []string{"-d", "hello.txt.gz"}, nil, fs) 114 + if err != nil { 115 + t.Fatalf("unexpected error: %v", err) 116 + } 117 + 118 + df, err := fs.Open("hello.txt") 119 + if err != nil { 120 + t.Fatalf("hello.txt not created: %v", err) 121 + } 122 + defer df.Close() 123 + data, _ := io.ReadAll(df) 124 + if string(data) != "hello world" { 125 + t.Errorf("decompressed = %q, want %q", string(data), "hello world") 126 + } 127 + if _, err := fs.Stat("hello.txt.gz"); err == nil { 128 + t.Error("original .gz file should be removed") 129 + } 130 + } 131 + 132 + func TestRoundTrip(t *testing.T) { 133 + original := []byte("hello world\nthis is a test\xff\xfe") 134 + fs := newFS(t) 135 + 136 + var compressedBuf bytes.Buffer 137 + w := gzlib.NewWriter(&compressedBuf) 138 + w.Write(original) 139 + w.Close() 140 + f, _ := fs.Create("test.txt.gz") 141 + f.Write(compressedBuf.Bytes()) 142 + f.Close() 143 + 144 + _, _, err := run(t, []string{"-d", "test.txt.gz"}, nil, fs) 145 + if err != nil { 146 + t.Fatalf("decompress: %v", err) 147 + } 148 + 149 + df, _ := fs.Open("test.txt") 150 + decompressed, _ := io.ReadAll(df) 151 + df.Close() 152 + 153 + if !bytes.Equal(decompressed, original) { 154 + t.Errorf("round trip mismatch: got %q, want %q", string(decompressed), string(original)) 155 + } 156 + } 157 + 158 + func TestStdoutMode(t *testing.T) { 159 + fs := newFS(t) 160 + stdout, _, err := run(t, []string{"-c", "hello.txt"}, nil, fs) 161 + if err != nil { 162 + t.Fatalf("unexpected error: %v", err) 163 + } 164 + r, err := gzlib.NewReader(bytes.NewReader(stdout)) 165 + if err != nil { 166 + t.Fatalf("output is not valid gzip: %v", err) 167 + } 168 + r.Close() 169 + if _, err := fs.Stat("hello.txt"); err != nil { 170 + t.Error("original file should still exist with -c") 171 + } 172 + if _, err := fs.Stat("hello.txt.gz"); err == nil { 173 + t.Error(".gz file should not be created with -c") 174 + } 175 + } 176 + 177 + func TestKeepMode(t *testing.T) { 178 + fs := newFS(t) 179 + _, _, err := run(t, []string{"-k", "hello.txt"}, nil, fs) 180 + if err != nil { 181 + t.Fatalf("unexpected error: %v", err) 182 + } 183 + if _, err := fs.Stat("hello.txt"); err != nil { 184 + t.Error("original file should still exist with -k") 185 + } 186 + if _, err := fs.Stat("hello.txt.gz"); err != nil { 187 + t.Error(".gz file should be created with -k") 188 + } 189 + } 190 + 191 + func TestListMode(t *testing.T) { 192 + fs := newFS(t) 193 + var buf bytes.Buffer 194 + w := gzlib.NewWriter(&buf) 195 + w.Write([]byte("hello world")) 196 + w.Close() 197 + f, _ := fs.Create("hello.txt.gz") 198 + f.Write(buf.Bytes()) 199 + f.Close() 200 + 201 + stdout, stderr, err := run(t, []string{"-l", "hello.txt.gz"}, nil, fs) 202 + if err != nil { 203 + t.Fatalf("unexpected error: %v", err) 204 + } 205 + if len(stdout) > 0 { 206 + t.Errorf("unexpected stdout: %q", string(stdout)) 207 + } 208 + output := string(stderr) 209 + if !bytes.Contains([]byte(output), []byte("compressed")) { 210 + t.Error("output missing header") 211 + } 212 + if !bytes.Contains(stderr, []byte("hello.txt")) { 213 + t.Error("output missing filename") 214 + } 215 + } 216 + 217 + func TestTestValid(t *testing.T) { 218 + fs := newFS(t) 219 + var buf bytes.Buffer 220 + w := gzlib.NewWriter(&buf) 221 + w.Write([]byte("hello world")) 222 + w.Close() 223 + f, _ := fs.Create("hello.txt.gz") 224 + f.Write(buf.Bytes()) 225 + f.Close() 226 + 227 + _, stderr, err := run(t, []string{"-t", "hello.txt.gz"}, nil, fs) 228 + if err != nil { 229 + t.Fatalf("unexpected error: %v", err) 230 + } 231 + if len(stderr) > 0 { 232 + t.Errorf("unexpected stderr: %q", string(stderr)) 233 + } 234 + } 235 + 236 + func TestTestInvalid(t *testing.T) { 237 + _, stderr, err := run(t, []string{"-t", "hello.txt"}, nil, newFS(t)) 238 + if err == nil { 239 + t.Fatal("expected error for non-gzip file") 240 + } 241 + if len(stderr) == 0 { 242 + t.Error("expected error message on stderr") 243 + } 244 + } 245 + 246 + func TestVerbose(t *testing.T) { 247 + fs := newFS(t) 248 + stdout, stderr, err := run(t, []string{"-c", "-v", "hello.txt"}, nil, fs) 249 + if err != nil { 250 + t.Fatalf("unexpected error: %v", err) 251 + } 252 + r, err := gzlib.NewReader(bytes.NewReader(stdout)) 253 + if err != nil { 254 + t.Fatalf("output is not valid gzip: %v", err) 255 + } 256 + r.Close() 257 + if len(stderr) == 0 { 258 + t.Error("expected verbose output on stderr") 259 + } 260 + if !bytes.Contains(stderr, []byte("%")) { 261 + t.Error("verbose output missing ratio") 262 + } 263 + } 264 + 265 + func TestCustomSuffix(t *testing.T) { 266 + fs := newFS(t) 267 + _, _, err := run(t, []string{"-S", ".custom", "hello.txt"}, nil, fs) 268 + if err != nil { 269 + t.Fatalf("unexpected error: %v", err) 270 + } 271 + if _, err := fs.Stat("hello.txt.custom"); err != nil { 272 + t.Error("hello.txt.custom not created") 273 + } 274 + if _, err := fs.Stat("hello.txt.gz"); err == nil { 275 + t.Error("hello.txt.gz should not be created with custom suffix") 276 + } 277 + } 278 + 279 + func TestFastCompression(t *testing.T) { 280 + stdout, _, err := run(t, []string{"-1"}, []byte("hello"), newFS(t)) 281 + if err != nil { 282 + t.Fatalf("unexpected error: %v", err) 283 + } 284 + r, err := gzlib.NewReader(bytes.NewReader(stdout)) 285 + if err != nil { 286 + t.Fatalf("output is not valid gzip: %v", err) 287 + } 288 + r.Close() 289 + } 290 + 291 + func TestBestCompression(t *testing.T) { 292 + stdout, _, err := run(t, []string{"-9"}, []byte("hello"), newFS(t)) 293 + if err != nil { 294 + t.Fatalf("unexpected error: %v", err) 295 + } 296 + r, err := gzlib.NewReader(bytes.NewReader(stdout)) 297 + if err != nil { 298 + t.Fatalf("output is not valid gzip: %v", err) 299 + } 300 + r.Close() 301 + } 302 + 303 + func TestEmptyInput(t *testing.T) { 304 + stdout, _, err := run(t, nil, []byte{}, newFS(t)) 305 + if err != nil { 306 + t.Fatalf("unexpected error: %v", err) 307 + } 308 + r, err := gzlib.NewReader(bytes.NewReader(stdout)) 309 + if err != nil { 310 + t.Fatalf("output is not valid gzip: %v", err) 311 + } 312 + r.Close() 313 + } 314 + 315 + func TestMissingFile(t *testing.T) { 316 + _, stderr, err := run(t, []string{"nope.txt"}, nil, newFS(t)) 317 + if err == nil { 318 + t.Fatal("expected error for missing file") 319 + } 320 + if !bytes.Contains(stderr, []byte("No such file or directory")) { 321 + t.Errorf("stderr = %q, want 'No such file or directory'", string(stderr)) 322 + } 323 + } 324 + 325 + func TestHelp(t *testing.T) { 326 + _, stderr, err := run(t, []string{"--help"}, nil, newFS(t)) 327 + if err != nil { 328 + t.Fatalf("unexpected error: %v", err) 329 + } 330 + if !bytes.Contains(stderr, []byte("Usage: gzip")) { 331 + t.Error("usage missing from stderr") 332 + } 333 + if !bytes.Contains(stderr, []byte("--stdout")) { 334 + t.Error("--stdout flag missing from help") 335 + } 336 + } 337 + 338 + func TestUnknownFlag(t *testing.T) { 339 + _, _, err := run(t, []string{"--nope"}, nil, newFS(t)) 340 + if err == nil { 341 + t.Fatal("expected error for unknown flag") 342 + } 343 + } 344 + 345 + func TestDirectoryWithoutRecursive(t *testing.T) { 346 + fs := newFS(t) 347 + fs.MkdirAll("mydir", 0o755) 348 + _, stderr, err := run(t, []string{"mydir"}, nil, fs) 349 + if err != nil { 350 + t.Fatalf("unexpected error: %v", err) 351 + } 352 + if !bytes.Contains(stderr, []byte("is a directory")) { 353 + t.Errorf("stderr = %q, want 'is a directory'", string(stderr)) 354 + } 355 + } 356 + 357 + func TestAlreadyHasSuffix(t *testing.T) { 358 + fs := newFS(t) 359 + var buf bytes.Buffer 360 + w := gzlib.NewWriter(&buf) 361 + w.Write([]byte("hello")) 362 + w.Close() 363 + f, _ := fs.Create("file.gz") 364 + f.Write(buf.Bytes()) 365 + f.Close() 366 + 367 + _, stderr, err := run(t, []string{"file.gz"}, nil, fs) 368 + if err != nil { 369 + t.Fatalf("unexpected error: %v", err) 370 + } 371 + if !bytes.Contains(stderr, []byte("already has .gz suffix")) { 372 + t.Errorf("stderr = %q, want 'already has .gz suffix'", string(stderr)) 373 + } 374 + }