package zipfs import ( "archive/zip" "bytes" "io" "testing" ) // makeZip builds an in-memory zip. A value of "" creates an // explicit directory entry (name must end with "/"); anything else is // the file body. func makeZip(t *testing.T, entries map[string]string) *zip.Reader { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) for name, body := range entries { w, err := zw.Create(name) if err != nil { t.Fatalf("zip.Create(%q): %v", name, err) } if body != "" { if _, err := w.Write([]byte(body)); err != nil { t.Fatalf("write %q: %v", name, err) } } } if err := zw.Close(); err != nil { t.Fatalf("zip.Close: %v", err) } zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) if err != nil { t.Fatalf("zip.NewReader: %v", err) } return zr } func TestList(t *testing.T) { zr := makeZip(t, map[string]string{ "a/b.txt": "hello", "c.txt": "world", "d/": "", // explicit dir "d/e/f.pdf": "pdfbytes", "d/g.txt": "g", }) t.Run("root level", func(t *testing.T) { out, ok := List(zr, "", "/Z/") if !ok { t.Fatal("root level should be valid") } got := map[string]bool{} // name -> isDir for _, fi := range out { got[fi.Name] = fi.IsDir if fi.IsDir && fi.URL != "/Z/"+stripSlash(fi.Name)+"/" { t.Errorf("dir %q URL=%q", fi.Name, fi.URL) } if !fi.IsDir && fi.URL != "/Z/"+fi.Name { t.Errorf("file %q URL=%q", fi.Name, fi.URL) } } // Expect: a/ (synthesized dir), c.txt (file), d/ (explicit dir). if !got["a/"] || !got["d/"] { t.Errorf("missing dir children; got %v", got) } if isDir, ok := got["c.txt"]; !ok || isDir { t.Errorf("c.txt should be a file child; got %v", got) } if len(out) != 3 { t.Errorf("root children = %d, want 3: %v", len(out), got) } // Directories sort before files. if !out[0].IsDir || !out[1].IsDir || out[2].IsDir { t.Errorf("sort order wrong: %v", out) } }) t.Run("nested level with explicit dir entry", func(t *testing.T) { out, ok := List(zr, "d", "/Z/d/") if !ok { t.Fatal("d/ should be valid") } got := map[string]bool{} for _, fi := range out { got[fi.Name] = fi.IsDir } if !got["e/"] { t.Errorf("d/ should contain synthesized e/; got %v", got) } if isDir, ok := got["g.txt"]; !ok || isDir { t.Errorf("d/ should contain file g.txt; got %v", got) } }) t.Run("deep level without explicit dir entry", func(t *testing.T) { out, ok := List(zr, "a", "/Z/a/") if !ok { t.Fatal("a/ should be valid (only known via a/b.txt)") } if len(out) != 1 || out[0].Name != "b.txt" || out[0].IsDir { t.Errorf("a/ children = %v, want [b.txt]", out) } }) t.Run("nonexistent level", func(t *testing.T) { if _, ok := List(zr, "nope", "/Z/nope/"); ok { t.Error("nope should not be a valid level") } // A file name is not a directory level. if _, ok := List(zr, "c.txt", "/Z/c.txt/"); ok { t.Error("c.txt is a file, not a directory level") } }) t.Run("URL escaping", func(t *testing.T) { zr2 := makeZip(t, map[string]string{"my doc.pdf": "x", "sub dir/k.txt": "y"}) out, _ := List(zr2, "", "/Z/") for _, fi := range out { if fi.Name == "my doc.pdf" && fi.URL != "/Z/my%20doc.pdf" { t.Errorf("file URL not escaped: %q", fi.URL) } if fi.Name == "sub dir/" && fi.URL != "/Z/sub%20dir/" { t.Errorf("dir URL not escaped: %q", fi.URL) } } }) } func stripSlash(s string) string { if len(s) > 0 && s[len(s)-1] == '/' { return s[:len(s)-1] } return s } func TestIsDirLevel(t *testing.T) { zr := makeZip(t, map[string]string{ "a/b.txt": "x", "c.txt": "y", "d/": "", "d/e/f.pdf": "z", }) cases := map[string]bool{ "": true, // root "a": true, // implied (a/b.txt) "a/": true, // trailing slash tolerated "d": true, // explicit "d/e": true, // implied (d/e/f.pdf) "c.txt": false, // a file, not a level "nope": false, "a/b": false, // a/b is a file (a/b.txt is a/b/... no — "a/b.txt", so "a/b" prefix? "a/b.txt" starts with "a/b" but not "a/b/") } for prefix, want := range cases { if got := IsDirLevel(zr, prefix); got != want { t.Errorf("IsDirLevel(%q) = %v, want %v", prefix, got, want) } } } func TestOpenMember(t *testing.T) { zr := makeZip(t, map[string]string{ "a/b.txt": "hello world", "c.txt": "ccc", "d/e/f.pdf": "pdf-bytes", "d/": "", }) t.Run("file member", func(t *testing.T) { rc, size, _, name, ok := OpenMember(zr, "a/b.txt") if !ok { t.Fatal("a/b.txt should be found") } defer rc.Close() if name != "b.txt" { t.Errorf("name=%q, want b.txt", name) } if size != int64(len("hello world")) { t.Errorf("size=%d, want %d", size, len("hello world")) } b, _ := io.ReadAll(rc) if string(b) != "hello world" { t.Errorf("body=%q", b) } }) t.Run("case-insensitive", func(t *testing.T) { rc, _, _, _, ok := OpenMember(zr, "D/E/F.PDF") if !ok { t.Fatal("D/E/F.PDF should match d/e/f.pdf") } rc.Close() }) t.Run("directory entry is not a member", func(t *testing.T) { if _, _, _, _, ok := OpenMember(zr, "d"); ok { t.Error("d/ is a directory, not a file member") } if _, _, _, _, ok := OpenMember(zr, "a"); ok { t.Error("a is a directory level, not a file member") } }) t.Run("missing", func(t *testing.T) { if _, _, _, _, ok := OpenMember(zr, "no/such.txt"); ok { t.Error("missing member reported as found") } if _, _, _, _, ok := OpenMember(zr, ""); ok { t.Error("empty member should not match") } }) } func TestCleanMemberRejectsZipSlip(t *testing.T) { bad := []string{ "../evil.txt", "a/../../evil.txt", "/abs/evil.txt", "a\\b.txt", "..", "", } for _, n := range bad { if _, ok := cleanMember(n); ok { t.Errorf("cleanMember(%q) should be rejected", n) } } good := map[string]string{ "a/b.txt": "a/b.txt", "a/./b.txt": "a/b.txt", "dir/": "dir/", "x.txt": "x.txt", } for in, want := range good { got, ok := cleanMember(in) if !ok || got != want { t.Errorf("cleanMember(%q) = (%q, %v), want (%q, true)", in, got, ok, want) } } } func TestListIgnoresUnsafeEntries(t *testing.T) { // A zip whose central directory carries a malicious "../" entry // must not surface it. var buf bytes.Buffer zw := zip.NewWriter(&buf) for _, n := range []string{"good.txt", "../escape.txt", "sub/ok.txt"} { w, _ := zw.Create(n) w.Write([]byte("x")) } zw.Close() zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) if err != nil { t.Fatal(err) } out, _ := List(zr, "", "/Z/") for _, fi := range out { if fi.Name == "escape.txt" || fi.Name == "../escape.txt" { t.Errorf("unsafe entry surfaced: %q", fi.Name) } } if _, _, _, _, ok := OpenMember(zr, "../escape.txt"); ok { t.Error("unsafe member openable") } }