Files
dsh-web/internal/api/snapshot_download.go
T

231 lines
5.9 KiB
Go

package api
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"syscall"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
var errSnapshotDownloadLimit = errors.New("snapshot archive exceeds size, entry or depth limit")
var errUnsafeSnapshotArchive = errors.New("unsafe snapshot link or unsupported file type")
type snapshotArchiveWriter struct {
ctx context.Context
w io.Writer
remaining int64
}
func (w *snapshotArchiveWriter) Write(p []byte) (int, error) {
if err := w.ctx.Err(); err != nil {
return 0, err
}
if int64(len(p)) > w.remaining {
return 0, errSnapshotDownloadLimit
}
n, err := w.w.Write(p)
w.remaining -= int64(n)
return n, err
}
func snapshotArchive(ctx context.Context, root *os.Root, out io.Writer, limit int64) error {
// Bound headers, padding and gzip expansion independently of file content.
gz := gzip.NewWriter(&snapshotArchiveWriter{ctx, out, limit + (256 << 20)})
tw := tar.NewWriter(&snapshotArchiveWriter{ctx, gz, limit + (128 << 20)})
remaining := limit
entries := 100000
var walk func(*os.Root, string, string, int) error
walk = func(parent *os.Root, name, rel string, depth int) error {
if err := ctx.Err(); err != nil {
return err
}
entries--
if entries < 0 || depth > 128 {
return errSnapshotDownloadLimit
}
st, err := parent.Lstat(name)
if err != nil {
return err
}
link := ""
if st.Mode()&os.ModeSymlink != 0 {
link, err = parent.Readlink(name)
if err != nil {
return err
}
// Archive link text only; do not read or traverse its target.
} else if !st.IsDir() && !st.Mode().IsRegular() {
return errUnsafeSnapshotArchive
}
if st.Mode().IsRegular() && st.Size() > remaining {
return errSnapshotDownloadLimit
}
if st.Mode().IsRegular() {
remaining -= st.Size()
}
h, err := tar.FileInfoHeader(st, link)
if err != nil {
return err
}
h.Name = filepath.ToSlash(rel)
if st.IsDir() {
h.Name += "/"
}
if err = tw.WriteHeader(h); err != nil {
return err
}
if st.Mode()&os.ModeSymlink != 0 {
return nil
}
if st.IsDir() {
child, err := parent.OpenRoot(name)
if err != nil {
return err
}
defer child.Close()
actual, err := child.Stat(".")
if err != nil || !os.SameFile(st, actual) {
return errors.New("snapshot directory changed")
}
f, err := child.Open(".")
if err != nil {
return err
}
defer f.Close()
for {
children, err := f.ReadDir(128)
if err != nil && err != io.EOF {
return err
}
for _, entry := range children {
if err := walk(child, entry.Name(), filepath.Join(rel, entry.Name()), depth+1); err != nil {
return err
}
}
if err == io.EOF {
return nil
}
}
}
f, err := parent.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return err
}
defer f.Close()
actual, err := f.Stat()
if err != nil || !actual.Mode().IsRegular() || !os.SameFile(st, actual) || actual.Size() != st.Size() {
return errors.New("snapshot file changed")
}
if _, err = io.CopyN(tw, f, st.Size()); err != nil {
return err
}
after, err := f.Stat()
if err != nil || after.Size() != st.Size() || !after.ModTime().Equal(st.ModTime()) {
return errors.New("snapshot file changed")
}
return nil
}
if err := walk(root, ".", ".", 0); err != nil {
return err
}
if err := tw.Close(); err != nil {
return err
}
return gz.Close()
}
func (a *API) snapshotDownload(w http.ResponseWriter, r *http.Request, i store.Instance) {
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
ids := r.URL.Query()["download"]
if len(ids) != 1 || !snapshotIDPattern.MatchString(ids[0]) {
fail(w, 400, "invalid snapshot ID")
return
}
id := ids[0]
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()
archive, err := os.CreateTemp("", "dsh-snapshot-*.tar.gz")
if err != nil {
fail(w, 500, "temporary archive unavailable")
return
}
defer os.Remove(archive.Name())
defer archive.Close()
code := 500
err = func() error {
lock := a.instanceLocks.Acquire(i.ID)
defer lock.Release()
if err := ctx.Err(); err != nil {
return err
}
current, err := a.s.GetInstance(i.ID)
if err != nil || current.UID != i.UID {
code = 404
return errors.New("not found")
}
root, err := managementRoot(a.root, "workspaces", current.UID, "instances", current.ID, id)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
code = 404
}
if errors.Is(err, errUnsafeManagementPath) {
code = 400
}
return errors.New("snapshot unavailable or unsafe path")
}
defer root.Close()
return snapshotArchive(ctx, root, archive, upgradeSnapshotLimit)
}()
if err != nil {
if errors.Is(err, errUnsafeSnapshotArchive) {
code = 400
}
if errors.Is(err, errSnapshotDownloadLimit) {
code = 413
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
code = 408
}
fail(w, code, err.Error())
return
}
if ctx.Err() != nil {
fail(w, 408, "snapshot download timed out or canceled")
return
}
if _, err = archive.Seek(0, io.SeekStart); err != nil {
fail(w, 500, "archive unavailable")
return
}
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{})
a.s.AddAudit(i.UID, "snapshot.download", i.ID, id)
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": id + ".tar.gz"}))
http.ServeContent(w, r, id+".tar.gz", time.Time{}, archive)
}