From 8b5b5962b1d6c455b829eac3c2b170861644e4aa Mon Sep 17 00:00:00 2001 From: Jeremy Edwards <1312331+jeremyje@users.noreply.github.com> Date: Wed, 15 Jun 2022 07:56:42 +0000 Subject: [PATCH] Fix nested directory listing for archives without explicit directory names. --- .gitignore | 1 + archiver.go | 6 +++ archiver_test.go | 40 +++++++++++++++++ fs.go | 8 +++- fs_test.go | 104 ++++++++++++++++++++++++++++++++++++++++++++- testdata/nodir.zip | Bin 0 -> 9447 bytes testdata/test.zip | Bin 388 -> 401 bytes 7 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 testdata/nodir.zip diff --git a/.gitignore b/.gitignore index 58accbac..69f688f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ _gitignore +__debug_bin diff --git a/archiver.go b/archiver.go index 4ad9d3ad..35cdc3d3 100644 --- a/archiver.go +++ b/archiver.go @@ -148,6 +148,9 @@ func nameOnDiskToNameInArchive(nameOnDisk, rootOnDisk, rootInArchive string) str // // For example, "a/b/c" => "b/c". func trimTopDir(dir string) string { + if len(dir) > 0 && dir[0] == '/' { + dir = dir[1:] + } if pos := strings.Index(dir, "/"); pos >= 0 { return dir[pos+1:] } @@ -159,6 +162,9 @@ func trimTopDir(dir string) string { // // For example, "a/b/c" => "a". func topDir(dir string) string { + if len(dir) > 0 && dir[0] == '/' { + dir = dir[1:] + } if pos := strings.Index(dir, "/"); pos >= 0 { return dir[:pos] } diff --git a/archiver_test.go b/archiver_test.go index 0f5e9f51..e4355540 100644 --- a/archiver_test.go +++ b/archiver_test.go @@ -7,6 +7,46 @@ import ( "testing" ) +func TestTrimTopDir(t *testing.T) { + for _, tc := range []struct { + input string + want string + }{ + {input: "a/b/c", want: "b/c"}, + {input: "a", want: "a"}, + {input: "abc/def", want: "def"}, + {input: "/abc/def", want: "def"}, + } { + tc := tc + t.Run(tc.input, func(t *testing.T) { + got := trimTopDir(tc.input) + if got != tc.want { + t.Errorf("want: '%s', got: '%s')", tc.want, got) + } + }) + } +} + +func TestTopDir(t *testing.T) { + for _, tc := range []struct { + input string + want string + }{ + {input: "a/b/c", want: "a"}, + {input: "a", want: "a"}, + {input: "abc/def", want: "abc"}, + {input: "/abc/def", want: "abc"}, + } { + tc := tc + t.Run(tc.input, func(t *testing.T) { + got := topDir(tc.input) + if got != tc.want { + t.Errorf("want: '%s', got: '%s')", tc.want, got) + } + }) + } +} + func TestFileIsIncluded(t *testing.T) { for i, tc := range []struct { included []string diff --git a/fs.go b/fs.go index d3464ca3..915a8fea 100644 --- a/fs.go +++ b/fs.go @@ -458,13 +458,17 @@ func (f ArchiveFS) ReadDir(name string) ([]fs.DirEntry, error) { // leaving them to be inferred from the names of files instead (issue #330) // so as we traverse deeper, we need to implicitly find subfolders within // this current directory and add fake entries to the output - remainingPath := strings.TrimPrefix(file.NameInArchive, name) + remainingPath := file.NameInArchive + + if name != "." { + remainingPath = strings.TrimPrefix(file.NameInArchive, name) + } nextDir := topDir(remainingPath) // if name in archive is "a/b/c" and root is "a", this becomes "b" (the implied folder to add) implicitDir := path.Join(name, nextDir) // the full path of the implied directory // create fake entry only if no entry currently exists (don't overwrite a real entry) if _, ok := entries[implicitDir]; !ok { - entries[implicitDir] = implicitDirEntry{implicitDir} + entries[implicitDir] = implicitDirEntry{nextDir} } return fs.SkipDir diff --git a/fs_test.go b/fs_test.go index ab4c85ed..dd86bc91 100644 --- a/fs_test.go +++ b/fs_test.go @@ -9,6 +9,8 @@ import ( "log" "net/http" "path" + "reflect" + "sort" "testing" ) @@ -40,9 +42,14 @@ func TestPathWithoutTopDir(t *testing.T) { } //go:generate zip testdata/test.zip go.mod +//go:generate zip -qr9 testdata/nodir.zip archiver.go go.mod cmd/arc/main.go .github/ISSUE_TEMPLATE/bug_report.md .github/FUNDING.yml README.md .github/workflows/ubuntu-latest.yml -//go:embed testdata/test.zip -var testZIP []byte +var ( + //go:embed testdata/test.zip + testZIP []byte + //go:embed testdata/nodir.zip + nodirZIP []byte +) func ExampleArchiveFS_Stream() { fsys := ArchiveFS{ @@ -70,3 +77,96 @@ func ExampleArchiveFS_Stream() { // go.mod // true } + +func TestArchiveFS_ReadDir(t *testing.T) { + for _, tc := range []struct { + name string + archive ArchiveFS + want map[string][]string + }{ + { + name: "test.zip", + archive: ArchiveFS{ + Stream: io.NewSectionReader(bytes.NewReader(testZIP), 0, int64(len(testZIP))), + Format: Zip{}, + }, + // unzip -l testdata/test.zip + want: map[string][]string{ + ".": {"go.mod"}, + }, + }, + { + name: "nodir.zip", + archive: ArchiveFS{ + Stream: io.NewSectionReader(bytes.NewReader(nodirZIP), 0, int64(len(nodirZIP))), + Format: Zip{}, + }, + // unzip -l testdata/nodir.zip + want: map[string][]string{ + ".": {".github", "README.md", "archiver.go", "cmd", "go.mod"}, + ".github": {"FUNDING.yml", "ISSUE_TEMPLATE", "workflows"}, + "cmd": {"arc"}, + }, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + fsys := tc.archive + for baseDir, wantLS := range tc.want { + baseDir := baseDir + wantLS := wantLS + t.Run(fmt.Sprintf("ReadDir(%s)", baseDir), func(t *testing.T) { + dis, err := fsys.ReadDir(baseDir) + if err != nil { + t.Error(err) + } + + dirs := []string{} + for _, di := range dis { + dirs = append(dirs, di.Name()) + } + + // Stabilize the sort order + sort.Strings(dirs) + + if !reflect.DeepEqual(wantLS, dirs) { + t.Errorf("ReadDir() got: %v, want: %v", dirs, wantLS) + } + }) + + // Uncomment to reproduce https://github.com/mholt/archiver/issues/340. + /* + t.Run(fmt.Sprintf("Open(%s)", baseDir), func(t *testing.T) { + f, err := fsys.Open(baseDir) + if err != nil { + t.Error(err) + } + + rdf, ok := f.(fs.ReadDirFile) + if !ok { + t.Fatalf("'%s' did not return a fs.ReadDirFile, %+v", baseDir, rdf) + } + + dis, err := rdf.ReadDir(-1) + if err != nil { + t.Fatal(err) + } + + dirs := []string{} + for _, di := range dis { + dirs = append(dirs, di.Name()) + } + + // Stabilize the sort order + sort.Strings(dirs) + + if !reflect.DeepEqual(wantLS, dirs) { + t.Errorf("Open().ReadDir(-1) got: %v, want: %v", dirs, wantLS) + } + }) + */ + } + }) + } +} diff --git a/testdata/nodir.zip b/testdata/nodir.zip new file mode 100644 index 0000000000000000000000000000000000000000..ccda2c5ba0872c88ccd718a46b90b1055b5c9a59 GIT binary patch literal 9447 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 literal 0 HcmV?d00001 diff --git a/testdata/test.zip b/testdata/test.zip index 8ead34501f7667967e49867d58935b1076eeaaed..88bbd4cc341bd97ab090c8e7426357dcf3526c3b 100644 GIT binary patch delta 355 zcmZo+p2%Du;LXe;!oa}5!SK!Ge8`vcjE~;~d1o0J7}ywO7}E3ga`RI{LpT|jH`J|6 z0^!mMZU#n{7t9O{V4`%QcV4rBK-=}FB5~WgD;k7MuNxW|h4mblwdb0%Pc6r3$(o## z_y5oM=##ujV@qf7tEUdeXM}HZbhV#MNjVW)ADSuDo0AuM^v7(T#Xr{A%@aA*o4r2g z;@JzvuGubmDZ0CbP1)K7Pn|VxQz(k!zC*JJ)_q^=ntm-1Qy#DV|ajC)K@$btP zozz;cXyNtrZxzq&y$nlqr#&`0^Y6p%xeWOeB-uAahub+m@tYR=Mvgz-b4_AnhxeVM l_dhK!`NTQe%KfI?_;Jfl3DJ}?9s7ywP8mB|1A literal 388 zcmWIWW@Zs#U|`^2_+nxb;@9#-=Mj*%hLM4RjX{PXJzp<3KP5DTlYx28*B6PKfw;7S zn}Lz#1v3K!m?)iax~SQJ$MyQx@QRyyT8xTkze`+@@b7ths-G!7esazsCF`>VI?66}7XK4HxxyoVlkKVsmbpK=RAh3vt^n+-phrb+*m3uB%_Xwdk^$Msz7R-nVs9&_~g+}6Drr@4UCQ~!~kKA1))7-y4a zy4ed(^P9HL%IrR~D(hWNf8&kcUlxSs9WJeZX21OOwL6=QGwc6I?{K=^vA%qEM*h^x z5_7o{tEbtX7vwR#+)?)a^|4J~0#?gylymy9rtqjsw$%~w9igl11H2iT