fix(transfer): honor bind-mount semantics in filesystem transfers - #287
fix(transfer): honor bind-mount semantics in filesystem transfers#287ilopezluna wants to merge 20 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.
extractOverFile's contract is exactly one regular file. An archive with no entries left the destination's old bytes in place while the transfer reported success, masking a truncated input stream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
The doc claimed any unreadable config.json falls back to the rootfs, but only a missing or unparseable one does; a config that exists and cannot be read is reported, since resolving blind could land a transfer on a shadowed path or bypass a read-only mount. Describe the deliberate behavior instead of changing it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
…rget extractOverFile truncated the destination while the archive could still turn out malformed: a second entry or a truncated payload was only discovered after the target's bytes were gone, so a rejected import mutated the file it rejected. Stage the payload in a temporary sibling, verify the archive holds exactly one regular file, and only then truncate the destination in place — same inode, so a bind-mount source keeps being seen by the container. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
8aa7217 to
08e8102
Compare
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
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
Since mount resolution, writePath and readPath receive whichever directory backs the container path — a rootfs or a bind-mount source. Name the parameter dir and drop rootfs from the docs and errors. 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
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
03a721b to
0f3a566
Compare
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.
a411703 to
5895d3e
Compare
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
Signed-off-by: Ignacio López Luna <ignacio.lopezluna@docker.com>
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.
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.