Files
mhoennigandClaude Opus 5 1e4c24d408
werkator build succeeded after 00:02
A copied tree meets the image where the two first differ (#10)
PUT /archive decided once, at the top level of the archive: a directory
the image filled was refused, because binding it would hide what was
inside. That was the right worry in the wrong place. Docker's clients put
the whole path in the tar entries, so MariaDBContainer copying its
configuration to /etc/mysql/conf.d arrives as a top-level "etc" — refused,
and the container never started at all.

The rule now descends instead of giving up: bind where the image has
nothing, descend where it has a directory, bind the file where it has a
file. The two trees meet at whatever depth they first differ, which is
docker's merge; mariadb's config binds one file four levels down and
/etc is left alone. The refusal that said werkdock "would have to hide
them" is gone with it, because there is no longer a case where it would.

Found by MariaDBContainer, which is now a test in werkprobe rather than
something I ran once by hand.

Squashed from branch archive-merge-deeper and pushed to main directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:31:39 +02:00

260 lines
8.2 KiB
Go

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, and the only question is how
// deep each bind sits. One rule answers it, applied to every entry:
//
// - The image has nothing at this path: bind it here, whole, and stop
// descending. Nothing can be hidden, because there is nothing there.
// - The image fills this directory: do not bind it — that would hide
// what is inside — but descend into it and ask the same question of
// each child.
// - It is a file: bind it. A file shadows only itself, which is what
// copying over one means.
//
// The result is docker's merge, reached by another road: the image's
// files stay visible beside the copied ones, at whatever depth the two
// trees first differ. Copying mariadb's config into /etc/mysql/conf.d
// binds one file four levels down and leaves /etc alone.
//
// path=/ is the ordinary case, not a special one: docker's clients send
// the whole path in the tar entries and / as the destination, and the
// rule walks down from there.
//
// The price is a mountpoint per bind, pre-created in the shared image
// directory (EnsureMountpoints) — empty files and directories that
// other instances of the image will see. Werkdock does that for every
// bind it has ever made; this is the same wart, not a new one.
//
// 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
}
if err := unpack(tar.NewReader(body), root); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
spec := inst.Config.Spec
// The rule starts at the destination itself: where the image has
// nothing there, one mount carries the whole copy.
mounts := []engine.Mount{{Mode: engine.MountBind, Source: root, Dest: dest}}
if imageFills(spec.RootFS, dest) {
if mounts, err = bindsFor(root, dest, spec.RootFS, nil); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
}
for _, m := range mounts {
spec.Mounts = addMount(spec.Mounts, m)
}
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 binds into %s at %s", len(mounts), inst.ShortID(), dest)
w.WriteHeader(http.StatusOK)
}
// unpack writes the tar below root. 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) error {
count := 0
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return 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 err
}
count++
if count > maxArchiveEntries {
return fmt.Errorf("the archive holds more than %d entries; werkdock binds them "+
"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 err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode).Perm())
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
default:
return 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))
}
}
if count == 0 {
return fmt.Errorf("the archive is empty")
}
return nil
}
// bindsFor decides where the copied tree meets the image: a bind as
// high as it can sit without hiding anything the image put there, and
// one level deeper wherever it would. See containerArchivePut.
func bindsFor(hostDir, containerDir, rootfs string, out []engine.Mount) ([]engine.Mount, error) {
entries, err := os.ReadDir(hostDir)
if err != nil {
return nil, err
}
for _, e := range entries {
source := filepath.Join(hostDir, e.Name())
target := path.Join(containerDir, e.Name())
if e.IsDir() && imageFills(rootfs, target) {
if out, err = bindsFor(source, target, rootfs, out); err != nil {
return nil, err
}
continue
}
out = append(out, engine.Mount{Mode: engine.MountBind, Source: source, Dest: target})
}
return out, 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
}