package api import ( "archive/tar" "compress/gzip" "fmt" "io" "net/http" "os" "path" "path/filepath" "strings" "werkdock/internal/engine" ) // maxArchiveEntries bounds what one PUT may unpack. Every top-level // entry becomes a bind mount and therefore two words of bwrap's argv; // a client that wants to populate a whole tree wants an image, not a // copy. const maxArchiveEntries = 64 // containerArchivePut is docker's PUT /containers/{id}/archive, the // call behind withCopyFileToContainer: a tar stream unpacked at the // directory named by ?path=. // // Werkdock does it differently underneath, because it has to. The // rootfs is the image's own directory in the store, shared by every // instance and bound read-only; unpacking into it would change the // image for everyone. So the tar is unpacked into the instance // directory and bound into the sandbox — in one of two ways, and the // image decides which: // // - The destination is empty in the image, or absent: the unpacked // directory is bound over it whole. Nothing is hidden, because // there was nothing there, and the image gets one empty directory // as a mountpoint. // - The image already fills the destination (/etc, say): each // unpacked file is bound to its own path instead, so the image's // files stay visible beside the copied ones — docker's merge. The // price is a mountpoint per file in the shared image directory, // empty files that other instances of the image will see. Werkdock // pre-creates mountpoints there for every bind (EnsureMountpoints); // this is that same wart, not a new one. // // A copied *directory* aimed at one the image fills cannot be // expressed either way — binding it would hide what is inside — and is // refused with the reason rather than done quietly. // // path=/ is the ordinary case, not a special one: docker's clients // send the whole path in the tar entries and / as the destination. The // rootfs is never empty, so that request always takes the second way // and lands one level down, where it belongs. // // The mounts are fixed when the sandbox starts, so this works on a // created instance and is refused on a running one, with the reason. func (s *Server) containerArchivePut(w http.ResponseWriter, r *http.Request) { inst, ok := s.instanceOf(w, r) if !ok { return } if inst.Running() { writeError(w, http.StatusNotImplemented, "cannot copy into a running instance: a sandbox's mounts are fixed when it starts, "+ "and werkdock has no writable container layer to put the file in. "+ "Copy before start (withCopyFileToContainer does), or write through a volume") return } dest := r.URL.Query().Get("path") if dest == "" { writeError(w, http.StatusBadRequest, "path is required") return } if !strings.HasPrefix(dest, "/") { dest = "/" + dest } dest = path.Clean(dest) body, err := maybeGunzip(r.Body) if err != nil { writeError(w, http.StatusBadRequest, "%v", err) return } root := filepath.Join(inst.Dir, "archive", filepath.FromSlash(strings.TrimPrefix(dest, "/"))) if err := os.MkdirAll(root, 0o755); err != nil { writeError(w, http.StatusInternalServerError, "%v", err) return } names, err := unpack(tar.NewReader(body), root) if err != nil { writeError(w, http.StatusBadRequest, "%v", err) return } spec := inst.Config.Spec if imageFills(spec.RootFS, dest) { // The image has files here; bind the copies one by one so its // own stay visible. for _, name := range names { source := filepath.Join(root, name) target := path.Join(dest, name) info, err := os.Stat(source) if err != nil { writeError(w, http.StatusInternalServerError, "%v", err) return } if info.IsDir() && imageFills(spec.RootFS, target) { writeError(w, http.StatusNotImplemented, "cannot copy a directory onto %s: the image already has files there, and werkdock "+ "would have to hide them — it binds the copy in rather than writing into the shared "+ "read-only image. Copy the files themselves, or use a volume", target) return } spec.Mounts = addMount(spec.Mounts, engine.Mount{Mode: engine.MountBind, Source: source, Dest: target}) } } else { // Nothing of the image's is at stake; one mount carries the lot, // and a second copy to the same path lands in the same directory. spec.Mounts = addMount(spec.Mounts, engine.Mount{Mode: engine.MountBind, Source: root, Dest: dest}) } if err := engine.EnsureMountpoints(spec); err != nil { writeError(w, http.StatusInternalServerError, "%v", err) return } inst.Config.Spec = spec if err := inst.SaveConfig(); err != nil { writeError(w, http.StatusInternalServerError, "%v", err) return } s.logf("copied %d entries into %s at %s", len(names), inst.ShortID(), dest) w.WriteHeader(http.StatusOK) } // unpack writes the tar below root and returns the top-level names, in // the order they arrived. Only regular files and directories: a // symlink or a device node in a copied archive would mean something // werkdock cannot honour, and saying so beats unpacking half of it. func unpack(tr *tar.Reader, root string) ([]string, error) { var names []string seen := map[string]bool{} count := 0 for { header, err := tr.Next() if err == io.EOF { break } if err != nil { return nil, fmt.Errorf("reading the archive: %w", err) } name := path.Clean(strings.TrimPrefix(header.Name, "./")) if name == "." || name == "/" { continue } target, err := safeJoin(root, name) if err != nil { return nil, err } count++ if count > maxArchiveEntries { return nil, fmt.Errorf("the archive holds more than %d entries; werkdock binds each one "+ "into the sandbox and will not build an argv that long", maxArchiveEntries) } switch header.Typeflag { case tar.TypeDir: if err := os.MkdirAll(target, 0o755); err != nil { return nil, err } case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return nil, err } f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode).Perm()) if err != nil { return nil, err } if _, err := io.Copy(f, tr); err != nil { f.Close() return nil, err } if err := f.Close(); err != nil { return nil, err } default: return nil, fmt.Errorf("%s is a %s; werkdock copies regular files and directories only, "+ "because everything else would have to mean something inside the sandbox that it cannot promise", header.Name, typeName(header.Typeflag)) } top := name if i := strings.Index(top, "/"); i >= 0 { top = top[:i] } if !seen[top] { seen[top] = true names = append(names, top) } } if len(names) == 0 { return nil, fmt.Errorf("the archive is empty") } return names, nil } func typeName(flag byte) string { switch flag { case tar.TypeSymlink: return "symlink" case tar.TypeLink: return "hard link" case tar.TypeChar, tar.TypeBlock: return "device node" case tar.TypeFifo: return "fifo" } return "special entry" } // imageFills reports whether the image has anything at dest, which is // what decides between the two ways of binding a copy in. func imageFills(rootfs, dest string) bool { target := filepath.Join(rootfs, filepath.FromSlash(strings.TrimPrefix(dest, "/"))) entries, err := os.ReadDir(target) return err == nil && len(entries) > 0 } // addMount keeps one mount per destination, so copying twice to the // same place does not bind it twice. func addMount(mounts []engine.Mount, m engine.Mount) []engine.Mount { for _, existing := range mounts { if existing.Dest == m.Dest { return mounts } } return append(mounts, m) } // safeJoin refuses an archive that tries to write outside its root. func safeJoin(root, name string) (string, error) { target := filepath.Join(root, filepath.FromSlash(name)) if target != root && !strings.HasPrefix(target, root+string(os.PathSeparator)) { return "", fmt.Errorf("the archive entry %q leads outside the destination", name) } return target, nil } // maybeGunzip accepts the compressed tar docker's clients may send. func maybeGunzip(r io.Reader) (io.Reader, error) { buffered := make([]byte, 2) n, err := io.ReadFull(r, buffered) if err == io.EOF || err == io.ErrUnexpectedEOF { return strings.NewReader(""), nil } if err != nil { return nil, err } joined := io.MultiReader(strings.NewReader(string(buffered[:n])), r) if buffered[0] == 0x1f && buffered[1] == 0x8b { return gzip.NewReader(joined) } return joined, nil }