Add exact-image compatibility policy, experimental admission, paginated build history and shared reauthentication checks. Preserve uncertain Docker creation records, bound frontend resource waits and renew runtime sessions proactively. Lazy-load and split Monaco, add regression and opt-in live Docker fault tests, and record full race and real-browser acceptance results.
826 lines
20 KiB
Go
826 lines
20 KiB
Go
package api
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"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 workspacePath(uid string) string { return path.Join("workspaces", uid, "files") }
|
|
|
|
func (a *API) openWorkspace(u store.User, instanceIDs ...string) (*os.Root, error) {
|
|
instanceID := ""
|
|
if len(instanceIDs) > 0 {
|
|
instanceID = instanceIDs[0]
|
|
}
|
|
base, err := os.OpenRoot(a.root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer base.Close()
|
|
parts := []string{"workspaces", path.Join("workspaces", u.ID), workspacePath(u.ID)}
|
|
if instanceID != "" {
|
|
rel := path.Join("workspaces", u.ID, "instances", instanceID, "home")
|
|
if err := rootSafe(base, rel, false); err != nil {
|
|
return nil, err
|
|
}
|
|
return base.OpenRoot(rel)
|
|
}
|
|
for _, p := range parts {
|
|
if err = rootSafe(base, p, true); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = base.Mkdir(p, 0750); err != nil && !errors.Is(err, os.ErrExist) {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err = base.Chown(workspacePath(u.ID), 1000, 1000); err != nil {
|
|
return nil, err
|
|
}
|
|
return base.OpenRoot(workspacePath(u.ID))
|
|
}
|
|
|
|
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" {
|
|
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()
|
|
}()
|
|
}
|
|
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 "usage":
|
|
a.wsUsage(w, r, root, u)
|
|
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)
|
|
default:
|
|
fail(w, 404, "not found")
|
|
}
|
|
}
|
|
|
|
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, root *os.Root, 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 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", "attachment")
|
|
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
|
|
}
|