From 2f2b54fbe683f07c8b942f2f3eaaf49c4d959c20 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 29 Jul 2026 17:47:24 +0200 Subject: [PATCH 1/2] fix(transfer): resolve container paths against declared bind mounts The container-FS transferrer anchored both directions at the bundle's rootfs. That directory backs only the paths no mount covers: where the runtime spec declares a bind mount, the container's mount namespace has the source mounted over the destination, so the rootfs entry underneath is shadowed. Importing to such a path therefore produced a file the container never sees, and exporting from one archived whatever the rootfs happened to hold instead of the mounted content. Neither reported an error. resolveMountRoot reads the bundle spec and maps a container-view path onto the directory backing it, preferring the longest matching bind destination so a nested mount wins over its parent. Both directions go through it, keeping the import and export views consistent with the container's own. A bundle with no readable or parseable config.json resolves to the rootfs, so callers that supply no mount information are unaffected. Signed-off-by: Nicolas De Loof --- internal/transfer/containerfs.go | 82 +++++++++++- internal/transfer/containerfs_test.go | 171 ++++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 4 deletions(-) diff --git a/internal/transfer/containerfs.go b/internal/transfer/containerfs.go index 61baa4f0..cbb11281 100644 --- a/internal/transfer/containerfs.go +++ b/internal/transfer/containerfs.go @@ -19,6 +19,8 @@ package transfer import ( "archive/tar" "context" + "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -51,10 +53,14 @@ func (t *containerFSTransferrer) Transfer(ctx context.Context, src, dst any, opt if !ok { return errdefs.ErrNotImplemented } - rootfs := filepath.Join(t.bundleDir, s.ContainerID, "rootfs") + bundle := filepath.Join(t.bundleDir, s.ContainerID) + root, src, err := resolveMountRoot(bundle, s.Path) + if err != nil { + return err + } w := d.Writer(ctx) defer w.Close() - return writePath(rootfs, s.Path, w, d.MediaType, s.NoWalk) + return writePath(root, src, w, d.MediaType, s.NoWalk) case *ReadStream: // Copy-to: ReadStream -> ContainerPath @@ -62,14 +68,82 @@ func (t *containerFSTransferrer) Transfer(ctx context.Context, src, dst any, opt if !ok { return errdefs.ErrNotImplemented } - rootfs := filepath.Join(t.bundleDir, d.ContainerID, "rootfs") + bundle := filepath.Join(t.bundleDir, d.ContainerID) + root, dst, err := resolveMountRoot(bundle, d.Path) + if err != nil { + return err + } r := s.Reader(ctx) - return readPath(r, rootfs, d.Path, s.MediaType, d.PreserveOwnership) + return readPath(r, root, dst, s.MediaType, d.PreserveOwnership) } return errdefs.ErrNotImplemented } +// resolveMountRoot maps a path expressed in the container's view onto the +// directory that backs it, returning that directory and the path relative to +// it. +// +// The bundle's rootfs backs only the paths no mount covers. Where the runtime +// spec declares a bind mount, the container's mount namespace has the source +// mounted over the destination, so the rootfs entry underneath is shadowed: +// extracting there produces a file the container never sees, and archiving +// from there reads whatever the rootfs happens to hold rather than the mounted +// content. Resolving against the mount's source keeps both directions +// consistent with the container's own view of its filesystem. +// +// The longest matching destination wins, so a mount nested inside another +// resolves against the innermost one. A bundle with no readable or parseable +// config.json resolves to the rootfs: absent mount information there is +// nothing to redirect, and the caller reports any genuine failure. +func resolveMountRoot(bundleContainerDir, containerPath string) (root, rel string, err error) { + rootfs := filepath.Join(bundleContainerDir, "rootfs") + + data, err := os.ReadFile(filepath.Join(bundleContainerDir, "config.json")) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return rootfs, containerPath, nil + } + return "", "", fmt.Errorf("failed to read bundle config: %w", err) + } + + var spec struct { + Mounts []struct { + Destination string `json:"destination"` + Type string `json:"type"` + Source string `json:"source"` + } `json:"mounts"` + } + if err := json.Unmarshal(data, &spec); err != nil { + return rootfs, containerPath, nil + } + + target := path.Clean("/" + containerPath) + + var bestDest, bestSrc string + for _, m := range spec.Mounts { + if m.Type != "bind" || m.Source == "" { + continue + } + dest := path.Clean("/" + m.Destination) + if target != dest && !strings.HasPrefix(target, strings.TrimSuffix(dest, "/")+"/") { + continue + } + if len(dest) > len(bestDest) { + bestDest, bestSrc = dest, m.Source + } + } + if bestDest == "" { + return rootfs, containerPath, nil + } + + rel = strings.TrimPrefix(target, bestDest) + if rel == "" { + rel = "." + } + return bestSrc, rel, nil +} + // rootRel converts a path expressed in the container's view (which // may be absolute or contain parent-directory components) into a path // usable with *os.Root operations. Leading "/" is stripped after diff --git a/internal/transfer/containerfs_test.go b/internal/transfer/containerfs_test.go index 2f57491e..a0c59008 100644 --- a/internal/transfer/containerfs_test.go +++ b/internal/transfer/containerfs_test.go @@ -19,6 +19,7 @@ package transfer import ( "archive/tar" "bytes" + "encoding/json" "errors" "io" "io/fs" @@ -798,3 +799,173 @@ func TestWritePathExportRootDotfilesPreserved(t *testing.T) { t.Errorf("dotfile was renamed to 'bashrc' (leading dot stripped by TrimPrefix bug)") } } + +// writeBundleSpec writes a config.json declaring the given bind mounts, as +// destination -> source pairs. +func writeBundleSpec(t *testing.T, bundle string, binds map[string]string) { + t.Helper() + type mount struct { + Destination string `json:"destination"` + Type string `json:"type"` + Source string `json:"source"` + } + spec := struct { + Mounts []mount `json:"mounts"` + }{} + for dest, src := range binds { + spec.Mounts = append(spec.Mounts, mount{Destination: dest, Type: "bind", Source: src}) + } + data, err := json.Marshal(spec) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bundle, "config.json"), data, 0644); err != nil { + t.Fatal(err) + } +} + +// TestResolveMountRootNoSpec resolves to the rootfs when the bundle carries no +// config.json, so a bundle without mount information behaves as before. +func TestResolveMountRootNoSpec(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + + root, rel, err := resolveMountRoot(bundle, "/etc/hosts") + if err != nil { + t.Fatal(err) + } + if root != rootfs { + t.Fatalf("root = %q, want %q", root, rootfs) + } + if rel != "/etc/hosts" { + t.Fatalf("rel = %q, want %q", rel, "/etc/hosts") + } +} + +// TestResolveMountRootSelectsLongestDestination pins the nesting rule: a path +// covered by two mounts resolves against the innermost one. +func TestResolveMountRootSelectsLongestDestination(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + writeBundleSpec(t, bundle, map[string]string{ + "/data": "/mnt/outer", + "/data/inner": "/mnt/inner", + }) + + for _, tc := range []struct { + path string + wantRoot string + wantRel string + }{ + {"/data/file", "/mnt/outer", "/file"}, + {"/data/inner/file", "/mnt/inner", "/file"}, + {"/data", "/mnt/outer", "."}, + {"/elsewhere/file", rootfs, "/elsewhere/file"}, + // A sibling whose name merely shares the prefix is not inside the mount. + {"/database", rootfs, "/database"}, + } { + root, rel, err := resolveMountRoot(bundle, tc.path) + if err != nil { + t.Fatal(err) + } + if root != tc.wantRoot || rel != tc.wantRel { + t.Errorf("%s -> (%q, %q), want (%q, %q)", tc.path, root, rel, tc.wantRoot, tc.wantRel) + } + } +} + +// TestReadPathImportLandsInBindSource is the observable effect on the import +// side: extracting to a bind-mounted destination must produce the file in the +// mount's source directory, where the container reads it through the mount — +// not in the shadowed rootfs entry underneath. +func TestReadPathImportLandsInBindSource(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + source := filepath.Join(bundle, "bind-source") + if err := os.MkdirAll(source, 0755); err != nil { + t.Fatal(err) + } + // The shadowed directory exists in the rootfs, as it does for a real + // container: the runtime creates the mount point before mounting over it. + if err := os.MkdirAll(filepath.Join(rootfs, "data"), 0755); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{"/data": source}) + + root, rel, err := resolveMountRoot(bundle, "/data") + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + body := []byte("through the mount\n") + if err := tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: "payload.txt", + Mode: 0644, + Size: int64(len(body)), + }); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(body); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + + if err := readPath(&buf, root, rel, mediaTypeTar, false); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(source, "payload.txt")) + if err != nil { + t.Fatalf("file missing from the bind source: %v", err) + } + if string(got) != string(body) { + t.Fatalf("content = %q, want %q", got, body) + } + if _, err := os.Stat(filepath.Join(rootfs, "data", "payload.txt")); !errors.Is(err, fs.ErrNotExist) { + t.Fatal("file was written to the shadowed rootfs entry, where the container cannot see it") + } +} + +// TestWritePathExportReadsBindSource is the same effect on the export side: an +// archive of a bind-mounted path must carry the mounted content, not whatever +// the shadowed rootfs entry holds. +func TestWritePathExportReadsBindSource(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + source := filepath.Join(bundle, "bind-source") + if err := os.MkdirAll(source, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "payload.txt"), []byte("mounted\n"), 0644); err != nil { + t.Fatal(err) + } + // Same name under the shadowed rootfs entry, with different content: if + // resolution is wrong the export silently returns this instead. + if err := os.MkdirAll(filepath.Join(rootfs, "data"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootfs, "data", "payload.txt"), []byte("shadowed\n"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{"/data": source}) + + root, rel, err := resolveMountRoot(bundle, "/data/payload.txt") + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := writePath(root, rel, &buf, mediaTypeTar, false); err != nil { + t.Fatal(err) + } + + entries := readTar(t, &buf) + e, ok := entries["payload.txt"] + if !ok { + t.Fatalf("payload.txt missing from archive, got %v", entries) + } + if string(e.body) != "mounted\n" { + t.Fatalf("archived %q, want the mounted content", e.body) + } +} From b66731d6775d26bb74525eddd1fa596cddb8a677 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 10 Aug 2026 16:19:30 +0200 Subject: [PATCH 2/2] fix(transfer): handle single-file bind mounts and bundle-relative sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bind mount whose source is a file (nerdbox itself declares one for /etc/resolv.conf on every networked container) cannot anchor an *os.Root: resolving it to the source path made both transfer directions fail with ENOTDIR. Resolve such mounts to the source's parent directory with the file's name as the relative path. On import, an existing-file destination now receives the archived file's bytes in place — same inode, so the container's mount keeps seeing the update — and rejects directory archives. On export, the archive's top-level name is derived from the container-view path rather than the resolved source, whose basename need not match. Relative mount sources are interpreted against the bundle directory, as the runtime does for bundle extra files. A source is treated as absolute when either filepath.IsAbs or path.IsAbs says so: the code runs in the Linux VM where both agree, and the unit tests mix spec-style sources with Windows host temp directories. Not covered here, tracked by #164: paths whose subtree crosses into a deeper mount, and non-bind mount types (tmpfs) whose content only exists in the container's mount namespace. Signed-off-by: Nicolas De Loof --- internal/transfer/containerfs.go | 110 ++++++++++-- internal/transfer/containerfs_test.go | 240 ++++++++++++++++++++++++-- 2 files changed, 329 insertions(+), 21 deletions(-) diff --git a/internal/transfer/containerfs.go b/internal/transfer/containerfs.go index cbb11281..e78ce592 100644 --- a/internal/transfer/containerfs.go +++ b/internal/transfer/containerfs.go @@ -60,7 +60,9 @@ func (t *containerFSTransferrer) Transfer(ctx context.Context, src, dst any, opt } w := d.Writer(ctx) defer w.Close() - return writePath(root, src, w, d.MediaType, s.NoWalk) + // The archive's top-level name reflects the container's view of + // the path: a mount source's basename need not match it. + return writePath(root, src, path.Base(rootRel(s.Path)), w, d.MediaType, s.NoWalk) case *ReadStream: // Copy-to: ReadStream -> ContainerPath @@ -96,6 +98,18 @@ func (t *containerFSTransferrer) Transfer(ctx context.Context, src, dst any, opt // resolves against the innermost one. A bundle with no readable or parseable // config.json resolves to the rootfs: absent mount information there is // nothing to redirect, and the caller reports any genuine failure. +// +// A relative source is interpreted against the bundle directory, as the +// runtime does (nerdbox itself declares such mounts for bundle extra files +// like resolv.conf). A source that is not a directory — a single-file bind +// mount — cannot anchor an *os.Root, so it resolves to the file's parent +// directory with the file's name as the relative path. +// +// Known limitations, tracked by issue #164: a path whose subtree contains a +// mount deeper inside (e.g. archiving /etc when /etc/resolv.conf is a mount) +// resolves to the outer directory only, and non-bind mounts (tmpfs, ...) +// exist only in the container's mount namespace and cannot be resolved from +// the bundle at all. func resolveMountRoot(bundleContainerDir, containerPath string) (root, rel string, err error) { rootfs := filepath.Join(bundleContainerDir, "rootfs") @@ -137,7 +151,23 @@ func resolveMountRoot(bundleContainerDir, containerPath string) (root, rel strin return rootfs, containerPath, nil } + // This code runs in the Linux VM, where the two predicates agree; + // accepting either form of absolute path keeps the unit tests, which + // mix spec-style Linux sources with host temp directories, portable + // to Windows hosts. + if !filepath.IsAbs(bestSrc) && !path.IsAbs(bestSrc) { + bestSrc = filepath.Join(bundleContainerDir, bestSrc) + } + rel = strings.TrimPrefix(target, bestDest) + + if fi, err := os.Stat(bestSrc); err == nil && !fi.IsDir() { + // Single-file mount: anchor at the parent directory. A residual + // rel below the file yields a path that fails with ENOTDIR when + // the caller stats it, which is the honest answer. + return filepath.Dir(bestSrc), filepath.Base(bestSrc) + rel, nil + } + if rel == "" { rel = "." } @@ -159,14 +189,17 @@ func rootRel(p string) string { } // writePath creates a tar archive from the given path within rootfs -// and writes it to w. When noWalk is true and path is a directory, -// only the directory entry itself is included without walking into -// it. +// and writes it to w. name is the archive's top-level entry name, +// taken from the container's view of the path: when src resolved +// through a mount, the backing file or directory's own basename may +// differ from the name the container sees. When noWalk is true and +// path is a directory, only the directory entry itself is included +// without walking into it. // // All filesystem accesses are anchored to rootfs through *os.Root, // so symlink resolution cannot escape the rootfs even if the // container concurrently mutates its own filesystem. -func writePath(rootfs, src string, w io.Writer, mediaType string, noWalk bool) error { +func writePath(rootfs, src, name string, w io.Writer, mediaType string, noWalk bool) error { if mediaType != mediaTypeTar { return fmt.Errorf("unsupported media type %q: %w", mediaType, errdefs.ErrNotImplemented) } @@ -184,12 +217,11 @@ func writePath(rootfs, src string, w io.Writer, mediaType string, noWalk bool) e return fmt.Errorf("failed to stat %s: %w", src, err) } - // The top-level entry name is the basename of the requested - // path. When the caller asks for the whole filesystem (path "/"), - // relPath is "." and baseName is "."; child entries then drop - // the leading "./" via path.Join, so the tar contains - // "bin/sh" rather than leaking the host bundle's directory name. - baseName := path.Base(relPath) + // When the caller asks for the whole filesystem (path "/"), name + // is "."; child entries then drop the leading "./" via path.Join, + // so the tar contains "bin/sh" rather than leaking the host + // bundle's directory name. + baseName := name tw := tar.NewWriter(w) @@ -303,6 +335,13 @@ func readPath(r io.Reader, rootfs, dstPath, mediaType string, preserveOwnership dst := root if relDst != "." { + // A destination naming an existing non-directory — a plain + // file in the rootfs, or the source of a single-file bind + // mount after resolution — receives the archived file's bytes + // rather than a tree extraction. + if fi, err := root.Stat(relDst); err == nil && !fi.IsDir() { + return extractOverFile(root, relDst, r, preserveOwnership) + } if err := root.MkdirAll(relDst, 0755); err != nil { return fmt.Errorf("failed to create destination: %w", err) } @@ -339,6 +378,55 @@ func readPath(r io.Reader, rootfs, dstPath, mediaType string, preserveOwnership } } +// extractOverFile extracts an archive onto a destination that is an +// existing file rather than a directory, replacing its contents with +// the archived bytes. Only an archive carrying a single regular file +// makes sense here; a directory or any other entry type cannot be +// extracted over a file and is rejected. The file is truncated in +// place rather than recreated, so it keeps its mode and, when it is a +// bind-mount source, its inode — a mounted file replaced by a new +// inode would leave the container reading the stale one. +func extractOverFile(dst *os.Root, target string, r io.Reader, preserveOwnership bool) error { + tr := tar.NewReader(r) + written := false + for { + header, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("failed to read tar header: %w", err) + } + if header.Typeflag != tar.TypeReg { + return fmt.Errorf("cannot extract %q over file %s: not a regular file", header.Name, target) + } + if written { + return fmt.Errorf("cannot extract multiple entries over file %s", target) + } + + f, err := dst.OpenFile(target, os.O_WRONLY|os.O_TRUNC, 0) + if err != nil { + return err + } + // Copy exactly the size the header declares; the tar reader + // bounds the entry anyway, and the explicit limit satisfies + // gosec's decompression-bomb rule (G110). + if _, err := io.CopyN(f, tr, header.Size); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if preserveOwnership { + if err := dst.Lchown(target, header.Uid, header.Gid); err != nil { + return fmt.Errorf("failed to chown %s: %w", target, err) + } + } + written = true + } +} + func extractTarEntry(dst *os.Root, target string, header *tar.Header, r io.Reader, preserveOwnership bool) error { switch header.Typeflag { case tar.TypeDir: diff --git a/internal/transfer/containerfs_test.go b/internal/transfer/containerfs_test.go index a0c59008..cf264ae7 100644 --- a/internal/transfer/containerfs_test.go +++ b/internal/transfer/containerfs_test.go @@ -110,7 +110,7 @@ func TestWritePathExportSymlinkEscapeBlocked(t *testing.T) { buf := &bytes.Buffer{} // Asking to copy /escape/secret. Lstat would have to traverse // the symlink "/escape" out of the rootfs to reach "secret". - err := writePath(rootfs, "/escape/secret", buf, mediaTypeTar, false) + err := writePath(rootfs, "/escape/secret", "secret", buf, mediaTypeTar, false) if err == nil { t.Fatal("expected error when traversing symlink out of rootfs, got nil") } @@ -130,7 +130,7 @@ func TestWritePathExportPreservesSymlinks(t *testing.T) { } buf := &bytes.Buffer{} - if err := writePath(rootfs, "/alias", buf, mediaTypeTar, false); err != nil { + if err := writePath(rootfs, "/alias", "alias", buf, mediaTypeTar, false); err != nil { t.Fatalf("writePath: %v", err) } @@ -168,7 +168,7 @@ func TestWritePathExportWalkContainsSymlinkToOutside(t *testing.T) { } buf := &bytes.Buffer{} - if err := writePath(rootfs, "/dir", buf, mediaTypeTar, false); err != nil { + if err := writePath(rootfs, "/dir", "dir", buf, mediaTypeTar, false); err != nil { t.Fatalf("writePath: %v", err) } @@ -398,7 +398,7 @@ func TestRoundTripExportImport(t *testing.T) { } buf := &bytes.Buffer{} - if err := writePath(src, "/a", buf, mediaTypeTar, false); err != nil { + if err := writePath(src, "/a", "a", buf, mediaTypeTar, false); err != nil { t.Fatalf("writePath: %v", err) } @@ -618,7 +618,7 @@ func TestWritePathExportRelativeDotDotPath(t *testing.T) { // "../outside/secret" cleans to "outside/secret" (relative), // which doesn't exist inside the rootfs. buf := &bytes.Buffer{} - err := writePath(rootfs, "../outside/secret", buf, mediaTypeTar, false) + err := writePath(rootfs, "../outside/secret", "secret", buf, mediaTypeTar, false) if err == nil { t.Fatal("expected error for path escaping rootfs, got nil") } @@ -644,7 +644,7 @@ func TestWritePathExportNoWalk(t *testing.T) { } buf := &bytes.Buffer{} - if err := writePath(rootfs, "/d", buf, mediaTypeTar, true); err != nil { + if err := writePath(rootfs, "/d", "d", buf, mediaTypeTar, true); err != nil { t.Fatalf("writePath: %v", err) } @@ -676,7 +676,7 @@ func TestWritePathExportRootDoesNotLeakBundleName(t *testing.T) { leaked := filepath.Base(rootfs) // e.g. "rootfs" buf := &bytes.Buffer{} - if err := writePath(rootfs, "/", buf, mediaTypeTar, false); err != nil { + if err := writePath(rootfs, "/", ".", buf, mediaTypeTar, false); err != nil { t.Fatalf("writePath: %v", err) } @@ -717,7 +717,7 @@ func TestRoundTripExportRootImport(t *testing.T) { } buf := &bytes.Buffer{} - if err := writePath(src, "/", buf, mediaTypeTar, false); err != nil { + if err := writePath(src, "/", ".", buf, mediaTypeTar, false); err != nil { t.Fatalf("writePath: %v", err) } @@ -787,7 +787,7 @@ func TestWritePathExportRootDotfilesPreserved(t *testing.T) { } buf := &bytes.Buffer{} - if err := writePath(rootfs, "/", buf, mediaTypeTar, false); err != nil { + if err := writePath(rootfs, "/", ".", buf, mediaTypeTar, false); err != nil { t.Fatalf("writePath: %v", err) } @@ -956,7 +956,7 @@ func TestWritePathExportReadsBindSource(t *testing.T) { } var buf bytes.Buffer - if err := writePath(root, rel, &buf, mediaTypeTar, false); err != nil { + if err := writePath(root, rel, "payload.txt", &buf, mediaTypeTar, false); err != nil { t.Fatal(err) } @@ -969,3 +969,223 @@ func TestWritePathExportReadsBindSource(t *testing.T) { t.Fatalf("archived %q, want the mounted content", e.body) } } + +// TestResolveMountRootSingleFileMount pins resolution for bind mounts whose +// source is a file: the root is the file's parent directory (an *os.Root +// cannot anchor at a file), and a relative source is interpreted against the +// bundle directory, as the runtime does for bundle extra files. +func TestResolveMountRootSingleFileMount(t *testing.T) { + bundle, _, _ := makeRootfs(t) + if err := os.WriteFile(filepath.Join(bundle, "resolv.conf"), []byte("nameserver 10.0.0.1\n"), 0644); err != nil { + t.Fatal(err) + } + extra := filepath.Join(bundle, "extra") + if err := os.MkdirAll(extra, 0755); err != nil { + t.Fatal(err) + } + hosts := filepath.Join(extra, "hosts") + if err := os.WriteFile(hosts, []byte("127.0.0.1 localhost\n"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{ + "/etc/resolv.conf": "resolv.conf", // relative to the bundle + "/etc/hosts": hosts, // absolute + }) + + for _, tc := range []struct { + path string + wantRoot string + wantRel string + }{ + {"/etc/resolv.conf", bundle, "resolv.conf"}, + {"/etc/hosts", extra, "hosts"}, + // A path below a file mount cannot exist; the residual rel makes + // the caller's stat fail with ENOTDIR rather than silently + // resolving elsewhere. + {"/etc/hosts/sub", extra, "hosts/sub"}, + } { + root, rel, err := resolveMountRoot(bundle, tc.path) + if err != nil { + t.Fatal(err) + } + if root != tc.wantRoot || rel != tc.wantRel { + t.Errorf("%s -> (%q, %q), want (%q, %q)", tc.path, root, rel, tc.wantRoot, tc.wantRel) + } + } +} + +// TestWritePathExportSingleFileBindMount exports a file-mount destination: +// the archive must carry the mounted bytes under the container-view name, +// even though the source file's own basename differs. +func TestWritePathExportSingleFileBindMount(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + source := filepath.Join(bundle, "resolv-generated.conf") + if err := os.WriteFile(source, []byte("nameserver 10.0.0.1\n"), 0644); err != nil { + t.Fatal(err) + } + // Shadowed rootfs entry with different content: the image may ship its + // own resolv.conf under the mount point. + if err := os.MkdirAll(filepath.Join(rootfs, "etc"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootfs, "etc", "resolv.conf"), []byte("shadowed\n"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{"/etc/resolv.conf": source}) + + root, rel, err := resolveMountRoot(bundle, "/etc/resolv.conf") + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := writePath(root, rel, "resolv.conf", &buf, mediaTypeTar, false); err != nil { + t.Fatal(err) + } + + entries := readTar(t, &buf) + e, ok := entries["resolv.conf"] + if !ok { + t.Fatalf("resolv.conf missing from archive, got %v", keys(entries)) + } + if string(e.body) != "nameserver 10.0.0.1\n" { + t.Fatalf("archived %q, want the mounted content", e.body) + } +} + +// TestReadPathImportOverSingleFileBindMount imports onto a file-mount +// destination: the mount source's bytes are replaced in place — same inode, +// so the container's mount keeps seeing the file — and the shadowed rootfs +// entry stays untouched. +func TestReadPathImportOverSingleFileBindMount(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + source := filepath.Join(bundle, "resolv.conf") + if err := os.WriteFile(source, []byte("nameserver 10.0.0.1\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(rootfs, "etc"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootfs, "etc", "resolv.conf"), []byte("shadowed\n"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{"/etc/resolv.conf": "resolv.conf"}) + + before, err := os.Stat(source) + if err != nil { + t.Fatal(err) + } + + root, rel, err := resolveMountRoot(bundle, "/etc/resolv.conf") + if err != nil { + t.Fatal(err) + } + + buf := writeTar(t, func(tw *tar.Writer) { + body := []byte("nameserver 10.0.0.2\n") + _ = tw.WriteHeader(&tar.Header{ + Name: "resolv.conf", + Typeflag: tar.TypeReg, + Mode: 0644, + Size: int64(len(body)), + }) + _, _ = tw.Write(body) + }) + + if err := readPath(buf, root, rel, mediaTypeTar, false); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + if string(got) != "nameserver 10.0.0.2\n" { + t.Fatalf("source content = %q, want the imported bytes", got) + } + after, err := os.Stat(source) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(before, after) { + t.Fatal("source was replaced by a new inode; the container's mount would keep the stale file") + } + shadow, err := os.ReadFile(filepath.Join(rootfs, "etc", "resolv.conf")) + if err != nil { + t.Fatal(err) + } + if string(shadow) != "shadowed\n" { + t.Fatalf("shadowed rootfs entry was modified: %q", shadow) + } +} + +// TestReadPathImportDirectoryOverFileFails rejects extracting a directory +// archive over a file-mount destination, mirroring "cannot copy a directory +// to a file" semantics. +func TestReadPathImportDirectoryOverFileFails(t *testing.T) { + bundle, _, _ := makeRootfs(t) + source := filepath.Join(bundle, "resolv.conf") + if err := os.WriteFile(source, []byte("nameserver 10.0.0.1\n"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{"/etc/resolv.conf": source}) + + root, rel, err := resolveMountRoot(bundle, "/etc/resolv.conf") + if err != nil { + t.Fatal(err) + } + + buf := writeTar(t, func(tw *tar.Writer) { + _ = tw.WriteHeader(&tar.Header{ + Name: "d", + Typeflag: tar.TypeDir, + Mode: 0755, + }) + }) + + if err := readPath(buf, root, rel, mediaTypeTar, false); err == nil { + t.Fatal("expected error extracting a directory over a file destination") + } + got, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + if string(got) != "nameserver 10.0.0.1\n" { + t.Fatalf("source was modified by a failed import: %q", got) + } +} + +// TestWritePathExportDirMountExactKeepsName pins the naming contract when the +// requested path is exactly a directory mount's destination: the walk anchors +// at the mount source, but the archive's top-level name is the destination's +// basename as the container sees it — Transfer derives it from the container +// path, not from the resolved source. +func TestWritePathExportDirMountExactKeepsName(t *testing.T) { + bundle, _, _ := makeRootfs(t) + source := filepath.Join(bundle, "bind-source") + if err := os.MkdirAll(source, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "file"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, map[string]string{"/data": source}) + + root, rel, err := resolveMountRoot(bundle, "/data") + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := writePath(root, rel, "data", &buf, mediaTypeTar, false); err != nil { + t.Fatal(err) + } + + entries := readTar(t, &buf) + if _, ok := entries["data/file"]; !ok { + t.Fatalf("expected 'data/file' entry, got %v", keys(entries)) + } + if _, ok := entries[filepath.Base(source)+"/file"]; ok { + t.Fatal("archive leaked the mount source's basename instead of the container-view name") + } +}