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 }