Skip to content

fix(bundle): hold a bundle to regular files and directories - #868

Open
raymondk wants to merge 3 commits into
rk/confined-project-loadfrom
rk/bundle-symlinks
Open

raymondk wants to merge 3 commits into
rk/confined-project-loadfrom
rk/bundle-symlinks

Conversation

@raymondk

@raymondk raymondk commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

A symlink inside a plugin's files: or dirs: directory was recorded in the bundle as a link entry with its target unchecked, so a link to a file outside the project reached that file on whatever machine unpacked the bundle. A files: entry that was itself a link to a file outside the project was read straight into the bundle, since it is checked lexically before the build, when it need not exist yet.

The archive walk is now the bundler's own rather than tar's append_dir_all. Every entry met is canonicalized and must land under the canonical project directory. One that does is appended as what it points at, so a project may link to its own files freely. One that does not fails the bundle, naming the link and where it leads. A dangling link, a link that loops, a link back to a directory being copied, an entry that resolves to the archive being written, a name that is not UTF-8, and anything that is neither a file nor a directory fail the same way. A files: entry is held to the project once more after the build, when it exists. Entries go in sorted order, so the same tree gives the same archive.

This is the bundling side of the rule #866 states. The user's own project may link wherever it likes. A bundle holds only regular files and directories, in-project links dereferenced and out-of-project links refused. A consumer of an untrusted bundle refuses anything else on the way in, after which the lexical Confined is complete.

Seven unix integration tests: an in-project file and directory link copied as what they point at, an outward link, a dangling link, a cycle, a loop, the output reached through a link, and a symlinked files: entry.

Ref: #863, #866

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Directory-link traversal can include the still-being-written output archive, while non-UTF-8 entries are silently omitted and some link-resolution failures are misreported as missing targets.

3 open issues
What changed in this PR

This PR tightens bundle creation to produce regular files and directories, supporting the bundle contract described in #863 and complementing the lexical confinement in #866.

Changes:

  • Replaces tar’s directory traversal with a sorted walk that dereferences in-project links and rejects escaping links, dangling links, directory cycles, and unsupported entries.
  • Rechecks plugin file paths after the build.
  • Adds five Unix integration tests and documents the behavior change.
File Description
crates/​icp-project/​src/​operations/​bundle.rs Adds confined directory traversal, link diagnostics, and post-build file validation.
crates/​icp-cli/​tests/​bundle_tests.rs Tests in-project link copying and rejection of unsafe links.
CHANGELOG.md Documents the new bundle restrictions and symlink handling.

Review effort: Balanced

path: entry.clone(),
})?;
if meta.is_dir() {
self.walk(&entry, &entry_archive_path, root, open)?;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and that was new with this PR: tar's walk recorded the link and never reached the archive, while dereferencing does. Fixed in 4b3b82e: validate_output_path now hands back the canonical output, the walk carries it, and an entry that resolves to it is refused with OutputReachedThroughLink, named by the path it was reached by (assets/shared/bundle.tar.gz). bundle_rejects_output_reached_through_symlink is the regression test, with exactly the layout you describe.

path: PathBuf::from(archive_path),
})?;
open.push(real.clone());
for entry in crate::fs::read_dir(&real)? {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. crate::fs::read_dir skipping such a name is the right call for the glob machinery, where it lists candidates, and the wrong one here, where a skipped entry is a file silently missing from the bundle. In 4b3b82e the walk lists directories itself and refuses a name that is not UTF-8 with NameNotUtf8, naming the directory and the name lossily, since the archive could not carry it anyway (all paths here are UTF-8). bundle_rejects_a_name_that_is_not_utf8 covers it on Linux.

Comment on lines +1927 to +1933
if let Ok(target) = std::fs::read_link(path) {
return DanglingLinkSnafu {
path: path.to_path_buf(),
target: target.display().to_string(),
}
.fail();
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. In 4b3b82e a link is called dangling only when canonicalizing fails with NotFound and read_link succeeds; a loop or an unreadable target keeps its own io::Error under ResolveArchiveEntry. bundle_rejects_symlink_loop checks that a self -> self link is reported as unresolvable and not as missing.

@raymondk
raymondk changed the base branch from main to rk/confined-project-load November 4, 2026 19:54
@raymondk
raymondk added this pull request to stack #869 November 4, 2026 19:54
@raymondk
raymondk marked this pull request as ready for review November 5, 2026 01:39
@raymondk
raymondk requested a review from a team as a code owner November 5, 2026 01:39
raymondk and others added 2 commits November 5, 2026 07:46
A symlink inside a plugin's `files:` or `dirs:` directory was recorded
as a link entry with its target unchecked, so a link to a file outside
the project reached that file on whatever machine unpacked the bundle.
A `files:` entry that was itself a symlink to a file outside the
project was read straight into the bundle: it was validated lexically
before the build, when it need not have existed, and the read followed
the link.

The archive walk is now the bundler's own rather than tar's. Every
entry met is canonicalized and must land under the canonical project
directory: one that does is appended as what it points at, so a project
may link to its own files freely, and one that does not fails the
bundle, naming the link and where it leads. A dangling link, a link
back to a directory being copied, and anything that is neither a file
nor a directory fail the same way. A `files:` entry is held to the
project again once the build has run and it exists.

The walk appends entries in sorted order, which makes the archive
reproducible for a given tree where tar's own walk left the order to
the filesystem.

This is the bundling side of the rule that `files::Confined` (#866)
relies on: a bundle holds no symlinks, a consumer refuses any archive
entry that is not a regular file or directory (#863), and so the tree
a confined loader reads has nothing in it that leads elsewhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y name

Three holes in the archive walk, from review:

- The output path is checked against the copied directories by its
  spelling, which a symlink inside one of them gets around: with
  `assets/shared -> ../shared` and `--output shared/bundle.tar.gz`, the
  walk met the archive it was writing and copied a partial copy of it
  into itself. The canonical output now travels with the walk, and an
  entry that resolves to it is refused, named by the path it was
  reached by.
- `crate::fs::read_dir` skips a name that is not UTF-8, which for a
  bundle meant a file silently left out where tar's walk had carried
  it. The walk lists directories itself and refuses such a name, since
  the archive cannot carry it either way.
- A link was called dangling whenever canonicalizing it failed and
  `read_link` succeeded, which also covers a link that loops or leads
  somewhere unreadable. Only a `NotFound` is dangling now; the rest
  keep their own cause.

Entries met under a link are listed where the directory really is but
named by the path they were reached by, so every message says what the
user wrote rather than where it led.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread CHANGELOG.md Resolved
…stale doc comment

The new `## Experimental` heading sat above the existing Intel macOS
chore entry, which made that general change read as experimental. The
heading and the bundle entry now follow it, as in v1.8.0.

`validate_output_path` kept its old doc comment above the new one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants