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")
}
}