264 lines
6.7 KiB
Go
264 lines
6.7 KiB
Go
package zipfs
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"io"
|
|
"testing"
|
|
)
|
|
|
|
// makeZip builds an in-memory zip. A value of "<dir>" 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 != "<dir>" {
|
|
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/": "<dir>", // 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/": "<dir>",
|
|
"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/": "<dir>",
|
|
})
|
|
|
|
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")
|
|
}
|
|
}
|