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

272 lines
8.2 KiB
Go

package api
import (
"archive/tar"
"bytes"
"os"
"path/filepath"
"testing"
"werkdock/internal/instance"
)
// tarball builds an archive the way a docker client sends one.
type entry struct {
name string
dir bool
link string
body string
}
func tarball(t *testing.T, entries ...entry) string {
t.Helper()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, e := range entries {
header := &tar.Header{Name: e.name, Mode: 0o644, Size: int64(len(e.body)), Typeflag: tar.TypeReg}
switch {
case e.dir:
header.Typeflag, header.Mode, header.Size = tar.TypeDir, 0o755, 0
case e.link != "":
header.Typeflag, header.Linkname, header.Size = tar.TypeSymlink, e.link, 0
}
if err := tw.WriteHeader(header); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(e.body)); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
return buf.String()
}
func createdInstance(t *testing.T, s *Server) string {
t.Helper()
w := do(t, s, "POST", "/v1.41/containers/create", `{"Image":"postgres:16-alpine"}`)
if w.Code != 201 {
t.Fatalf("create: %d %s", w.Code, w.Body.String())
}
return decode(t, w)["Id"].(string)
}
// A copied file is unpacked into the instance and bound to its place,
// rather than written into the image every instance shares.
func TestArchiveCopiesAFileWithoutTouchingTheImage(t *testing.T) {
s := testServer(t)
id := createdInstance(t, s)
body := tarball(t, entry{name: "init.sql", body: "create table t (i int);\n"})
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/docker-entrypoint-initdb.d", body)
if w.Code != 200 {
t.Fatalf("archive: %d %s", w.Code, w.Body.String())
}
inst, err := s.Manager.Get(id)
if err != nil {
t.Fatal(err)
}
var mount string
for _, m := range inst.Config.Spec.Mounts {
if m.Dest == "/docker-entrypoint-initdb.d" {
mount = m.Source
}
}
if mount == "" {
t.Fatalf("no mount for the copied file; mounts are %v", inst.Config.Spec.Mounts)
}
content, err := os.ReadFile(filepath.Join(mount, "init.sql"))
if err != nil {
t.Fatal(err)
}
if string(content) != "create table t (i int);\n" {
t.Fatalf("content is %q", content)
}
if !filepath.HasPrefix(mount, inst.Dir) {
t.Fatalf("the copy landed outside the instance: %s", mount)
}
// The image is shared, so the content must not be in it. The
// destination is empty in this image, so one directory mount
// carries the copy and only that mountpoint appears there.
rootfs, err := s.Store.RootFS("postgres:16-alpine")
if err != nil {
t.Fatal(err)
}
if mount != filepath.Join(inst.Dir, "archive", "docker-entrypoint-initdb.d") {
t.Fatalf("expected one directory mount, got %s", mount)
}
shared := filepath.Join(rootfs, "docker-entrypoint-initdb.d")
entries, err := os.ReadDir(shared)
if err != nil {
t.Fatalf("the mountpoint should exist in the image: %v", err)
}
if len(entries) != 0 {
t.Fatalf("the copy leaked into the shared image: %v", entries)
}
}
// The mount is fixed when the sandbox starts, so a running instance is
// told why rather than left to wonder.
func TestArchiveIntoARunningInstanceIsRefusedWithTheReason(t *testing.T) {
s := testServer(t)
id := createdInstance(t, s)
inst, err := s.Manager.Get(id)
if err != nil {
t.Fatal(err)
}
inst.State.Status = instance.StatusRunning
inst.State.PID = os.Getpid() // load() reconciles a dead supervisor away
if err := inst.SaveState(); err != nil {
t.Fatal(err)
}
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/tmp", tarball(t, entry{name: "x", body: "y"}))
if w.Code != 501 {
t.Fatalf("want 501, got %d %s", w.Code, w.Body.String())
}
if msg := w.Body.String(); !bytes.Contains([]byte(msg), []byte("fixed when it starts")) {
t.Fatalf("the refusal does not say why: %s", msg)
}
}
// Docker's clients put the whole path in the tar and send / as the
// destination — Testcontainers does. That must land one level down and
// never bind over the shared rootfs itself.
func TestArchiveToTheRootLandsOneLevelDown(t *testing.T) {
s := testServer(t)
id := createdInstance(t, s)
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/",
tarball(t,
entry{name: "docker-entrypoint-initdb.d", dir: true},
entry{name: "docker-entrypoint-initdb.d/init.sql", body: "select 1;\n"}))
if w.Code != 200 {
t.Fatalf("want 200, got %d %s", w.Code, w.Body.String())
}
inst, err := s.Manager.Get(id)
if err != nil {
t.Fatal(err)
}
var dest string
for _, m := range inst.Config.Spec.Mounts {
if m.Dest == "/" {
t.Fatalf("the rootfs itself was bound over: %v", m)
}
if m.Dest == "/docker-entrypoint-initdb.d" {
dest = m.Source
}
}
if dest == "" {
t.Fatalf("nothing was bound at the copied directory; mounts are %v", inst.Config.Spec.Mounts)
}
if _, err := os.Stat(filepath.Join(dest, "init.sql")); err != nil {
t.Fatal(err)
}
}
// A symlink in the archive would have to mean something inside the
// sandbox that werkdock cannot promise.
func TestArchiveRefusesWhatItCannotHonour(t *testing.T) {
s := testServer(t)
id := createdInstance(t, s)
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/tmp",
tarball(t, entry{name: "link", link: "/etc/passwd"}))
if w.Code != 400 {
t.Fatalf("want 400, got %d %s", w.Code, w.Body.String())
}
}
// An archive entry that climbs out of its destination is refused.
func TestArchiveRefusesAnEntryThatEscapes(t *testing.T) {
s := testServer(t)
id := createdInstance(t, s)
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/tmp",
tarball(t, entry{name: "../../escaped", body: "x"}))
if w.Code != 400 {
t.Fatalf("want 400, got %d %s", w.Code, w.Body.String())
}
}
// A directory the image fills is not bound over — that would hide what
// is inside — but descended into, until the two trees first differ.
// This is the shape mariadb's own config copy has.
func TestArchiveDescendsIntoADirectoryTheImageAlreadyFills(t *testing.T) {
s := testServer(t)
rootfs, err := s.Store.RootFS("postgres:16-alpine")
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(rootfs, "opt", "tools"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(rootfs, "opt", "tools", "shipped"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
id := createdInstance(t, s)
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/opt",
tarball(t, entry{name: "tools", dir: true}, entry{name: "tools/mine", body: "y"}))
if w.Code != 200 {
t.Fatalf("want 200, got %d %s", w.Code, w.Body.String())
}
inst, err := s.Manager.Get(id)
if err != nil {
t.Fatal(err)
}
for _, m := range inst.Config.Spec.Mounts {
if m.Dest == "/opt/tools" {
t.Fatalf("the image's own /opt/tools was hidden by a bind: %v", m)
}
}
var found bool
for _, m := range inst.Config.Spec.Mounts {
if m.Dest == "/opt/tools/mine" {
found = true
}
}
if !found {
t.Fatalf("the copied file was not bound; mounts are %v", inst.Config.Spec.Mounts)
}
// What the image shipped there is untouched and still visible.
if _, err := os.Stat(filepath.Join(rootfs, "opt", "tools", "shipped")); err != nil {
t.Fatalf("the image's own file went missing: %v", err)
}
}
// Where the image fills the destination, the copy is bound file by
// file, so what the image ships stays visible beside it.
func TestArchiveIntoAnImageFilledDirectoryBindsFileByFile(t *testing.T) {
s := testServer(t)
id := createdInstance(t, s)
w := do(t, s, "PUT", "/v1.41/containers/"+id+"/archive?path=/etc",
tarball(t, entry{name: "mine.conf", body: "key = value\n"}))
if w.Code != 200 {
t.Fatalf("archive: %d %s", w.Code, w.Body.String())
}
inst, err := s.Manager.Get(id)
if err != nil {
t.Fatal(err)
}
for _, m := range inst.Config.Spec.Mounts {
if m.Dest == "/etc" {
t.Fatalf("/etc was bound whole, which hides the image's own files: %v", m)
}
}
var found bool
for _, m := range inst.Config.Spec.Mounts {
if m.Dest == "/etc/mine.conf" {
found = true
}
}
if !found {
t.Fatalf("no mount for the copied file; mounts are %v", inst.Config.Spec.Mounts)
}
// The image's own /etc/passwd is still there, unbound and unhidden.
rootfs, _ := s.Store.RootFS("postgres:16-alpine")
if _, err := os.Stat(filepath.Join(rootfs, "etc", "passwd")); err != nil {
t.Fatalf("the image's own file went missing: %v", err)
}
}