Files
werkdock/internal/store/load_test.go
mhoennigandClaude Fable 5.1 05bb4e7ae5 load: docker save and OCI image archives, layers flattened (RFC 0004)
werkdock load -i ARCHIVE reads the docker save manifest (legacy and
OCI layout) or a plain OCI image layout, applies the layers in order
into one rootfs with whiteout handling, and keeps the OCI config as
the image metadata. Layer extraction is werkdock's own: every path is
resolved inside the rootfs the way the sandbox sees it, following
lower layers' symlinks and clamping .. at the root as a chroot does,
so a hostile layer can rewrite the image but never the host. gzip
layers in-process, zstd through the zstd binary. RepoTags name the
image, each further tag is a hardlinked copy; a multi-arch index picks
the host platform, otherwise the available ones are listed.

Verified against docker 27 save archives of busybox:1.36 and
alpine:3.20, run in the sandbox afterwards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:09:41 +02:00

365 lines
13 KiB
Go

package store
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// tarEntry is one member of a test layer.
type tarEntry struct {
name, content, link string
typ byte
mode int64
}
func tarBytes(t *testing.T, entries []tarEntry) []byte {
t.Helper()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, e := range entries {
hdr := &tar.Header{Name: e.name, Mode: e.mode, Typeflag: e.typ, Linkname: e.link}
if hdr.Mode == 0 {
hdr.Mode = 0o644
if e.typ == tar.TypeDir {
hdr.Mode = 0o755
}
}
if e.typ == 0 {
hdr.Typeflag = tar.TypeReg
}
hdr.Size = int64(len(e.content))
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(e.content)); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func gzipBytes(t *testing.T, raw []byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(raw); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// writeDockerSave builds a legacy-layout `docker save` archive at path:
// manifest.json, <id>.json config, <n>/layer.tar per layer.
func writeDockerSave(t *testing.T, path string, repoTags []string, config string, layers [][]byte) {
t.Helper()
var files []tarEntry
var layerPaths []string
for i, l := range layers {
name := strings.Repeat("a", 3) + string(rune('0'+i)) + "/layer.tar"
files = append(files, tarEntry{name: name, content: string(l)})
layerPaths = append(layerPaths, name)
}
files = append(files, tarEntry{name: "cfg.json", content: config})
manifest, _ := json.Marshal([]map[string]interface{}{{"Config": "cfg.json", "RepoTags": repoTags, "Layers": layerPaths}})
files = append(files, tarEntry{name: "manifest.json", content: string(manifest)})
if err := os.WriteFile(path, tarBytes(t, files), 0o644); err != nil {
t.Fatal(err)
}
}
func skipWithoutTar(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("tar"); err != nil {
t.Skip("tar not installed")
}
}
const testConfig = `{"architecture":"amd64","os":"linux","created":"2026-09-05T12:00:00Z",
"config":{"Env":["PATH=/usr/local/bin:/bin","JAVA_HOME=/opt/jdk"],"Entrypoint":["/bin/sh","-c"],"Cmd":["java -version"],"WorkingDir":"/work","Labels":{"k":"v"}}}`
func TestLoadFlattensLayersWithWhiteoutsAndKeepsTheConfig(t *testing.T) {
skipWithoutTar(t)
lower := tarBytes(t, []tarEntry{
{name: "bin/", typ: tar.TypeDir},
{name: "bin/sh", content: "#!lower sh", mode: 0o755},
{name: "etc/", typ: tar.TypeDir},
{name: "etc/old.conf", content: "gone in upper"},
{name: "etc/keep.conf", content: "kept"},
{name: "opt/", typ: tar.TypeDir},
{name: "opt/cache/", typ: tar.TypeDir},
{name: "opt/cache/a", content: "lower a"},
{name: "opt/cache/b", content: "lower b"},
{name: "usr/", typ: tar.TypeDir},
{name: "usr/bin/", typ: tar.TypeDir},
{name: "usr/bin/real", content: "real"},
{name: "sbin", typ: tar.TypeSymlink, link: "usr/bin"},
{name: "var/", typ: tar.TypeDir},
{name: "var/wasdir/", typ: tar.TypeDir},
{name: "var/wasdir/child", content: "child"},
})
upper := gzipBytes(t, tarBytes(t, []tarEntry{
{name: "bin/sh", content: "#!upper sh", mode: 0o755},
{name: "etc/.wh.old.conf"},
{name: "opt/cache/.wh..wh..opq"},
{name: "opt/cache/c", content: "upper c"},
{name: "sbin/viasymlink", content: "through the lower layer's symlink"},
{name: "var/wasdir", content: "now a file"},
{name: "usr/bin/hard", typ: tar.TypeLink, link: "usr/bin/real"},
}))
archive := filepath.Join(t.TempDir(), "saved.tar")
writeDockerSave(t, archive, []string{"example/app:1.0"}, testConfig, [][]byte{lower, upper})
st := Store{Root: t.TempDir()}
loaded, err := st.Load(archive, "")
if err != nil {
t.Fatal(err)
}
if strings.Join(loaded, ",") != "example/app:1.0" {
t.Errorf("loaded %v", loaded)
}
rootfs, err := st.RootFS("example/app:1.0")
if err != nil {
t.Fatal(err)
}
read := func(rel string) string {
raw, err := os.ReadFile(filepath.Join(rootfs, rel))
if err != nil {
return "ERR:" + err.Error()
}
return string(raw)
}
checks := map[string]string{
"bin/sh": "#!upper sh",
"etc/keep.conf": "kept",
"opt/cache/c": "upper c",
"usr/bin/viasymlink": "through the lower layer's symlink",
"var/wasdir": "now a file",
"usr/bin/hard": "real",
}
for rel, want := range checks {
if got := read(rel); got != want {
t.Errorf("%s: got %q, want %q", rel, got, want)
}
}
for _, gone := range []string{"etc/old.conf", "opt/cache/a", "opt/cache/b", "etc/.wh.old.conf", "opt/cache/.wh..wh..opq"} {
if _, err := os.Lstat(filepath.Join(rootfs, gone)); !os.IsNotExist(err) {
t.Errorf("%s should not exist: %v", gone, err)
}
}
if fi, err := os.Stat(filepath.Join(rootfs, "bin/sh")); err != nil || fi.Mode().Perm() != 0o755 {
t.Errorf("bin/sh mode: %v %v", fi, err)
}
if link, err := os.Readlink(filepath.Join(rootfs, "sbin")); err != nil || link != "usr/bin" {
t.Errorf("sbin symlink: %q %v", link, err)
}
meta, err := st.Meta("example/app:1.0")
if err != nil {
t.Fatal(err)
}
if meta.Architecture != "amd64" || meta.OS != "linux" || meta.CreatedAt.Year() != 2026 ||
strings.Join(meta.Config.Entrypoint, " ") != "/bin/sh -c" || strings.Join(meta.Config.Cmd, " ") != "java -version" ||
meta.Config.WorkingDir != "/work" || len(meta.Config.Env) != 2 || meta.Config.Labels["k"] != "v" {
t.Errorf("metadata: %+v", meta)
}
if !strings.HasSuffix(meta.Source, "saved.tar") {
t.Errorf("source: %q", meta.Source)
}
if _, err := os.Stat(filepath.Join(st.Root, "images", "example", "app", "1.0", "image.json")); err != nil {
t.Errorf("image.json missing: %v", err)
}
leftovers, _ := filepath.Glob(filepath.Join(st.Root, "images", ".load-*"))
if len(leftovers) != 0 {
t.Errorf("staging left behind: %v", leftovers)
}
}
func TestLoadContainsEscapingPathsInsideTheRootfs(t *testing.T) {
skipWithoutTar(t)
// Every escape attempt resolves the way it would inside the sandbox:
// ".." above the root is the root, so the files land in the rootfs
// and nothing is written elsewhere in the store.
layer := tarBytes(t, []tarEntry{
{name: "../../dotdot", content: "x"},
{name: "escape", typ: tar.TypeSymlink, link: "../../.."},
{name: "escape/etc/passwd", content: "via relative symlink"},
{name: "abs", typ: tar.TypeSymlink, link: "/../.."},
{name: "abs/via-abs", content: "via absolute symlink"},
{name: "real", content: "hardlink target"},
{name: "h", typ: tar.TypeLink, link: "escape/real"},
})
st := Store{Root: t.TempDir()}
archive := filepath.Join(t.TempDir(), "evil.tar")
writeDockerSave(t, archive, []string{"evil"}, testConfig, [][]byte{layer})
if _, err := st.Load(archive, ""); err != nil {
t.Fatal(err)
}
rootfs, _ := st.RootFS("evil")
for rel, want := range map[string]string{"dotdot": "x", "etc/passwd": "via relative symlink", "via-abs": "via absolute symlink", "h": "hardlink target"} {
if raw, err := os.ReadFile(filepath.Join(rootfs, rel)); err != nil || string(raw) != want {
t.Errorf("%s: %q %v", rel, raw, err)
}
}
entries, _ := os.ReadDir(filepath.Join(st.Root, "images"))
for _, e := range entries {
if e.Name() != "evil" {
t.Errorf("unexpected entry beside the image: %s", e.Name())
}
}
if entries, _ := os.ReadDir(st.Root); len(entries) != 1 {
t.Errorf("store root polluted: %v", entries)
}
}
func TestLoadNamesFromTheArchiveOrTheCaller(t *testing.T) {
skipWithoutTar(t)
layer := tarBytes(t, []tarEntry{{name: "hello", content: "hi"}})
st := Store{Root: t.TempDir()}
// Two RepoTags: the second is a hardlinked copy of the first.
tagged := filepath.Join(t.TempDir(), "tagged.tar")
writeDockerSave(t, tagged, []string{"app:1.0", "app:latest"}, testConfig, [][]byte{layer})
loaded, err := st.Load(tagged, "")
if err != nil {
t.Fatal(err)
}
if strings.Join(loaded, ",") != "app:1.0,app" {
t.Errorf("loaded %v", loaded)
}
for _, ref := range []string{"app:1.0", "app"} {
if _, err := st.RootFS(ref); err != nil {
t.Errorf("%s: %v", ref, err)
}
}
// No RepoTags: the caller must name it.
unnamed := filepath.Join(t.TempDir(), "unnamed.tar")
writeDockerSave(t, unnamed, nil, testConfig, [][]byte{layer})
if _, err := st.Load(unnamed, ""); err == nil || !strings.Contains(err.Error(), "no name") {
t.Errorf("got %v, want a no-name refusal", err)
}
loaded, err = st.Load(unnamed, "named:v2")
if err != nil || strings.Join(loaded, ",") != "named:v2" {
t.Errorf("loaded %v, %v", loaded, err)
}
// An existing image is not replaced.
if _, err := st.Load(tagged, ""); err == nil || !strings.Contains(err.Error(), "already exists") {
t.Errorf("got %v, want an already-exists refusal", err)
}
}
func TestLoadReadsAPlainOCILayoutAndPicksTheHostPlatform(t *testing.T) {
skipWithoutTar(t)
layer := gzipBytes(t, tarBytes(t, []tarEntry{{name: "hello", content: "from oci"}}))
config := testConfig
otherConfig := strings.Replace(testConfig, "amd64", "s390x", 1)
manifestFor := func(cfg string) string {
return `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",
"config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:` + cfg + `"},
"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:layer1"}]}`
}
index := `{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:nested",
"annotations":{"org.opencontainers.image.ref.name":"oci/app:2"}}]}`
nested := `{"schemaVersion":2,"manifests":[
{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:m-other","platform":{"os":"linux","architecture":"s390x"}},
{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:m-host","platform":{"os":"linux","architecture":"amd64"}}]}`
files := []tarEntry{
{name: "oci-layout", content: `{"imageLayoutVersion":"1.0.0"}`},
{name: "index.json", content: index},
{name: "blobs/sha256/nested", content: nested},
{name: "blobs/sha256/m-host", content: manifestFor("cfg-host")},
{name: "blobs/sha256/m-other", content: manifestFor("cfg-other")},
{name: "blobs/sha256/cfg-host", content: config},
{name: "blobs/sha256/cfg-other", content: otherConfig},
{name: "blobs/sha256/layer1", content: string(layer)},
}
archive := filepath.Join(t.TempDir(), "oci.tar")
if err := os.WriteFile(archive, tarBytes(t, files), 0o644); err != nil {
t.Fatal(err)
}
st := Store{Root: t.TempDir()}
loaded, err := st.Load(archive, "")
if err != nil {
t.Fatal(err)
}
if strings.Join(loaded, ",") != "oci/app:2" {
t.Errorf("loaded %v", loaded)
}
rootfs, _ := st.RootFS("oci/app:2")
if raw, err := os.ReadFile(filepath.Join(rootfs, "hello")); err != nil || string(raw) != "from oci" {
t.Errorf("hello: %q %v", raw, err)
}
meta, _ := st.Meta("oci/app:2")
if meta.Architecture != "amd64" {
t.Errorf("picked the wrong platform: %+v", meta)
}
// An index without the host platform names what it has.
noHost := strings.Replace(nested, `"amd64"`, `"arm64"`, 1)
files[2].content = noHost
if err := os.WriteFile(archive, tarBytes(t, files), 0o644); err != nil {
t.Fatal(err)
}
_, err = Store{Root: t.TempDir()}.Load(archive, "")
if err == nil || !strings.Contains(err.Error(), "available: linux/arm64, linux/s390x") {
t.Errorf("got %v, want the available platforms", err)
}
}
func TestLoadRefusesAPlainRootfsArchive(t *testing.T) {
skipWithoutTar(t)
archive := filepath.Join(t.TempDir(), "rootfs.tar.gz")
writeTestArchive(t, archive)
_, err := Store{Root: t.TempDir()}.Load(archive, "")
if err == nil || !strings.Contains(err.Error(), "werkdock import") {
t.Errorf("got %v, want a pointer to import", err)
}
}
func TestResolveInRootfs(t *testing.T) {
rootfs := t.TempDir()
if err := os.MkdirAll(filepath.Join(rootfs, "usr", "bin"), 0o755); err != nil {
t.Fatal(err)
}
for link, target := range map[string]string{"bin": "usr/bin", "abs": "/usr", "loop": "loop", "out": "../.."} {
if err := os.Symlink(target, filepath.Join(rootfs, link)); err != nil {
t.Fatal(err)
}
}
tests := []struct{ in, want, wantErr string }{
{"/bin/sh", "usr/bin/sh", ""},
{"/abs/bin/x", "usr/bin/x", ""},
{"/new/dir/file", "new/dir/file", ""},
{"/../x", "x", ""},
{"/usr/../etc", "etc", ""},
{"/", "", ""},
{"/loop/x", "", "too many levels"},
{"/out/x", "x", ""}, // .. above the root stays at the root
}
for _, tt := range tests {
got, err := resolveInRootfs(rootfs, tt.in)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("%s: got %q, %v; want error %q", tt.in, got, err, tt.wantErr)
}
continue
}
if err != nil || got != filepath.Join(rootfs, tt.want) {
t.Errorf("%s: got %q, %v; want %q", tt.in, got, err, filepath.Join(rootfs, tt.want))
}
}
}