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) } }