fix(transfer): honor bind-mount semantics in filesystem transfers - #287
fix(transfer): honor bind-mount semantics in filesystem transfers#287ilopezluna wants to merge 5 commits into
Conversation
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 <nicolas.deloof@gmail.com>
…rces 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 containerd#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 <nicolas.deloof@gmail.com>
resolveMountRoot maps a container path onto its backing directory, outside the mount namespace where MS_RDONLY is enforced, so an import to a path the container sees as read-only would silently modify content the container cannot write itself. Surface the protection during resolution — the matched bind mount's options, scanned with last-option-wins ro/rw semantics like the shim's mount transform, or the spec's root read-only flag when no mount covers the path — and refuse the transfer before the input stream is consumed. Exports are unaffected: reading a read-only mount is what the container itself may do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens the container filesystem transferrer so imports (copy-to) do not bypass the container’s read-only view when paths are backed by bind mounts or a read-only root, by resolving container paths against bundle config.json mounts and refusing writes to destinations marked read-only.
Changes:
- Extend
resolveMountRootto resolve container paths to their backing directory and report whether the container-view path is write-protected (mountro/rwoptions +root.readonlyfallback). - Update copy-to to reject imports to read-only destinations with
ErrPermissionDeniedbefore consuming the input stream; keep exports allowed. - Adjust export tar top-level naming to reflect the container-view path (even when mount source basenames differ) and add file-destination import support (
extractOverFile) for single-file bind mounts.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| internal/transfer/containerfs.go | Adds mount-aware path resolution (including readonly flag), enforces read-only import refusal, and improves export/import behavior around mounts and file destinations. |
| internal/transfer/containerfs_test.go | Adds/updates unit and end-to-end tests covering mount resolution, readonly semantics, and copy-to refusal behavior. |
Suppressed comments (1)
internal/transfer/containerfs.go:438
- extractOverFile can modify the target file and still return an error when the tar contains multiple entries: the first entry is applied, then the second header triggers "cannot extract multiple entries". This violates the expectation that a rejected archive leaves the destination untouched; consider validating single-entry-ness (e.g., stream to a temp file / buffer, ensure EOF, then truncate+write) before touching the target.
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)
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
8aa7217 to
08e8102
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
internal/transfer/containerfs.go:369
- In readPath, the parameters and error message still refer to "rootfs", but callers now pass the resolved backing directory (rootfs or a bind-mount source). The inline comment here also says "file in the rootfs", which is no longer always true and can confuse debugging when OpenRoot fails on a mount source path.
// 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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
internal/transfer/containerfs.go:90
- The comment for resolveMountRoot says it returns "the path relative" to the resolved root, but the function currently returns values like "/file" (see strings.TrimPrefix at line 174). This mismatch makes the contract unclear for callers; either normalize rel to be truly relative (no leading "/") or adjust the docstring to reflect that rel may be absolute-like and is intended to be passed through rootRel().
// 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.
internal/transfer/containerfs.go:440
- extractOverFile returns the raw error from io.CopyN / Close without context; in common failure cases this can surface as a bare EOF/UnexpectedEOF, which is hard to diagnose from a Transfer error. Wrapping these with target/path context would make failures actionable.
// 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
03a721b to
0f3a566
Compare
Kern--
left a comment
There was a problem hiding this comment.
I don't think this statement is correct:
The longest matching bind destination wins, so a nested mount takes precedence over its parent.
Mounts are ordered, so last match should win, not longest.
We've got a bunch of unhandled edge cases that are cropping up because we're trying to figure out the filesystem without actually mounting. This is fine for now, but as part of #164 we should consider the moby approach of doing the mounting and copying from the mount point instead.
AI finding:
[P2] Longest destination does not always represent the visible mount
Location: internal/transfer/containerfs.go:158
Description: Mount resolution ignores OCI mount ordering and always selects the most deeply nested destination.
Behavior:
- Setup: The spec lists a bind mount at /data/inner, followed by another bind mount at /data.
- Actual: A transfer involving /data/inner/file accesses the first mount’s source.
- Expected: It must access the later /data mount’s inner/file, because OCI mounts are applied in array order and the later parent mount hides the earlier child mount.
Contract/invariant: Transfers must reflect the container’s actual mounted filesystem view.
Failure path: resolveMountRoot considers every matching bind mount and replaces the candidate solely when len(dest) >= len(bestDest). Thus, /data/inner remains selected even when a
later /data mount shadows it. The transfer then reads or writes a backing source that is invisible inside the container.
Impact: Copy-to can report success while the container sees no change. Copy-from can expose stale or otherwise hidden content from a mount source. Reverse-ordered nested mounts are valid
OCI input, though less common than parent-before-child ordering.
Confidence: High
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.
There was a problem hiding this comment.
🟢 Approval recommended
The mount-aware resolution and read-only enforcement are implemented consistently with the stated OCI ordering semantics and are backed by comprehensive unit tests for the key edge cases and failure modes.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
7df41dc to
f2a11a9
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The new single-file overwrite path (extractOverFile) appears to enforce “exactly one tar entry” rather than “exactly one regular-file payload,” which can reject otherwise-valid single-file archives that include common tar metadata entries.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/transfer/containerfs.go:424
extractOverFileenforces “exactly one tar entry” (tr.Next()must hit EOF immediately after the first regular file). Many tar writers may include non-payload metadata entries (e.g., PAX xheaders / GNU longname/link records) while still carrying exactly one regular-file payload; those would be rejected here even though they satisfy the stated “exactly one regular file” contract. If compatibility with such archives matters, consider skipping known metadata entry types and enforcing “exactly one regular file payload entry” instead.
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
It changes security-sensitive filesystem/path resolution and extraction behavior around mounts and read-only enforcement, so a final human review is warranted despite strong test coverage.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
5895d3e to
bc879fd
Compare
Kern--
left a comment
There was a problem hiding this comment.
LGTM.
I'm not sure the development history is that useful, but squashing is definitely not blocking.
Extracting directly over an existing file truncated it before the complete archive had been validated. An empty, truncated, or multi-entry archive could therefore report an error only after destroying some or all of the original content. Stage the single regular-file payload alongside the destination and validate the remainder of the archive before opening the destination. Reject empty, non-regular, truncated, and multi-entry archives without modifying the target, then copy the staged bytes over the existing inode so a container with that file bind-mounted immediately sees the replacement. Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com> (squashed from the original changeset) Signed-off-by: Kern Walster <kern.walster@gmail.com>
Choosing the longest matching mount destination does not reproduce the OCI runtime view: mounts are applied in declaration order, so a later parent may hide an earlier child and duplicate destinations resolve to the last entry. Resolve against the last matching bind mount, interpret legacy relative destinations from /, and canonicalize source symlinks to the object selected when the runtime created the mount. Treat an unreadable or malformed bundle config as an error instead of silently falling back to the shadowed rootfs, and include its path in the diagnostic. Honor recursive rro and rrw options when determining whether an import is allowed. Also tighten file-destination extraction: accept legacy regular-file and global PAX headers, restrict the special path to actual regular files, and use a random exclusive staging file so concurrent transfers cannot collide. Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com> (squashed from the original changeset) Signed-off-by: Kern Walster <kern.walster@gmail.com>
bc879fd to
2bfdd93
Compare
| mountSrc = resolved | ||
| } | ||
|
|
||
| rel = strings.TrimPrefix(target, mountDest) |
There was a problem hiding this comment.
Single-file mount path building breaks when mount destination is /.
| rel = strings.TrimPrefix(target, mountDest) | |
| rel = strings.TrimPrefix(target, mountDest) | |
| if mountDest == "/" && rel != "" { | |
| rel = "/" + rel | |
| } |
Failing unit test:
func TestResolveMountRootSingleFileMountAtRoot(t *testing.T) {
bundle, _, _ := makeRootfs(t)
source := filepath.Join(bundle, "single-file")
if err := os.WriteFile(source, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
resolvedSource, err := filepath.EvalSymlinks(source)
if err != nil {
t.Fatal(err)
}
writeBundleSpec(t, bundle, specMount{Destination: "/", Source: source})
root, rel, _, err := resolveMountRoot(bundle, "/etc/foo")
if err != nil {
t.Fatal(err)
}
wantRoot := filepath.Dir(resolvedSource)
wantRel := filepath.Base(resolvedSource) + "/etc/foo"
if root != wantRoot || rel != wantRel {
t.Errorf("resolveMountRoot(%q) = (%q, %q), want (%q, %q)", "/etc/foo", root, rel, wantRoot, wantRel)
}
}| 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) |
There was a problem hiding this comment.
Opening with truncate can destroy the original contents if the copy fails. Is that acceptable or should we consider truncating after copy succeeds?
Supersedes #260.
This PR contains #260's two commits unchanged, preserving their authorship, plus the read-only enforcement and file-import hardening added during follow-up review. It is intended to merge directly into
mainas the complete mount-aware transfer change.Problem
The container-FS transferrer anchors imports and exports at the bundle's rootfs. That directory only backs paths not covered by a mount: when the OCI spec declares a bind mount, its source shadows the corresponding rootfs path inside the container.
As a result:
ro.Changes
Resolve paths against bind mounts
resolveMountRootreads the bundle spec and maps a container-view path to the directory that backs it. The last matching bind mount in spec order wins, so a later parent mount can hide an earlier child mount. Both transfer directions use the resolved path.The resolver also:
/, as required by OCI on Linux;os.Rootat the source's parent;Enforce the container's read-only view
Resolution happens outside the mount namespace where
MS_RDONLYis enforced, so the resolver now reports whether the destination is write-protected:ro/rwand recursiverro/rrwsemantics; orroot.readonlyflag applies.Copy-to rejects a read-only destination with
ErrPermissionDeniedbefore opening the input stream. A writable mount inside a read-only root remains writable, matching Docker semantics. Exports remain allowed.Validate file-destination imports before mutation
An import over an existing file must contain exactly one regular file. The payload is staged in a temporary sibling and the complete archive is validated before the destination is truncated in place. Empty, truncated, non-regular, or multi-entry archives therefore fail without changing the original file or leaving staging debris.
Bundle-spec behavior
A missing
config.jsonfalls back to the rootfs. A config that exists but cannot be read or parsed returns an error: resolving blindly could write to a shadowed path or bypass a read-only mount.Known limitations
Issue #164 continues to track paths whose subtree crosses into a deeper mount and non-bind mount types, such as
tmpfs, whose contents only exist in the container's mount namespace.Tests
ro/rwandrro/rrwbehavior and the read-only-root fallback.