-
Notifications
You must be signed in to change notification settings - Fork 30
fix(cow): return ENOENT when opening a file deleted in the branch #144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,6 +46,10 @@ pub enum CowCopyPlan { | |
| pub enum CowOpenPlan { | ||
| /// No interception needed — let the kernel handle it. | ||
| Skip, | ||
| /// The path was deleted in this branch (a whiteout) and is opened without | ||
| /// `O_CREAT`. The caller must return `ENOENT` rather than letting the kernel | ||
| /// open the untouched lower file, which still holds the pre-delete bytes. | ||
| Deleted, | ||
| /// File already resolved (upper or lower) — open this path directly. | ||
| Resolved(PathBuf), | ||
| /// Need to copy lower to upper, then open upper. | ||
|
|
@@ -382,7 +386,12 @@ impl SeccompCowBranch { | |
| if flags & O_CREAT != 0 { | ||
| return self.ensure_cow_copy(&rel).map(Some); | ||
| } | ||
| return Ok(None); | ||
| // Whiteout: the lower file still physically exists with its | ||
| // pre-delete bytes. Surface the deletion so the caller returns | ||
| // ENOENT rather than falling through to the lower file — matching | ||
| // the async prepare_open (CowOpenPlan::Deleted) and the stat/access | ||
| // handlers. | ||
| return Err(BranchError::Deleted); | ||
| } | ||
|
|
||
| // O_EXCL: fail if file already exists (in upper or lower) | ||
|
|
@@ -447,7 +456,11 @@ impl SeccompCowBranch { | |
| if flags & O_CREAT != 0 { | ||
| return self.prepare_cow_copy(&rel); | ||
| } | ||
| return Ok(CowOpenPlan::Skip); | ||
| // Whiteout: the file was deleted in this branch. Do NOT skip to the | ||
| // lower file (which still physically exists with its pre-delete | ||
| // content); report the deletion so the caller returns ENOENT, | ||
| // matching the stat/access path. | ||
| return Ok(CowOpenPlan::Deleted); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fixes the seccomp dispatcher, but chroot mode still leaks: the sync |
||
| } | ||
|
|
||
| // O_EXCL: fail if file already exists | ||
|
|
@@ -1384,6 +1397,37 @@ mod tests { | |
| assert!(matches!(plan, CowOpenPlan::Resolved(_))); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_prepare_open_read_deleted_reports_deleted() { | ||
| // A file deleted in this branch is a whiteout: a read-only open must NOT | ||
| // fall through to the untouched lower file (which still holds the | ||
| // pre-delete bytes). It must report the deletion so the caller returns | ||
| // ENOENT, matching the stat/access path. | ||
| let (workdir, storage) = setup_workdir(); | ||
| let mut branch = SeccompCowBranch::create(workdir.path(), Some(storage.path()), 0).unwrap(); | ||
| branch.mark_deleted("existing.txt"); | ||
| let path = abs(&branch, "existing.txt"); | ||
| // O_RDONLY | ||
| let plan = branch.prepare_open(&path, 0).unwrap(); | ||
| assert!(matches!(plan, CowOpenPlan::Deleted)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_handle_open_read_deleted_reports_deleted() { | ||
| // Sync mirror of test_prepare_open_read_deleted_reports_deleted: the | ||
| // chroot dispatcher calls the sync handle_open, so a read-only open of a | ||
| // whiteout must surface BranchError::Deleted (mapped to ENOENT at the | ||
| // chroot call site) instead of Ok(None), which fell through to the | ||
| // untouched lower file and leaked its pre-delete bytes. | ||
| let (workdir, storage) = setup_workdir(); | ||
| let mut branch = SeccompCowBranch::create(workdir.path(), Some(storage.path()), 0).unwrap(); | ||
| branch.mark_deleted("existing.txt"); | ||
| let path = abs(&branch, "existing.txt"); | ||
| // O_RDONLY | ||
| let err = branch.handle_open(&path, 0).unwrap_err(); | ||
| assert!(matches!(err, BranchError::Deleted)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_prepare_open_write_existing_needs_copy() { | ||
| let (workdir, storage) = setup_workdir(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
O_CREATon a whiteout bypasses the fail-closed guarantee this PR adds:prepare_copyremoves the path fromdeletedand copies the lower bytes into upper, sorm f; touch f; cat f(or a bareopen(f, O_CREAT|O_RDONLY)) resurrects the pre-delete content. After unlink,O_CREATshould create an empty upper file instead of copying lower. Fine as a tracked follow-up if out of scope here.