package api import ( "archive/zip" "context" "crypto/sha256" "encoding/json" "errors" "fmt" "git.misaka.ren/m1saka/dsh-web/internal/store" "io" "mime" "net/http" "os" "path" "sort" "strings" "syscall" "time" "unicode" "unicode/utf8" "golang.org/x/sys/unix" ) const maxUpload = 32 << 20 var uploadSlots = make(chan struct{}, 4) const ( maxZipEntries = 10000 maxZipDepth = 64 maxZipBytes int64 = 10 << 30 zipTimeout = 2 * time.Minute ) var zipSlots = make(chan struct{}, 4) var errZipLimit = errors.New("archive limit exceeded") func renameNoReplace(root *os.Root, old, next string) error { anchor, err := root.Open(".") if err != nil { return err } defer anchor.Close() openParent := func(p string) (int, error) { return unix.Openat2(int(anchor.Fd()), path.Dir(p), &unix.OpenHow{ Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS, }) } src, err := openParent(old) if err != nil { return err } defer unix.Close(src) dst, err := openParent(next) if err != nil { return err } defer unix.Close(dst) var st unix.Stat_t if err = unix.Fstatat(src, path.Base(old), &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { return err } if st.Mode&unix.S_IFMT == unix.S_IFLNK { return errors.New("unsafe path") } return unix.Renameat2(src, path.Base(old), dst, path.Base(next), unix.RENAME_NOREPLACE) } func safeRel(p string) (string, error) { p = strings.TrimSpace(strings.ReplaceAll(p, `\`, `/`)) if p == "" { return ".", nil } if strings.HasPrefix(p, "/") { return "", errors.New("invalid path") } c := path.Clean(p) if c == ".." || strings.HasPrefix(c, "../") { return "", errors.New("invalid path") } return c, nil } func (a *API) openWorkspace(u store.User, instanceID string) (*os.Root, error) { if instanceID == "" { return nil, errors.New("instance required") } base, err := os.OpenRoot(a.root) if err != nil { return nil, err } defer base.Close() rel := path.Join("workspaces", u.ID, "instances", instanceID, "home") if err := rootSafe(base, rel, false); err != nil { return nil, err } return base.OpenRoot(rel) } func rootSafe(root *os.Root, rel string, finalMayMissing bool) error { if rel == "." { return nil } parts := strings.Split(rel, "/") for i := range parts { q := strings.Join(parts[:i+1], "/") st, err := root.Lstat(q) if err != nil { if finalMayMissing && i == len(parts)-1 && errors.Is(err, os.ErrNotExist) { return nil } return err } if st.Mode()&os.ModeSymlink != 0 || (i < len(parts)-1 && !st.IsDir()) { return errors.New("unsafe path") } } return nil } func (a *API) workspace(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/ws/download" && r.URL.Path != "/api/ws/image" { var flush func() w, flush = deferJSONResponse(w) defer flush() } u, _, err := a.user(r) if err != nil { fail(w, 401, "authentication required") return } instanceID := strings.TrimSpace(r.URL.Query().Get("instance")) if instanceID == "" && r.URL.Path != "/api/ws/usage" { fail(w, http.StatusGone, "shared files removed; instance required") return } if instanceID != "" { // Pin this instance's home without holding the instance lifecycle lock // across client reads/writes. Lifecycle mutations reject active leases. inst := a.instanceLocks.Acquire(instanceID) i, e := a.s.GetInstance(instanceID) if e != nil || i.UID != u.ID { inst.Release() fail(w, 404, "instance not found") return } if r.Method != http.MethodGet && (i.Status == "error" || a.upgradeRecoveryRequired(i)) { inst.Release() fail(w, 409, "instance requires recovery") return } a.readersMu.Lock() if a.workspaceReaders == nil { a.workspaceReaders = make(map[string]int) } a.workspaceReaders[instanceID]++ a.readersMu.Unlock() inst.Release() defer func() { a.readersMu.Lock() if a.workspaceReaders != nil { a.workspaceReaders[instanceID]-- if a.workspaceReaders[instanceID] == 0 { delete(a.workspaceReaders, instanceID) } } a.readersMu.Unlock() }() } if r.URL.Path == "/api/ws/usage" { a.wsUsage(w, r, u) return } user := a.userLocks.Acquire(u.ID) root, err := a.openWorkspace(u, instanceID) user.Release() if err != nil { fail(w, 500, "storage unavailable") return } defer root.Close() switch strings.TrimPrefix(r.URL.Path, "/api/ws/") { case "tree": a.wsTree(w, r, root, u) case "mkdir": a.wsMkdir(w, r, root, u) case "rename": a.wsRename(w, r, root, u) case "delete": a.wsDelete(w, r, root, u) case "upload": a.wsUpload(w, r, root, u) case "download": a.wsDownload(w, r, root) case "image": a.wsImage(w, r, root) case "text": a.wsText(w, r, root, u) default: fail(w, 404, "not found") } } const maxTextBytes = 1 << 20 func textStatus(content string) int { if len(content) > maxTextBytes { return 413 } if !utf8.ValidString(content) { return 415 } for _, c := range content { if unicode.IsControl(c) && c != 9 && c != 10 && c != 13 { return 415 } } return 0 } func textVersion(content string, st os.FileInfo) string { s := st.Sys().(*syscall.Stat_t) return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d:%d:%d:%d:%d:%d:%s", s.Dev, s.Ino, st.ModTime().UnixNano(), s.Mode, s.Uid, s.Gid, content)))) } func readText(parent *os.File, name string) (string, string, os.FileInfo, int) { f, err := openDownload(parent, name) if err != nil { return "", "", nil, 404 } defer f.Close() st, err := f.Stat() if err != nil || !st.Mode().IsRegular() { return "", "", nil, 415 } if st.Size() > maxTextBytes { return "", "", nil, 413 } b, err := io.ReadAll(io.LimitReader(f, maxTextBytes+1)) if err != nil { return "", "", nil, 500 } if code := textStatus(string(b)); code != 0 { return "", "", nil, code } after, err := f.Stat() if err != nil || st.Size() != after.Size() || !st.ModTime().Equal(after.ModTime()) || st.Sys().(*syscall.Stat_t).Ctim != after.Sys().(*syscall.Stat_t).Ctim { return "", "", nil, 409 } return string(b), textVersion(string(b), after), after, 0 } func (a *API) wsText(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { if r.Method != http.MethodGet && r.Method != http.MethodPut { fail(w, 405, "method not allowed") return } p := r.URL.Query().Get("path") if p == "" || path.IsAbs(p) || p == "." || p == ".." || strings.HasPrefix(p, "../") || path.Clean(p) != p || strings.ContainsRune(p, 0) { fail(w, 400, "invalid path") return } var x struct { Content *string `json:"content"` Version string `json:"version"` } if r.Method == http.MethodPut { b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 6*maxTextBytes+4096)) if err != nil { fail(w, 413, "text request too large") return } if !utf8.Valid(b) { fail(w, 415, "invalid UTF-8") return } if json.Unmarshal(b, &x) != nil || x.Content == nil || x.Version == "" { fail(w, 400, "content and version required") return } if code := textStatus(*x.Content); code != 0 { fail(w, code, "unsupported text or text too large") return } } user := a.userLocks.Acquire(u.ID) defer user.Release() anchor, err := root.Open(".") if err != nil { fail(w, 500, "storage unavailable") return } defer anchor.Close() fd, err := unix.Openat2(int(anchor.Fd()), path.Dir(p), &unix.OpenHow{ Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV, }) if err != nil { code := 404 if r.Method == http.MethodPut { code = 409 } fail(w, code, "parent unavailable") return } parent := os.NewFile(uintptr(fd), path.Dir(p)) defer parent.Close() content, version, st, code := readText(parent, path.Base(p)) if code != 0 { if r.Method == http.MethodPut && code == 404 { code = 409 } fail(w, code, "text file unavailable") return } if r.Method == http.MethodGet { writeJSON(w, map[string]string{"content": content, "version": version}) return } if version != x.Version { fail(w, 409, "file changed; reload before saving") return } if err := a.s.DB.QueryRow("SELECT quota FROM users WHERE id=?", u.ID).Scan(&u.Quota); err != nil { fail(w, 413, "quota unavailable") return } used, err := a.userUsage(u) if err != nil || int64(len(*x.Content)) > u.Quota-used { fail(w, 413, "quota exceeded or usage unavailable") return } tmp := ".text-" + store.ID() tfd, err := unix.Openat2(fd, tmp, &unix.OpenHow{Flags: unix.O_WRONLY | unix.O_CREAT | unix.O_EXCL | unix.O_CLOEXEC, Mode: 0600, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS}) if err != nil { fail(w, 500, "cannot prepare text save") return } f := os.NewFile(uintptr(tfd), tmp) defer f.Close() defer unix.Unlinkat(fd, tmp, 0) _, err = io.WriteString(f, *x.Content) s := st.Sys().(*syscall.Stat_t) if err == nil { err = f.Chown(int(s.Uid), int(s.Gid)) } if err == nil { err = unix.Fchmod(tfd, s.Mode&07777) } if err == nil { err = f.Sync() } next, statErr := f.Stat() if err != nil || statErr != nil { fail(w, 500, "cannot prepare text save") return } nextVersion := textVersion(*x.Content, next) _, latest, _, code := readText(parent, path.Base(p)) if code != 0 || latest != version { fail(w, 409, "file changed; reload before saving") return } // External writers do not take the user lock; this is not a filesystem CAS. if err := unix.Renameat(fd, tmp, fd, path.Base(p)); err != nil { fail(w, 500, "text save failed") return } a.s.AddAudit(u.ID, "workspace.text", p, "") writeJSON(w, map[string]string{"version": nextVersion}) } func entry(root *os.Root, rel string) (os.FileInfo, error) { if err := rootSafe(root, rel, false); err != nil { return nil, err } return root.Lstat(rel) } func (a *API) wsUsage(w http.ResponseWriter, r *http.Request, u store.User) { if r.Method != "GET" { fail(w, 405, "method not allowed") return } user := a.userLocks.Acquire(u.ID) n, e := a.userUsage(u) user.Release() if e != nil { fail(w, 500, "usage unavailable") return } writeJSON(w, map[string]int64{"used_bytes": n, "quota_bytes": u.Quota}) } func (a *API) wsTree(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { user := a.userLocks.Acquire(u.ID) locked := user defer func() { if locked != nil { locked.Release() } }() if r.Method != http.MethodGet { fail(w, 405, "method not allowed") return } rel, err := safeRel(r.URL.Query().Get("path")) if err != nil { fail(w, 400, "invalid path") return } st, err := entry(root, rel) if err != nil || !st.IsDir() { fail(w, 404, "not found") return } es, err := readRootDir(root, rel) if err != nil { if errors.Is(err, errDirectoryLimit) { fail(w, 413, "directory entry limit exceeded") return } fail(w, 404, "not found") return } out := make([]map[string]any, 0, len(es)) for _, de := range es { p := de.Name() if rel != "." { p = rel + "/" + p } st, err := entry(root, p) if err != nil || (!st.IsDir() && !st.Mode().IsRegular()) { continue } sz := int64(0) if !st.IsDir() { sz = st.Size() } out = append(out, map[string]any{"name": de.Name(), "path": p, "is_dir": st.IsDir(), "size": sz, "mod_time": st.ModTime()}) } sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) }) locked.Release() locked = nil writeJSON(w, out) } func (a *API) wsMkdir(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { if r.Method != http.MethodPost { fail(w, 405, "method not allowed") return } var x struct { Path string `json:"path"` } if json.NewDecoder(r.Body).Decode(&x) != nil { fail(w, 400, "invalid json") return } p, e := safeRel(x.Path) user := a.userLocks.Acquire(u.ID) locked := user defer func() { if locked != nil { locked.Release() } }() if e != nil || p == "." || rootSafe(root, path.Dir(p), false) != nil { fail(w, 400, "cannot create directory") return } if e = root.Mkdir(p, 0750); e != nil { fail(w, 400, "cannot create directory") return } if f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0); e == nil { _ = f.Chown(1000, 1000) f.Close() } locked.Release() locked = nil a.s.AddAudit(u.ID, "workspace.mkdir", p, "") writeJSON(w, map[string]bool{"ok": true}) } func (a *API) wsRename(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { if r.Method != http.MethodPost { fail(w, 405, "method not allowed") return } var x struct { Path string `json:"path"` NewPath string `json:"new_path"` } if json.NewDecoder(r.Body).Decode(&x) != nil { fail(w, 400, "invalid json") return } p, e := safeRel(x.Path) q, e2 := safeRel(x.NewPath) user := a.userLocks.Acquire(u.ID) locked := user defer func() { if locked != nil { locked.Release() } }() if e != nil || e2 != nil || p == "." || q == "." || rootSafe(root, p, false) != nil || rootSafe(root, path.Dir(q), false) != nil { fail(w, 400, "rename failed") return } st, e := root.Lstat(p) if e != nil || st.Mode()&os.ModeSymlink != 0 { fail(w, 400, "rename failed") return } if _, e = root.Lstat(q); e == nil { fail(w, 400, "rename failed") return } if e = renameNoReplace(root, p, q); e != nil { fail(w, 400, "rename failed") return } locked.Release() locked = nil a.s.AddAudit(u.ID, "workspace.rename", p, q) writeJSON(w, map[string]bool{"ok": true}) } func (a *API) wsDelete(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { user := a.userLocks.Acquire(u.ID) locked := user defer func() { if locked != nil { locked.Release() } }() if r.Method != http.MethodDelete { fail(w, 405, "method not allowed") return } p, e := safeRel(r.URL.Query().Get("path")) if e != nil || p == "." { fail(w, 400, "delete failed") return } st, e := entry(root, p) if e != nil || st.Mode()&os.ModeSymlink != 0 { fail(w, 400, "delete failed") return } if st.IsDir() { es, e := readRootDir(root, p) if e != nil || len(es) != 0 { fail(w, 400, "delete failed") return } } if e = root.Remove(p); e != nil { fail(w, 400, "delete failed") return } locked.Release() locked = nil a.s.AddAudit(u.ID, "workspace.delete", p, "") writeJSON(w, map[string]bool{"ok": true}) } func (a *API) wsUpload(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { if r.Method != "POST" { fail(w, 405, "method not allowed") return } select { case uploadSlots <- struct{}{}: defer func() { <-uploadSlots }() default: fail(w, 429, "too many concurrent uploads") return } usedBefore, usageErr := a.userUsage(u) if usageErr != nil || usedBefore >= u.Quota { fail(w, 413, "quota exceeded or usage unavailable") return } r.Body = http.MaxBytesReader(w, r.Body, maxUpload+(1<<20)) controller := http.NewResponseController(w) if err := controller.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil && !errors.Is(err, http.ErrNotSupported) { fail(w, 400, "upload unavailable") return } defer controller.SetReadDeadline(time.Time{}) if r.ParseMultipartForm(1<<20) != nil { fail(w, 400, "invalid upload") return } defer r.MultipartForm.RemoveAll() user := a.userLocks.Acquire(u.ID) locked := user defer func() { if locked != nil { locked.Release() } }() dir, e := safeRel(r.FormValue("path")) if e != nil || rootSafe(root, dir, false) != nil { fail(w, 400, "invalid directory") return } input, h, e := r.FormFile("file") if e != nil { fail(w, 400, "file required") return } defer input.Close() name, e := safeRel(h.Filename) if e != nil || name == "." || path.Base(name) != name { fail(w, 400, "invalid filename") return } if !uploadExtensionAllowed(name, os.Getenv("UPLOAD_ALLOWED_EXTENSIONS")) { fail(w, 415, "upload file type denied") return } target := path.Join(dir, name) if _, e = root.Lstat(target); !errors.Is(e, os.ErrNotExist) { fail(w, 409, "target exists") return } if err := a.s.DB.QueryRow("SELECT quota FROM users WHERE id=?", u.ID).Scan(&u.Quota); err != nil { fail(w, 413, "quota unavailable") return } used, e := a.userUsage(u) if e != nil || h.Size > maxUpload || used+h.Size > u.Quota { fail(w, 413, "quota or upload limit exceeded") return } tmp := path.Join(dir, ".upload-"+store.ID()) f, e := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if e != nil { fail(w, 400, "upload directory unavailable") return } defer root.Remove(tmp) n, e := io.Copy(f, io.LimitReader(input, maxUpload+1)) if e == nil && (n > maxUpload || used+n > u.Quota) { e = errors.New("quota exceeded") } if e == nil { e = f.Chown(1000, 1000) } if e == nil { e = f.Sync() } f.Close() if e == nil { e = renameNoReplace(root, tmp, target) } if errors.Is(e, os.ErrExist) { fail(w, 409, "target exists") return } if e != nil { fail(w, 400, "upload failed") return } locked.Release() locked = nil a.s.AddAudit(u.ID, "workspace.upload", target, "") writeJSON(w, map[string]bool{"ok": true}) } // Empty policy permits arbitrary workspace files; configured entries are exact // case-insensitive extensions, not MIME claims supplied by the client. func uploadExtensionAllowed(name, policy string) bool { if strings.TrimSpace(policy) == "" { return true } ext := strings.ToLower(path.Ext(name)) for _, entry := range strings.Split(policy, ",") { entry = strings.ToLower(strings.TrimSpace(entry)) if strings.HasPrefix(entry, ".") && len(entry) > 1 && ext == entry { return true } } return false } func (a *API) userUsage(u store.User) (int64, error) { base, e := os.OpenRoot(a.root) if e != nil { return 0, e } defer base.Close() root, e := base.OpenRoot(path.Join("workspaces", u.ID)) if errors.Is(e, os.ErrNotExist) { return 0, nil } if e != nil { return 0, e } defer root.Close() var total int64 count := 0 var walk func(string, int) error walk = func(p string, depth int) error { count++ if count > 100000 || depth > 128 { return errors.New("tree limit") } st, e := root.Lstat(p) if e != nil { return e } if st.Mode().IsRegular() { total += st.Size() return nil } if !st.IsDir() { return nil } f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0) if e != nil { return e } defer f.Close() for { ds, readErr := f.ReadDir(128) for _, d := range ds { if e = walk(path.Join(p, d.Name()), depth+1); e != nil { return e } } if readErr == io.EOF { return nil } if readErr != nil { return readErr } if count >= 100000 { return errors.New("tree limit") } } } e = walk(".", 0) return total, e } func (a *API) wsDownload(w http.ResponseWriter, r *http.Request, root *os.Root) { if r.Method != http.MethodGet { fail(w, 405, "method not allowed") return } p, e := safeRel(r.URL.Query().Get("path")) if e != nil { fail(w, 400, "invalid path") return } anchor, e := root.Open(".") if e != nil { fail(w, 404, "not found") return } defer anchor.Close() f, e := openDownload(anchor, p) if e != nil { fail(w, 404, "not found") return } defer f.Close() st, e := f.Stat() if e == nil && st.IsDir() { a.wsDownloadZip(w, r, f, p) return } if e != nil || !st.Mode().IsRegular() { fail(w, 400, "invalid file") return } w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(p)})) http.ServeContent(w, r, path.Base(p), st.ModTime(), f) } func openDownload(anchor *os.File, p string) (*os.File, error) { fd, err := unix.Openat2(int(anchor.Fd()), p, &unix.OpenHow{ Flags: unix.O_RDONLY | unix.O_NONBLOCK | unix.O_CLOEXEC, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV, }) if err != nil { return nil, err } f := os.NewFile(uintptr(fd), p) st, err := f.Stat() if err == nil && (!st.IsDir() && !st.Mode().IsRegular() || st.Mode().IsRegular() && st.Sys().(*syscall.Stat_t).Nlink != 1) { err = errors.New("unsafe file") } if err != nil { f.Close() return nil, err } return f, nil } type zipEntry struct { name string info os.FileInfo } func zipManifest(ctx context.Context, anchor *os.File) ([]zipEntry, error) { var entries []zipEntry var total int64 var walk func(string, int) error walk = func(p string, depth int) error { if err := ctx.Err(); err != nil { return err } if depth > maxZipDepth || len(entries) >= maxZipEntries { return errZipLimit } f, err := openDownload(anchor, p) if err != nil { return err } defer f.Close() st, err := f.Stat() if err != nil { return err } if p != "." { if st.Size() < 0 || st.Mode().IsRegular() && st.Size() > maxZipBytes-total { return errZipLimit } if st.Mode().IsRegular() { total += st.Size() } entries = append(entries, zipEntry{p, st}) } if !st.IsDir() { return nil } for { ds, err := f.ReadDir(128) if err != nil && err != io.EOF { return err } for _, d := range ds { if strings.ContainsAny(d.Name(), "\\:") { return errors.New("unsafe archive name") } if e := walk(path.Join(p, d.Name()), depth+1); e != nil { return e } } if err == io.EOF { return nil } } } err := walk(".", 0) return entries, err } type zipContextWriter struct { ctx context.Context w io.Writer } func (w zipContextWriter) Write(p []byte) (int, error) { if err := w.ctx.Err(); err != nil { return 0, err } return w.w.Write(p) } func streamZip(ctx context.Context, w io.Writer, anchor *os.File, entries []zipEntry) error { z := zip.NewWriter(zipContextWriter{ctx, w}) buf := make([]byte, 32<<10) for _, item := range entries { if err := ctx.Err(); err != nil { return err } f, err := openDownload(anchor, item.name) if err != nil { return err } err = func() error { defer f.Close() st, err := f.Stat() if err != nil { return err } if !os.SameFile(st, item.info) || st.Mode() != item.info.Mode() || st.Size() != item.info.Size() || !st.ModTime().Equal(item.info.ModTime()) { return errors.New("file changed") } h, err := zip.FileInfoHeader(st) if err != nil { return err } h.Name = item.name if st.IsDir() { h.Name += "/" } dst, err := z.CreateHeader(h) if err != nil || st.IsDir() { return err } n, err := io.CopyBuffer(dst, io.LimitReader(f, item.info.Size()), buf) if err == nil && n != item.info.Size() { err = io.ErrUnexpectedEOF } if err == nil { after, statErr := f.Stat() if statErr != nil { return statErr } if after.Size() != st.Size() || !after.ModTime().Equal(st.ModTime()) { return errors.New("file changed") } } return err }() if err != nil { return err } } return z.Close() } func (a *API) wsDownloadZip(w http.ResponseWriter, r *http.Request, anchor *os.File, p string) { select { case zipSlots <- struct{}{}: defer func() { <-zipSlots }() default: fail(w, 429, "too many archive downloads") return } ctx, cancel := context.WithTimeout(r.Context(), zipTimeout) defer cancel() controller := http.NewResponseController(w) if err := controller.SetWriteDeadline(time.Now().Add(zipTimeout)); err != nil && !errors.Is(err, http.ErrNotSupported) { fail(w, 500, "download unavailable") return } defer controller.SetWriteDeadline(time.Time{}) entries, err := zipManifest(ctx, anchor) if err != nil { code := 400 if errors.Is(err, errZipLimit) { code = 413 } fail(w, code, "cannot archive directory") return } name := path.Base(p) if p == "." { name = "workspace" } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name + ".zip"})) if err := streamZip(ctx, w, anchor, entries); err != nil { // A failed stream must not become a successful, silently incomplete archive. panic(http.ErrAbortHandler) } } const maxDirectoryEntries = 10000 var errDirectoryLimit = errors.New("directory entry limit exceeded") func readRootDir(root *os.Root, p string) ([]os.DirEntry, error) { f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0) if e != nil { return nil, e } defer f.Close() entries, err := f.ReadDir(maxDirectoryEntries + 1) if len(entries) > maxDirectoryEntries { return nil, errDirectoryLimit } if err == io.EOF { err = nil } return entries, err }