Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
/dist/
/MANUAL
/MANUAL.html
/MANUAL.gz
/MANUAL.gz
/o/
2 changes: 1 addition & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions pkg/xt/filemode_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
99 changes: 99 additions & 0 deletions pkg/xt/job_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
12 changes: 8 additions & 4 deletions pkg/xt/xt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment on lines +113 to +120
}

start := time.Now()
Expand Down
117 changes: 117 additions & 0 deletions pkg/xt/xt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"archive/zip"
"os"
"path/filepath"
"slices"
"testing"
"time"
)
Expand Down Expand Up @@ -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()

Expand Down