fix(cow): return ENOENT when opening a file deleted in the branch#144
fix(cow): return ENOENT when opening a file deleted in the branch#144dzerik wants to merge 1 commit into
Conversation
A file deleted in a seccomp COW branch is recorded as a whiteout in the in-memory `deleted` set; the real lower file is intentionally left untouched until commit. The stat/access handlers honor the whiteout and return ENOENT, but the read/open path returned `CowOpenPlan::Skip` -> `NotifAction::Continue`, so the kernel opened the untouched lower file and the reader saw the file's pre-delete content. Concretely: after `rm f`, `test -e f` reports the deletion (stat path) while a bare `open(f, O_RDONLY)` — e.g. `dd if=f`, which does not stat first — reads the original bytes. The two paths disagreed and a deletion was invisible to a reader. Add `CowOpenPlan::Deleted` for a whiteout opened without `O_CREAT`; dispatch maps it to `Errno(ENOENT)`, matching the stat/access handlers. Reads of a deleted file now fail closed instead of leaking lower content. This also makes a stage's deletion visible to later stages under the transactional-pipeline read-committed model. Tests: a unit test asserting `prepare_open` reports `Deleted` for a whiteout, and an integration test where a child deletes a workdir file and a bare open (`dd`) must be denied rather than read the pre-delete bytes.
| // 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); |
There was a problem hiding this comment.
This fixes the seccomp dispatcher, but chroot mode still leaks: the sync handle_open above (~line 385) returns Ok(None) for a whiteout without O_CREAT, and chroot/dispatch.rs maps Ok(None) to a fall-through that opens the real lower file via openat2_in_root, so a reader still gets the pre-delete bytes there. handle_open should surface the whiteout too, with the chroot call site mapping it to ENOENT.
| @@ -447,7 +451,11 @@ impl SeccompCowBranch { | |||
| if flags & O_CREAT != 0 { | |||
| return self.prepare_cow_copy(&rel); | |||
There was a problem hiding this comment.
O_CREAT on a whiteout bypasses the fail-closed guarantee this PR adds: prepare_copy removes the path from deleted and copies the lower bytes into upper, so rm f; touch f; cat f (or a bare open(f, O_CREAT|O_RDONLY)) resurrects the pre-delete content. After unlink, O_CREAT should create an empty upper file instead of copying lower. Fine as a tracked follow-up if out of scope here.
Problem
A file deleted in a seccomp COW branch is recorded as a whiteout in the in-memory
deletedset; the real lower file is intentionally left untouched until commit. The stat/access handlers honor that whiteout and returnENOENT, but the read/open path did not: for a deleted path opened withoutO_CREAT,prepare_openreturnedCowOpenPlan::Skip, whichhandle_cow_openmaps toNotifAction::Continue— so the kernel opened the untouched lower file and the reader saw the file's pre-delete content.The two paths disagreed, so a deletion was invisible to a reader.
Example
cat secret.txthappens to hide the bug because coreutilscatstats first and short-circuits on the (correct) statENOENT; a bareopen()with no preceding stat (dd, a rawopenat, some readers) hits the leaking path.Fix
Add
CowOpenPlan::Deletedfor a whiteout opened withoutO_CREAT;handle_cow_openmaps it toNotifAction::Errno(libc::ENOENT), matching the stat/access handlers. Reads of a deleted file now fail closed instead of leaking lower content.Tests
prepare_openreportsDeleted(notSkip) for a whiteout openedO_RDONLY.dd) must be denied (ENOENT) and copy zero bytes — the test fails withleaked: "PREDELETE"on the old behavior.Scope
This is a general COW whiteout fix on the read/open path — it also reproduces in a single (non-pipeline) sandbox. It additionally makes a stage's deletion visible to later stages under the read-committed model of the multi-sandbox transactions work (#65), which is why I split it out as its own small change.