diff --git a/.gitignore b/.gitignore index 277ee86..3142bca 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ /dist/ /MANUAL /MANUAL.html -/MANUAL.gz \ No newline at end of file +/MANUAL.gz +/o/ \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d1bfb82..aa8ee4b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -108,7 +108,7 @@ brews: description: "eXtractor Tool - Recursively decompress archives" license: MIT url_template: "https://github.com/Unpackerr/xt/releases/download/{{ .Tag }}/{{ .ArtifactName }}" - test: assert_match "xt v#{version}", shell_output("#{bin}/xt -v 2>&1", 2) + test: assert_match "xt v#{version}", shell_output("#{bin}/xt -v 2>&1", 0) install: bin.install "xt" changelog: diff --git a/MANUAL.md b/MANUAL.md index 8128c59..e0b877a 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -42,7 +42,7 @@ OPTIONS -P _password_, --password _password_ Provided _passwords_ are attempted against extraction of encrypted - rar and/or 7zip archives. The `-p` option may be provided many times. + rar and/or 7zip archives. The `-P` option may be provided many times. -e _.ext_, --extension _.ext_ Only extract archives with these extensions. Include the leading dot. @@ -57,8 +57,9 @@ OPTIONS xml, json, toml and yaml. TOML is the default. See JOB FILES below. -p, --preserve-paths - This option determines if the archives will be extracted to their - parent folder. Using this flag will override the --output option. + Recreate the input directory hierarchy under the output directory. + Archives found in subfolders are extracted into matching subfolders + of --output. The --output option is still used as the base path. -V, --verbose Verbose logging prints the extracted file paths. diff --git a/README.md b/README.md index c7973ce..bd6fcb7 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,7 @@ brew install golift/mugs/xt - After you [install go](https://go.dev/doc/install), install the `xt` app using `go install`: ```shell -cd /tmp -go get github.com/Unpackerr/xt -go install github.com/Unpackerr/xt +go install golift.io/xt@latest ``` [releases]: https://github.com/Unpackerr/xt/releases diff --git a/pkg/xt/filemode_test.go b/pkg/xt/filemode_test.go new file mode 100644 index 0000000..cc22cc9 --- /dev/null +++ b/pkg/xt/filemode_test.go @@ -0,0 +1,71 @@ +package xt //nolint:testpackage + +import ( + "bytes" + "os" + "testing" +) + +func TestFileModeUnmarshalText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want os.FileMode + wantErr bool + }{ + {name: "unquoted", input: "644", want: 0o644}, + {name: "leading zero", input: "0644", want: 0o644}, + {name: "double quoted", input: `"0755"`, want: 0o755}, + {name: "single quoted", input: "'0755'", want: 0o755}, + {name: "padded", input: " 0644 ", want: 0o644}, + {name: "invalid letters", input: "abc", wantErr: true}, + {name: "invalid octal", input: "999", wantErr: true}, + {name: "empty", input: "", wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var mode FileMode + + err := mode.UnmarshalText([]byte(test.input)) + if test.wantErr { + if err == nil { + t.Fatal("expected error") + } + + return + } + + if err != nil { + t.Fatal(err) + } + + if mode.Mode() != test.want { + t.Fatalf("mode = %o, want %o", mode.Mode(), test.want) + } + }) + } +} + +func TestFileModeMarshalAndString(t *testing.T) { + t.Parallel() + + mode := FileMode(0o644) + + got, err := mode.MarshalText() + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(got, []byte("0644")) { + t.Fatalf("MarshalText = %q", got) + } + + if mode.String() != "0644" { + t.Fatalf("String = %q", mode.String()) + } +} diff --git a/pkg/xt/job_test.go b/pkg/xt/job_test.go new file mode 100644 index 0000000..60aa39d --- /dev/null +++ b/pkg/xt/job_test.go @@ -0,0 +1,99 @@ +package xt //nolint:testpackage + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseJobs(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + tomlPath := filepath.Join(dir, "job.toml") + writeFile(t, tomlPath, "paths = ['/tmp/a']\noutput = '.'\npreserve_paths = true\n") + + jsonPath := filepath.Join(dir, "job.json") + writeFile(t, jsonPath, `{"paths":["/tmp/b"],"output":".","squashRoot":true}`) + + yamlPath := filepath.Join(dir, "job.yaml") + writeFile(t, yamlPath, "paths:\n - /tmp/c\noutput: .\nverbose: true\n") + + jobs, err := ParseJobs([]string{tomlPath, jsonPath, yamlPath}) + if err != nil { + t.Fatal(err) + } + + if len(jobs) != 3 { + t.Fatalf("len(jobs) = %d", len(jobs)) + } + + if jobs[0].Paths[0] != "/tmp/a" || !jobs[0].Preserve || jobs[0].Output != "." { + t.Fatalf("toml job = %+v", jobs[0]) + } + + if jobs[1].Paths[0] != "/tmp/b" || !jobs[1].SquashRoot { + t.Fatalf("json job = %+v", jobs[1]) + } + + if jobs[2].Paths[0] != "/tmp/c" || !jobs[2].Verbose { + t.Fatalf("yaml job = %+v", jobs[2]) + } +} + +func TestParseJobsMissingFile(t *testing.T) { + t.Parallel() + + _, err := ParseJobs([]string{filepath.Join(t.TempDir(), "missing.toml")}) + if err == nil { + t.Fatal("expected error for missing job file") + } + + if !strings.Contains(err.Error(), "bad job file") { + t.Fatalf("error = %v", err) + } +} + +func TestFixModesAndString(t *testing.T) { + t.Parallel() + + job := &Job{Paths: []string{"a"}, Output: "/out"} + job.fixModes() + + if job.FileMode.Mode() != 0o644 { + t.Fatalf("FileMode = %o", job.FileMode.Mode()) + } + + if job.DirMode.Mode() != 0o755 { + t.Fatalf("DirMode = %o", job.DirMode.Mode()) + } + + job.FileMode = 0o600 + job.DirMode = 0o700 + job.fixModes() + + if job.FileMode.Mode() != 0o600 || job.DirMode.Mode() != 0o700 { + t.Fatal("fixModes overwrote non-zero modes") + } + + got := job.String() + if !strings.Contains(got, "1 path,") || !strings.Contains(got, "f/d-mode:0600/0700") { + t.Fatalf("String = %q", got) + } + + job.Paths = []string{"a", "b"} + if !strings.Contains(job.String(), "2 paths,") { + t.Fatalf("plural String = %q", job.String()) + } +} + +func writeFile(t *testing.T, path, body string) { + t.Helper() + + err := os.WriteFile(path, []byte(body), 0o600) + if err != nil { + t.Fatal(err) + } +} diff --git a/pkg/xt/xt.go b/pkg/xt/xt.go index 063e892..447a724 100644 --- a/pkg/xt/xt.go +++ b/pkg/xt/xt.go @@ -110,10 +110,14 @@ func (j *Job) processArchive(folder, archive string) (string, uint64, []string, // If preserving the file hierarchy: set the output directory to the same path as the input file. if j.Preserve { - // Remove input path prefix from fileName, - // append fileName.Dir to job.Output, - // extract file into job.Output/file(sub)Folder(s). - file.OutputDir = filepath.Join(j.Output, filepath.Dir(strings.TrimPrefix(archive, folder))) + // Rel(search folder, archive) then join that directory onto job.Output. + // TrimPrefix leaves a leading separator, and Join treats that as absolute. + rel, err := filepath.Rel(folder, archive) + if err != nil { + rel = strings.TrimPrefix(archive, folder) + } + + file.OutputDir = filepath.Join(j.Output, filepath.Dir(rel)) } start := time.Now() diff --git a/pkg/xt/xt_test.go b/pkg/xt/xt_test.go index de14134..94f9e54 100644 --- a/pkg/xt/xt_test.go +++ b/pkg/xt/xt_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "os" "path/filepath" + "slices" "testing" "time" ) @@ -89,6 +90,122 @@ func TestExtractZip(t *testing.T) { } } +func TestGetArchivesFileVsDir(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + zipPath := filepath.Join(dir, "sample.zip") + createTestZip(t, zipPath, "hello.txt", "hello") + + job := &Job{Paths: []string{zipPath}} + got := job.getArchives() + + if got.Count() != 1 || len(got[zipPath]) != 1 || got[zipPath][0] != zipPath { + t.Fatalf("file archives = %#v", got) + } + + job = &Job{Paths: []string{dir}} + got = job.getArchives() + + if got.Count() != 1 || !slices.Contains(got[dir], zipPath) { + t.Fatalf("dir archives = %#v", got) + } +} + +func TestGetArchivesIncludeExclude(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + zipPath := filepath.Join(dir, "sample.zip") + isoPath := filepath.Join(dir, "sample.iso") + createTestZip(t, zipPath, "hello.txt", "hello") + writeFile(t, isoPath, "not-an-iso") + + included := (&Job{Paths: []string{dir}, Include: []string{".zip"}}).getArchives() + if included.Count() != 1 || !slices.Contains(included[dir], zipPath) { + t.Fatalf("include .zip = %#v", included) + } + + if slices.Contains(included[dir], isoPath) { + t.Fatalf("include .zip should skip iso: %#v", included) + } + + excluded := (&Job{Paths: []string{dir}, Exclude: []string{".zip"}}).getArchives() + if slices.Contains(excluded[dir], zipPath) { + t.Fatalf("exclude .zip still found zip: %#v", excluded) + } + + if excluded.Count() != 1 || !slices.Contains(excluded[dir], isoPath) { + t.Fatalf("exclude .zip = %#v", excluded) + } +} + +func TestGetArchivesMissingPath(t *testing.T) { + t.Parallel() + + got := (&Job{Paths: []string{filepath.Join(t.TempDir(), "missing")}}).getArchives() + if got.Count() != 0 { + t.Fatalf("missing path archives = %#v", got) + } +} + +func TestExtractPreservePaths(t *testing.T) { + t.Parallel() + + root := t.TempDir() + nested := filepath.Join(root, "nested") + + err := os.MkdirAll(nested, 0o750) + if err != nil { + t.Fatal(err) + } + + createTestZip(t, filepath.Join(nested, "sample.zip"), "hello.txt", "hello world") + + out := filepath.Join(root, "out") + Extract(&Job{Paths: []string{root}, Output: out, Preserve: true}) + + got, err := os.ReadFile(filepath.Join(out, "nested", "hello.txt")) //nolint:gosec + if err != nil { + t.Fatal(err) + } + + if string(got) != "hello world" { + t.Fatalf("extracted content = %q", got) + } +} + +func TestExtractSquashRoot(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + zipPath := filepath.Join(dir, "sample.zip") + createTestZip(t, zipPath, "root/hello.txt", "squashed") + + out := filepath.Join(dir, "out") + + err := os.MkdirAll(out, 0o750) + if err != nil { + t.Fatal(err) + } + + Extract(&Job{Paths: []string{zipPath}, Output: out, SquashRoot: true}) + + got, err := os.ReadFile(filepath.Join(out, "hello.txt")) //nolint:gosec + if err != nil { + t.Fatal(err) + } + + if string(got) != "squashed" { + t.Fatalf("extracted content = %q", got) + } + + _, err = os.Stat(filepath.Join(out, "root")) + if !os.IsNotExist(err) { + t.Fatalf("squash left root folder: %v", err) + } +} + func createTestZip(t *testing.T, zipPath, name, body string) { t.Helper()