Files
werkdock/internal/api/archive.go
mhoennigandClaude Opus 5 eee4d5184c
werkator build succeeded after 00:03
A file into a container, without writing into the shared image
PUT /containers/{id}/archive was the last endpoint Testcontainers asks
for that werkdock answered with a 501. It is what withCopyFileToContainer
sends, and with it an image's own init mechanism works: a script copied
to /docker-entrypoint-initdb.d is run by the postgres entrypoint.

Docker unpacks the tar into the container's writable layer. Werkdock has
none, and its rootfs is the image's own directory in the store, shared by
every instance and bound read-only — unpacking there would change the
image for everyone. So the tar goes into the instance directory and is
bound into the sandbox, one of two ways, and the image decides which:
whole where the image has nothing at the destination, file by file where
it has something, so its own files stay visible beside the copied ones.
That is docker's merge semantics, reached by another road.

The real client corrected the design once: Testcontainers sends path=/
and carries the whole path in the tar entry names. An afternoon's
assumption that / should be refused would have refused every copy there
is. The measured call is now written down in docs/testcontainers-api.md,
where the guesses can be checked against it.

Refused rather than ignored: a copy into a running instance (mounts are
fixed at start), a directory onto one the image fills (the bind would
hide it), symlinks and device nodes, entries that climb out of the
destination, and archives above 64 entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 07:53:24 +02:00

261 lines
8.4 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 — 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
}