diff --git a/internal/transfer/containerfs.go b/internal/transfer/containerfs.go index 61baa4f0..2f2f5f5c 100644 --- a/internal/transfer/containerfs.go +++ b/internal/transfer/containerfs.go @@ -19,6 +19,9 @@ package transfer import ( "archive/tar" "context" + "crypto/rand" + "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -51,10 +54,16 @@ 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) + // 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 @@ -62,14 +71,145 @@ 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, readonly, err := resolveMountRoot(bundle, d.Path) + if err != nil { + return err + } + if readonly { + return fmt.Errorf("container path %q is marked read-only: %w", d.Path, errdefs.ErrPermissionDenied) + } 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 within it. +// The returned path may retain a leading slash; callers normalize it with +// rootRel before passing it to an *os.Root operation. +// +// 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. +// +// Mounts are applied in spec order, so the last matching bind mount wins. A +// later parent mount can therefore hide an earlier child mount. Legacy relative +// destinations are interpreted from "/", as required by the Linux OCI runtime +// spec. A bundle with no config.json resolves to the rootfs; a config that +// exists but cannot be read or parsed is an error rather than a blind fallback. +// +// 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). Source symlinks are resolved to the path selected when +// the runtime creates the mount. 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. +// +// Writers must honor the readonly result: resolution bypasses the mount +// namespace, so MS_RDONLY never intervenes on the backing directory. +// +// 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, readonly bool, err error) { + rootfs := filepath.Join(bundleContainerDir, "rootfs") + configPath := filepath.Join(bundleContainerDir, "config.json") + + data, err := os.ReadFile(configPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return rootfs, containerPath, false, nil + } + return "", "", false, fmt.Errorf("failed to read bundle config %q: %w", configPath, err) + } + + var spec struct { + Root struct { + Readonly bool `json:"readonly"` + } `json:"root"` + Mounts []struct { + Destination string `json:"destination"` + Type string `json:"type"` + Source string `json:"source"` + Options []string `json:"options"` + } `json:"mounts"` + } + if err := json.Unmarshal(data, &spec); err != nil { + return "", "", false, fmt.Errorf("failed to parse bundle config %q: %w", configPath, err) + } + + target := path.Clean("/" + containerPath) + + var mountDest, mountSrc string + var mountReadonly bool + for _, m := range spec.Mounts { + if m.Type != "bind" || m.Source == "" || m.Destination == "" { + continue + } + dest := path.Clean("/" + m.Destination) + if target != dest && !strings.HasPrefix(target, strings.TrimSuffix(dest, "/")+"/") { + continue + } + mountDest, mountSrc = dest, m.Source + mountReadonly = readOnlyMount(m.Options) + } + if mountDest == "" { + return rootfs, containerPath, spec.Root.Readonly, 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(mountSrc) && !path.IsAbs(mountSrc) { + mountSrc = filepath.Join(bundleContainerDir, mountSrc) + } + // A bind mount follows source symlinks when it is created. Use the same + // resolved path so later changes operate on the mounted object rather + // than on the symlink itself. + if resolved, err := filepath.EvalSymlinks(mountSrc); err == nil { + mountSrc = resolved + } + + rel = strings.TrimPrefix(target, mountDest) + + if fi, err := os.Stat(mountSrc); 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(mountSrc), filepath.Base(mountSrc) + rel, mountReadonly, nil + } + + if rel == "" { + rel = "." + } + return mountSrc, rel, mountReadonly, nil +} + +// readOnlyMount applies mount(8) semantics: the last read-only or read-write +// option wins, including their recursive variants. +func readOnlyMount(options []string) bool { + readonly := false + for _, opt := range options { + switch opt { + case "ro", "rro": + readonly = true + case "rw", "rrw": + readonly = false + } + } + return readonly +} + // 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 @@ -84,22 +224,25 @@ func rootRel(p string) string { return p } -// 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. +// writePath creates a tar archive from the given path within dir — the +// resolved backing directory, a rootfs or a mount source — 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 { +// All filesystem accesses are anchored to dir through *os.Root, so +// symlink resolution cannot escape it even if the container +// concurrently mutates its own filesystem. +func writePath(dir, 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) } - root, err := os.OpenRoot(rootfs) + root, err := os.OpenRoot(dir) if err != nil { - return fmt.Errorf("failed to open rootfs: %w", err) + return fmt.Errorf("failed to open transfer root: %w", err) } defer root.Close() @@ -110,12 +253,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) @@ -147,8 +289,8 @@ func writePath(rootfs, src string, w io.Writer, mediaType string, noWalk bool) e // The root entry itself. rel = "" case relPath == ".": - // Walking from the rootfs root: walkPath is already the - // entry name relative to the root. + // Walking from the root itself: walkPath is already the + // entry name relative to it. rel = walkPath default: // Walking a subdirectory: strip "relPath/" prefix. @@ -170,7 +312,7 @@ func writePath(rootfs, src string, w io.Writer, mediaType string, noWalk bool) e } // writeTarEntry writes a single tar entry. srcPath is interpreted -// relative to root, so symlink resolution cannot escape the rootfs. +// relative to root, so symlink resolution cannot escape it. func writeTarEntry(root *os.Root, tw *tar.Writer, srcPath string, fi os.FileInfo, name string) error { header, err := tar.FileInfoHeader(fi, "") if err != nil { @@ -205,23 +347,24 @@ func writeTarEntry(root *os.Root, tw *tar.Writer, srcPath string, fi os.FileInfo } // readPath reads a tar archive from r and extracts it under path -// within rootfs. When preserveOwnership is true, extracted files have -// their UID/GID set from the tar headers. +// within dir — the resolved backing directory, a rootfs or a mount +// source. When preserveOwnership is true, extracted files have their +// UID/GID set from the tar headers. // // The destination directory is opened as a sub-*os.Root so the // destination boundary is enforced by os.Root rather than by lexical -// path checks. Pre-existing symlinks within the rootfs, symlinks -// created by earlier entries in the same archive, absolute symlink -// targets, and tar entry names containing "../" all resolve within -// the destination's sub-root and cannot redirect writes outside it. -func readPath(r io.Reader, rootfs, dstPath, mediaType string, preserveOwnership bool) error { +// path checks. Pre-existing symlinks within dir, symlinks created by +// earlier entries in the same archive, absolute symlink targets, and +// tar entry names containing "../" all resolve within the +// destination's sub-root and cannot redirect writes outside it. +func readPath(r io.Reader, dir, dstPath, mediaType string, preserveOwnership bool) error { if mediaType != mediaTypeTar { return fmt.Errorf("unsupported media type %q: %w", mediaType, errdefs.ErrNotImplemented) } - root, err := os.OpenRoot(rootfs) + root, err := os.OpenRoot(dir) if err != nil { - return fmt.Errorf("failed to open rootfs: %w", err) + return fmt.Errorf("failed to open transfer root: %w", err) } defer root.Close() @@ -229,6 +372,13 @@ func readPath(r io.Reader, rootfs, dstPath, mediaType string, preserveOwnership dst := root if relDst != "." { + // A destination naming an existing regular file — 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.Lstat(relDst); err == nil && fi.Mode().IsRegular() { + return extractOverFile(root, relDst, r, preserveOwnership) + } if err := root.MkdirAll(relDst, 0755); err != nil { return fmt.Errorf("failed to create destination: %w", err) } @@ -265,13 +415,88 @@ func readPath(r io.Reader, rootfs, dstPath, mediaType string, preserveOwnership } } +// extractOverFile extracts an archive of exactly one regular file over an +// existing file. The payload is staged first so a rejected archive leaves +// the file untouched; truncating in place keeps a bind-mount source's inode. +func extractOverFile(dst *os.Root, target string, r io.Reader, preserveOwnership bool) error { + tr := tar.NewReader(r) + var header *tar.Header + for { + var err error + header, err = tr.Next() + if err == io.EOF { + return fmt.Errorf("cannot extract empty archive over file %s", target) + } + if err != nil { + return fmt.Errorf("failed to read tar header: %w", err) + } + // archive/tar hides per-file PAX and GNU long-name headers, but + // surfaces global PAX headers. They carry metadata, not a payload. + if header.Typeflag != tar.TypeXGlobalHeader { + break + } + } + if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA { //nolint:staticcheck // TypeRegA compatibility is intentional. + return fmt.Errorf("cannot extract %q over file %s: not a regular file", header.Name, target) + } + + tmp := path.Join(path.Dir(target), ".transfer-"+rand.Text()) + f, err := dst.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) + if err != nil { + return fmt.Errorf("failed to stage %s: %w", target, err) + } + defer func() { + f.Close() + dst.Remove(tmp) + }() + // 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 { + return fmt.Errorf("failed to stage %q over file %s: %w", header.Name, target, err) + } +validateArchive: + for { + next, err := tr.Next() + switch { + case err == io.EOF: + break validateArchive + case err != nil: + return fmt.Errorf("failed to read tar header: %w", err) + case next.Typeflag != tar.TypeXGlobalHeader: + return fmt.Errorf("cannot extract multiple entries over file %s", target) + } + } + + if _, err := f.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("failed to rewind staged data for %s: %w", target, err) + } + out, err := dst.OpenFile(target, os.O_WRONLY|os.O_TRUNC, 0) + if err != nil { + return fmt.Errorf("failed to open destination file %s: %w", target, err) + } + if _, err := io.Copy(out, f); err != nil { + out.Close() + return fmt.Errorf("failed to extract %q over file %s: %w", header.Name, target, err) + } + if err := out.Close(); err != nil { + return fmt.Errorf("failed to close destination file %s: %w", target, err) + } + if preserveOwnership { + if err := dst.Lchown(target, header.Uid, header.Gid); err != nil { + return fmt.Errorf("failed to chown %s: %w", target, err) + } + } + return nil +} + func extractTarEntry(dst *os.Root, target string, header *tar.Header, r io.Reader, preserveOwnership bool) error { switch header.Typeflag { case tar.TypeDir: if err := dst.MkdirAll(target, os.FileMode(header.Mode)); err != nil { return err } - case tar.TypeReg: + case tar.TypeReg, tar.TypeRegA: //nolint:staticcheck // TypeRegA compatibility is intentional. if err := dst.MkdirAll(path.Dir(target), 0755); err != nil { return err } diff --git a/internal/transfer/containerfs_test.go b/internal/transfer/containerfs_test.go index 2f57491e..7269f71c 100644 --- a/internal/transfer/containerfs_test.go +++ b/internal/transfer/containerfs_test.go @@ -19,13 +19,18 @@ package transfer import ( "archive/tar" "bytes" + "context" + "encoding/json" "errors" + "fmt" "io" "io/fs" "os" "path/filepath" "strings" "testing" + + "github.com/containerd/errdefs" ) // makeRootfs creates a temporary rootfs and a sibling "outside" @@ -88,6 +93,38 @@ func writeTar(t *testing.T, build func(tw *tar.Writer)) *bytes.Buffer { return buf } +// writeLegacyRegularTar writes a raw legacy regular-file typeflag. tar.Writer +// promotes TypeRegA to TypeReg, so the typeflag and checksum must be adjusted +// after writing the archive. +func writeLegacyRegularTar(t *testing.T, name, body string) *bytes.Buffer { + t.Helper() + buf := writeTar(t, func(tw *tar.Writer) { + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0644, + Size: int64(len(body)), + Typeflag: tar.TypeReg, + }); err != nil { + t.Fatalf("tar header: %v", err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatalf("tar body: %v", err) + } + }) + + header := buf.Bytes()[:tarBlockSize] + header[156] = tar.TypeRegA //nolint:staticcheck // Exercise a legacy tar typeflag. + copy(header[148:156], " ") + var checksum int + for _, b := range header { + checksum += int(b) + } + copy(header[148:156], fmt.Sprintf("%06o\x00 ", checksum)) + return buf +} + +const tarBlockSize = 512 + // TestWritePathExportSymlinkEscapeBlocked verifies that when a tar // export hits a regular file whose path would resolve outside the // rootfs (because an intermediate symlink points outside), the open @@ -109,7 +146,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") } @@ -129,7 +166,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) } @@ -167,7 +204,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) } @@ -375,6 +412,50 @@ func TestReadPathImportRoundTrip(t *testing.T) { } } +// TestReadPathImportLegacyRegularFile verifies that the legacy zero typeflag +// extracts as a regular file for both directory and existing-file +// destinations. +func TestReadPathImportLegacyRegularFile(t *testing.T) { + t.Run("directory destination", func(t *testing.T) { + _, rootfs, _ := makeRootfs(t) + if err := os.Mkdir(filepath.Join(rootfs, "dst"), 0755); err != nil { + t.Fatal(err) + } + + buf := writeLegacyRegularTar(t, "payload", "legacy") + if err := readPath(buf, rootfs, "/dst", mediaTypeTar, false); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(rootfs, "dst", "payload")) + if err != nil { + t.Fatal(err) + } + if string(got) != "legacy" { + t.Fatalf("payload = %q, want %q", got, "legacy") + } + }) + + t.Run("file destination", func(t *testing.T) { + _, rootfs, _ := makeRootfs(t) + target := filepath.Join(rootfs, "target") + if err := os.WriteFile(target, []byte("original"), 0644); err != nil { + t.Fatal(err) + } + + buf := writeLegacyRegularTar(t, "payload", "legacy") + if err := readPath(buf, rootfs, "/target", mediaTypeTar, false); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "legacy" { + t.Fatalf("target = %q, want %q", got, "legacy") + } + }) +} + // TestRoundTripExportImport writes some files into a rootfs, exports // them with writePath, then re-imports the tar into a fresh rootfs // with readPath, and verifies the content matches. @@ -397,7 +478,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) } @@ -617,7 +698,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") } @@ -643,7 +724,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) } @@ -675,7 +756,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) } @@ -716,7 +797,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) } @@ -786,7 +867,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) } @@ -798,3 +879,798 @@ func TestWritePathExportRootDotfilesPreserved(t *testing.T) { t.Errorf("dotfile was renamed to 'bashrc' (leading dot stripped by TrimPrefix bug)") } } + +// writeBundleSpec writes a config.json declaring bind mounts in the order +// provided. +func writeBundleSpec(t *testing.T, bundle string, binds ...specMount) { + t.Helper() + for i := range binds { + binds[i].Type = "bind" + } + writeBundleSpecOpts(t, bundle, false, binds) +} + +// 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") + } +} + +func TestResolveMountRootRejectsMalformedSpec(t *testing.T) { + bundle, _, _ := makeRootfs(t) + configPath := filepath.Join(bundle, "config.json") + if err := os.WriteFile(configPath, []byte("{"), 0644); err != nil { + t.Fatal(err) + } + + if _, _, _, err := resolveMountRoot(bundle, "/etc/hosts"); err == nil { + t.Fatal("expected malformed config.json to fail resolution") + } else if !strings.Contains(err.Error(), fmt.Sprintf("%q", configPath)) { + t.Fatalf("error = %q, want config path %q", err, configPath) + } +} + +func TestResolveMountRootReportsUnreadableSpecPath(t *testing.T) { + bundle, _, _ := makeRootfs(t) + configPath := filepath.Join(bundle, "config.json") + if err := os.Mkdir(configPath, 0755); err != nil { + t.Fatal(err) + } + + if _, _, _, err := resolveMountRoot(bundle, "/etc/hosts"); err == nil { + t.Fatal("expected unreadable config.json to fail resolution") + } else if !strings.Contains(err.Error(), fmt.Sprintf("%q", configPath)) { + t.Fatalf("error = %q, want config path %q", err, configPath) + } +} + +// TestResolveMountRootUsesLastMatchingDestination pins OCI mount ordering: a +// later parent mount hides an earlier child just as a later child overlays an +// earlier parent. +func TestResolveMountRootUsesLastMatchingDestination(t *testing.T) { + for _, tc := range []struct { + name string + mounts []specMount + wantRoot string + wantRel string + wantReadonly bool + }{ + { + name: "parent before child", + mounts: []specMount{ + {Destination: "/data", Type: "bind", Source: "/mnt/outer", Options: []string{"ro"}}, + {Destination: "/data/inner", Type: "bind", Source: "/mnt/inner", Options: []string{"rw"}}, + }, + wantRoot: "/mnt/inner", + wantRel: "/file", + }, + { + name: "child before parent", + mounts: []specMount{ + {Destination: "/data/inner", Type: "bind", Source: "/mnt/inner", Options: []string{"rw"}}, + {Destination: "/data", Type: "bind", Source: "/mnt/outer", Options: []string{"ro"}}, + }, + wantRoot: "/mnt/outer", + wantRel: "/inner/file", + wantReadonly: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + bundle, _, _ := makeRootfs(t) + writeBundleSpecOpts(t, bundle, false, tc.mounts) + + root, rel, readonly, err := resolveMountRoot(bundle, "/data/inner/file") + if err != nil { + t.Fatal(err) + } + if root != tc.wantRoot || rel != tc.wantRel || readonly != tc.wantReadonly { + t.Errorf("resolved to (%q, %q, readonly=%v), want (%q, %q, readonly=%v)", + root, rel, readonly, tc.wantRoot, tc.wantRel, tc.wantReadonly) + } + }) + } +} + +func TestResolveMountRootMatchesPathBoundary(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + writeBundleSpec(t, bundle, specMount{Destination: "/data", Source: "/mnt/data"}) + + for _, tc := range []struct { + path string + wantRoot string + wantRel string + }{ + {"/data/file", "/mnt/data", "/file"}, + {"/data", "/mnt/data", "."}, + {"/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) + } + } +} + +func TestResolveMountRootHandlesLegacyRelativeDestination(t *testing.T) { + bundle, rootfs, _ := makeRootfs(t) + writeBundleSpec(t, bundle, + specMount{Destination: "", Source: "/mnt/empty"}, + specMount{Destination: "relative", Source: "/mnt/relative"}, + ) + + for _, tc := range []struct { + containerPath string + wantRoot string + wantRel string + }{ + {containerPath: "/etc/hosts", wantRoot: rootfs, wantRel: "/etc/hosts"}, + {containerPath: "/relative/file", wantRoot: "/mnt/relative", wantRel: "/file"}, + } { + root, rel, _, err := resolveMountRoot(bundle, tc.containerPath) + if err != nil { + t.Fatal(err) + } + if root != tc.wantRoot || rel != tc.wantRel { + t.Errorf("%s -> (%q, %q), want (%q, %q)", tc.containerPath, 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, specMount{Destination: "/data", Source: 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, specMount{Destination: "/data", Source: source}) + + root, rel, _, err := resolveMountRoot(bundle, "/data/payload.txt") + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := writePath(root, rel, "payload.txt", &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) + } +} + +// 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) + resolvedBundle, err := filepath.EvalSymlinks(bundle) + if err != nil { + t.Fatal(err) + } + 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) + } + resolvedExtra, err := filepath.EvalSymlinks(extra) + if 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) + } + hostsLink := filepath.Join(bundle, "hosts-link") + if err := os.Symlink(hosts, hostsLink); err != nil { + t.Fatal(err) + } + writeBundleSpec(t, bundle, + specMount{Destination: "/etc/resolv.conf", Source: "resolv.conf"}, // relative to the bundle + specMount{Destination: "/etc/hosts", Source: hosts}, // absolute + specMount{Destination: "/etc/hostname", Source: hostsLink}, // symlink to a file + ) + + for _, tc := range []struct { + path string + wantRoot string + wantRel string + }{ + {"/etc/resolv.conf", resolvedBundle, "resolv.conf"}, + {"/etc/hosts", resolvedExtra, "hosts"}, + {"/etc/hostname", resolvedExtra, "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", resolvedExtra, "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, specMount{Destination: "/etc/resolv.conf", Source: 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) + } + stagingLookalike := source + ".transfer-tmp" + if err := os.WriteFile(stagingLookalike, []byte("keep\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, specMount{Destination: "/etc/resolv.conf", Source: "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) + } + lookalike, err := os.ReadFile(stagingLookalike) + if err != nil { + t.Fatal(err) + } + if string(lookalike) != "keep\n" { + t.Fatalf("staging lookalike was modified: %q", lookalike) + } +} + +// 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, specMount{Destination: "/etc/resolv.conf", Source: 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) + } +} + +// TestReadPathImportMultipleEntriesOverFileLeavesTargetUntouched rejects a +// multi-entry archive at a file destination without touching the file. +func TestReadPathImportMultipleEntriesOverFileLeavesTargetUntouched(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, specMount{Destination: "/etc/resolv.conf", Source: source}) + + root, rel, _, err := resolveMountRoot(bundle, "/etc/resolv.conf") + if err != nil { + t.Fatal(err) + } + + buf := writeTar(t, func(tw *tar.Writer) { + for _, name := range []string{"first", "second"} { + body := []byte("OWNED " + name + "\n") + _ = tw.WriteHeader(&tar.Header{ + Name: name, + Typeflag: tar.TypeReg, + Mode: 0644, + Size: int64(len(body)), + }) + _, _ = tw.Write(body) + } + }) + + if err := readPath(buf, root, rel, mediaTypeTar, false); err == nil { + t.Fatal("expected error extracting multiple entries 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("target was modified by a failed import: %q", got) + } + entries, err := os.ReadDir(filepath.Dir(source)) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".transfer-") { + t.Fatalf("staging file left behind after a failed import: %s", entry.Name()) + } + } +} + +func TestReadPathImportGlobalPAXHeaderOverFile(t *testing.T) { + _, rootfs, _ := makeRootfs(t) + target := filepath.Join(rootfs, "target") + if err := os.WriteFile(target, []byte("original"), 0644); err != nil { + t.Fatal(err) + } + + body := []byte("replacement") + buf := writeTar(t, func(tw *tar.Writer) { + if err := tw.WriteHeader(&tar.Header{ + Name: "pax_global_header", + Typeflag: tar.TypeXGlobalHeader, + PAXRecords: map[string]string{"comment": "metadata"}, + }); err != nil { + t.Fatalf("write global PAX header: %v", err) + } + if err := tw.WriteHeader(&tar.Header{ + Name: "payload", + Typeflag: tar.TypeReg, + Mode: 0644, + Size: int64(len(body)), + }); err != nil { + t.Fatalf("write payload header: %v", err) + } + if _, err := tw.Write(body); err != nil { + t.Fatalf("write payload: %v", err) + } + if err := tw.WriteHeader(&tar.Header{ + Name: "pax_global_footer", + Typeflag: tar.TypeXGlobalHeader, + PAXRecords: map[string]string{"comment": "trailing metadata"}, + }); err != nil { + t.Fatalf("write trailing global PAX header: %v", err) + } + }) + + if err := readPath(buf, rootfs, "/target", mediaTypeTar, false); err != nil { + t.Fatalf("import with global PAX metadata: %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != string(body) { + t.Fatalf("target content = %q, want %q", got, body) + } +} + +// TestReadPathImportEmptyArchiveOverFileFails rejects an empty archive at a +// file destination instead of succeeding without replacing anything. +func TestReadPathImportEmptyArchiveOverFileFails(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, specMount{Destination: "/etc/resolv.conf", Source: source}) + + root, rel, _, err := resolveMountRoot(bundle, "/etc/resolv.conf") + if err != nil { + t.Fatal(err) + } + + buf := writeTar(t, func(tw *tar.Writer) {}) + + if err := readPath(buf, root, rel, mediaTypeTar, false); err == nil { + t.Fatal("expected error extracting an empty archive 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) + } +} + +// TestReadPathImportOverSymlinkDestinationFails verifies that only an +// existing regular file activates the single-file import path. A symlink is +// not treated as a file destination, even when it points to a regular file. +func TestReadPathImportOverSymlinkDestinationFails(t *testing.T) { + _, rootfs, _ := makeRootfs(t) + target := filepath.Join(rootfs, "target") + if err := os.WriteFile(target, []byte("original"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target", filepath.Join(rootfs, "destination")); err != nil { + t.Fatal(err) + } + + buf := writeTar(t, func(tw *tar.Writer) { + body := []byte("replacement") + _ = tw.WriteHeader(&tar.Header{ + Name: "payload", + Typeflag: tar.TypeReg, + Mode: 0644, + Size: int64(len(body)), + }) + _, _ = tw.Write(body) + }) + + if err := readPath(buf, rootfs, "/destination", mediaTypeTar, false); err == nil { + t.Fatal("expected error extracting over a symlink destination") + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "original" { + t.Fatalf("symlink target was modified: %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, specMount{Destination: "/data", Source: 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") + } +} + +type specMount struct { + Destination string `json:"destination"` + Type string `json:"type"` + Source string `json:"source"` + Options []string `json:"options,omitempty"` +} + +func writeBundleSpecOpts(t *testing.T, bundle string, rootReadonly bool, mounts []specMount) { + t.Helper() + spec := struct { + Root struct { + Readonly bool `json:"readonly"` + } `json:"root"` + Mounts []specMount `json:"mounts"` + }{Mounts: mounts} + spec.Root.Readonly = rootReadonly + 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) + } +} + +// TestResolveMountRootReadOnly pins the readonly flag: the last ro/rw option, +// including recursive variants, wins, and a path no mount covers falls back +// to the spec's root flag. +func TestResolveMountRootReadOnly(t *testing.T) { + bundle, _, _ := makeRootfs(t) + writeBundleSpecOpts(t, bundle, true, []specMount{ + {Destination: "/ro", Type: "bind", Source: "/mnt/ro", Options: []string{"rbind", "ro"}}, + {Destination: "/rw", Type: "bind", Source: "/mnt/rw", Options: []string{"rbind"}}, + {Destination: "/ro-then-rw", Type: "bind", Source: "/mnt/a", Options: []string{"rbind", "ro", "rw"}}, + {Destination: "/rw-then-ro", Type: "bind", Source: "/mnt/b", Options: []string{"rbind", "rw", "ro"}}, + {Destination: "/rro", Type: "bind", Source: "/mnt/rro", Options: []string{"rbind", "rro"}}, + {Destination: "/rro-then-rrw", Type: "bind", Source: "/mnt/c", Options: []string{"rbind", "rro", "rrw"}}, + {Destination: "/rrw-then-rro", Type: "bind", Source: "/mnt/d", Options: []string{"rbind", "rrw", "rro"}}, + }) + + for _, tc := range []struct { + path string + want bool + }{ + {"/ro/file", true}, + {"/rw/file", false}, + {"/ro-then-rw/file", false}, + {"/rw-then-ro/file", true}, + {"/rro/file", true}, + {"/rro-then-rrw/file", false}, + {"/rrw-then-rro/file", true}, + // No mount covers the path: the read-only root decides. + {"/etc/hosts", true}, + } { + _, _, readonly, err := resolveMountRoot(bundle, tc.path) + if err != nil { + t.Fatal(err) + } + if readonly != tc.want { + t.Errorf("%s: readonly = %v, want %v", tc.path, readonly, tc.want) + } + } +} + +func TestResolveMountRootDuplicateDestinationUsesLast(t *testing.T) { + bundle, _, _ := makeRootfs(t) + writeBundleSpecOpts(t, bundle, false, []specMount{ + {Destination: "/data", Type: "bind", Source: "/mnt/first", Options: []string{"ro"}}, + {Destination: "/data", Type: "bind", Source: "/mnt/second", Options: []string{"rw"}}, + }) + + root, rel, readonly, err := resolveMountRoot(bundle, "/data/file") + if err != nil { + t.Fatal(err) + } + if root != "/mnt/second" || rel != "/file" || readonly { + t.Fatalf("resolved to (%q, %q, readonly=%v), want (%q, %q, readonly=false)", + root, rel, readonly, "/mnt/second", "/file") + } +} + +// TestTransferImportToReadOnlyPathRejected verifies copy-to into a read-only +// mount or rootfs fails with ErrPermissionDenied, the backing bytes intact. +func TestTransferImportToReadOnlyPathRejected(t *testing.T) { + newBundle := func(t *testing.T) (bundleParent, bundle string) { + t.Helper() + bundleParent = t.TempDir() + bundle = filepath.Join(bundleParent, "c1") + if err := os.MkdirAll(filepath.Join(bundle, "rootfs"), 0755); err != nil { + t.Fatal(err) + } + return bundleParent, bundle + } + + // A ReadStream with no backing stream asserts the rejection precedes any read. + transferTo := func(t *testing.T, bundleParent, containerPath string) error { + t.Helper() + return NewContainerFSTransferrer(bundleParent).Transfer(context.Background(), + &ReadStream{MediaType: mediaTypeTar}, + &ContainerPath{ContainerID: "c1", Path: containerPath}) + } + + t.Run("read-only bind mount", func(t *testing.T) { + bundleParent, bundle := newBundle(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, "keep"), []byte("ORIGINAL"), 0644); err != nil { + t.Fatal(err) + } + writeBundleSpecOpts(t, bundle, false, []specMount{ + {Destination: "/data", Type: "bind", Source: source, Options: []string{"rbind", "ro"}}, + }) + + err := transferTo(t, bundleParent, "/data/keep") + if !errdefs.IsPermissionDenied(err) { + t.Fatalf("expected permission-denied error, got %v", err) + } + got, err := os.ReadFile(filepath.Join(source, "keep")) + if err != nil { + t.Fatal(err) + } + if string(got) != "ORIGINAL" { + t.Fatalf("read-only mount source was modified: %q", got) + } + }) + + t.Run("read-only rootfs", func(t *testing.T) { + bundleParent, bundle := newBundle(t) + writeBundleSpecOpts(t, bundle, true, nil) + + if err := transferTo(t, bundleParent, "/etc/hosts"); !errdefs.IsPermissionDenied(err) { + t.Fatalf("expected permission-denied error, got %v", err) + } + }) +}