// Package zipfs presents the contents of a .zip file on disk as a // browsable virtual directory tree: a JSON listing at any directory // level inside the zip, and on-demand extraction of a single member. // // It does no HTTP and no ACL — that's the caller's job (see // handler.ServeZip). The model mirrors what the browse tool already // does client-side with JSZip: the zip's central directory is a flat // list of member names; directory levels are reconstructed on top, // synthesising the intermediate directories that have no explicit // "/" entry of their own. // // Member names are sanitised against zip-slip (no "..", no absolute // paths, no backslashes); unsafe entries are silently dropped. package zipfs import ( "archive/zip" "io" "net/url" "path" "sort" "strings" "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/listing" ) // Open opens the zip file at zipAbsPath for reading. The caller must // Close the returned *zip.ReadCloser. Errors propagate os.Open errors // (use os.IsNotExist) and zip-format errors. func Open(zipAbsPath string) (*zip.ReadCloser, error) { return zip.OpenReader(zipAbsPath) } // cleanMember normalises a raw zip entry name. Returns ("", false) for // anything unsafe to surface: absolute paths, backslash separators, // and any path that escapes the zip root via "..". A trailing slash // (the zip convention for an explicit directory entry) is preserved. func cleanMember(name string) (string, bool) { if name == "" || strings.ContainsRune(name, '\\') || strings.HasPrefix(name, "/") { return "", false } isDir := strings.HasSuffix(name, "/") cl := path.Clean(name) if cl == "." || cl == ".." || strings.HasPrefix(cl, "../") { return "", false } if isDir && cl != "" { cl += "/" } return cl, true } // normPrefix turns a member-path string ("", "a", "a/b", "a/b/") into // the canonical "directory level" form: no leading or trailing slash. func normPrefix(p string) string { return strings.Trim(p, "/") } // Open locates a *file* member matching memberPath inside r and returns // a reader for its bytes plus its size and modtime. Matching is // case-insensitive on the full path (segment-for-segment), mirroring // the URL case-folding the rest of the server does. ok is false when // no file member matches (it may still be a directory level — call // List). // // memberPath must be the cleaned, slash-separated path with no leading // or trailing slash (e.g. "sub/doc.pdf"). func OpenMember(r *zip.Reader, memberPath string) (rc io.ReadCloser, size int64, mod time.Time, name string, ok bool) { want := normPrefix(memberPath) if want == "" { return nil, 0, time.Time{}, "", false } for _, f := range r.File { cl, good := cleanMember(f.Name) if !good { continue } if strings.HasSuffix(cl, "/") { continue // directory entry } if strings.EqualFold(cl, want) { h, err := f.Open() if err != nil { return nil, 0, time.Time{}, "", false } return h, int64(f.UncompressedSize64), f.Modified, path.Base(cl), true } } return nil, 0, time.Time{}, "", false } // IsDirLevel reports whether prefix names a directory level inside r. // The zip root ("") always qualifies. Otherwise it's true when there's // an explicit "/" entry, or any entry sits under "/". func IsDirLevel(r *zip.Reader, prefix string) bool { pfx := normPrefix(prefix) if pfx == "" { return true } under := pfx + "/" for _, f := range r.File { cl, ok := cleanMember(f.Name) if !ok { continue } // An explicit "/" entry confirms the level; so does any // entry that lives under it. A *file* entry named exactly pfx // does NOT make pfx a directory. if strings.HasSuffix(cl, "/") && strings.TrimSuffix(cl, "/") == pfx { return true } if strings.HasPrefix(strings.TrimSuffix(cl, "/"), under) { return true } } return false } // List returns the immediate children of the directory level named by // prefix ("" = the zip root) as Caddy-style FileInfo entries, with URL // = baseURL + escaped-name. Synthesised intermediate directories (no // explicit "/" entry) are included. valid is false when prefix // names no directory level in the zip (no entry is at-or-under it). // // baseURL should end with "/" — it's the URL prefix for this level // (the request path, e.g. "/P/staging/Foo.zip/" or // "/P/staging/Foo.zip/sub/"). func List(r *zip.Reader, prefix, baseURL string) ([]listing.FileInfo, bool) { pfx := normPrefix(prefix) var under string // the "/" string we strip, or "" at root if pfx != "" { under = pfx + "/" } type child struct { isDir bool size int64 mod time.Time mode uint32 explicit bool // backed by a real zip entry (vs. synthesised) } children := map[string]*child{} sawLevel := pfx == "" // the root level always exists for _, f := range r.File { cl, good := cleanMember(f.Name) if !good { continue } entryIsDir := strings.HasSuffix(cl, "/") bare := strings.TrimSuffix(cl, "/") if pfx != "" { if bare == pfx { // Entry is the prefix itself: an explicit "/" dir // entry confirms the level; a file with that exact name // is not a directory, so don't count it. if entryIsDir { sawLevel = true } continue } if !strings.HasPrefix(bare, under) { continue } } sawLevel = true rest := bare if pfx != "" { rest = strings.TrimPrefix(bare, under) } if rest == "" { continue } seg := rest nested := false if i := strings.IndexByte(rest, '/'); i >= 0 { seg = rest[:i] nested = true } c := children[seg] if c == nil { c = &child{} children[seg] = c } if nested { // seg is an intermediate directory on the way to a deeper // member. Synthesised unless an explicit entry upgrades it. c.isDir = true } else if entryIsDir { c.isDir = true c.explicit = true c.mod = f.Modified c.mode = uint32(f.Mode()) } else { // Immediate file child. (If a dir and a file share a name — // malformed zip — the file wins; harmless.) if !c.explicit || !c.isDir { c.isDir = false c.explicit = true c.size = int64(f.UncompressedSize64) c.mod = f.Modified c.mode = uint32(f.Mode()) } } } if !sawLevel { return nil, false } out := make([]listing.FileInfo, 0, len(children)) for name, c := range children { fi := listing.FileInfo{ Name: name, Size: c.size, ModTime: c.mod, Mode: c.mode, IsDir: c.isDir, URL: baseURL + url.PathEscape(name), } if c.isDir { fi.Name += "/" fi.URL += "/" } out = append(out, fi) } sort.Slice(out, func(i, j int) bool { if out[i].IsDir != out[j].IsDir { return out[i].IsDir // directories first } return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) return out, true } // ResolveCloser is a tiny convenience for the handler: open the zip, // returning the reader and a close func, or (nil, nil, err). func ResolveCloser(zipAbsPath string) (*zip.Reader, func() error, error) { rc, err := Open(zipAbsPath) if err != nil { return nil, nil, err } return &rc.Reader, rc.Close, nil } // IsZipName reports whether name has a ".zip" extension (case-insensitive). func IsZipName(name string) bool { return strings.EqualFold(path.Ext(name), ".zip") }