Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
fcf4c93
pipeline: transactional pipelines over a shared COW workdir (RFC #65 …
dzerik Jul 11, 2026
c91197d
test(pipeline): cover the untested transaction guardrails and boundaries
dzerik Jul 15, 2026
7805334
docs(pipeline): TxnOutcome.stages is empty on a timeout/driver-error …
dzerik Jul 15, 2026
89ea1f6
feat(pipeline): first-committer-win lock on the transactional commit …
dzerik Jul 19, 2026
d566a14
fix(cow): a failed commit must preserve the upper, not destroy it
dzerik Jul 21, 2026
aacbdd5
refactor: move the transactional path off Pipeline onto its own Trans…
dzerik Jul 21, 2026
bef6f3f
fix(txn): wait for the commit lock, and stop calling it first-committ…
dzerik Jul 21, 2026
c341686
feat(txn): type the abort reason, report changes, add dry_run
dzerik Jul 21, 2026
bfe1556
fix(txn): preserve the upper when the commit lock cannot be taken
dzerik Jul 22, 2026
8e55708
fix(cow): an abandoned sandbox must not override BranchAction::Keep
dzerik Jul 22, 2026
12d562f
feat(txn): type the commit channel and give each failure an exit code
dzerik Jul 22, 2026
647e97c
fix(cow): a partial merge must leave the remainder, not the whole cha…
dzerik Jul 22, 2026
99d0e5d
feat(cow): make a preserved branch findable on disk, not only in RAM
dzerik Jul 22, 2026
9d6da62
test(txn): make the commit-lock test observe the contention it claims
dzerik Jul 22, 2026
eeff47e
test(txn): give the uncontended-lock test a property it can fail
dzerik Jul 22, 2026
3e3d645
docs(txn): correct three statements the code does not carry
dzerik Jul 22, 2026
c3478aa
style(cow): collect the merge walk with a for loop
dzerik Jul 22, 2026
1a085b8
fix(cow): fail the commit when a deletion could not be applied
dzerik Jul 22, 2026
4a23787
fix(txn): run the commit phase on a blocking thread so cancelling the…
dzerik Jul 22, 2026
177df31
fix(cow): record a preserved branch's deletions, so recovery cannot r…
dzerik Jul 22, 2026
5a0faea
fix(txn): flatten the commit channel to a Branch error, not a child f…
dzerik Jul 22, 2026
1602906
test(txn): cover the shared upper's Landlock execute grant
dzerik Jul 22, 2026
86acfed
refactor(pipeline): make a chain's stages private so the type claim h…
dzerik Jul 22, 2026
2d70402
docs(cow): say that a merge in flight is indistinguishable from an in…
dzerik Jul 22, 2026
ee24c31
test(cow): cover the on_error half of an abandoned Keep
dzerik Jul 22, 2026
f008812
docs(txn): describe why the branch is moved onto a blocking thread
dzerik Jul 22, 2026
93532e1
test(cow): cover the interception boundary and the preserved-marker s…
dzerik Jul 22, 2026
ed02a9b
test(txn): cover the commit phase's disposition of the shared upper
dzerik Jul 22, 2026
6ddc5a8
test(txn): cover the dry-run contract, the timeout's scope and the ca…
dzerik Jul 22, 2026
4f97e3b
test(txn): cover the validator's guardrails and the error channels
dzerik Jul 22, 2026
e1e4871
test(cow): cover branch disposition, failed merges and the COW error …
dzerik Jul 22, 2026
e3c41d2
test(cow): cover the merge's deletion, mode and name handling
dzerik Jul 22, 2026
2be77ab
docs(cow): stop claiming Ok(()) always means the change set landed
dzerik Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,234 changes: 2,139 additions & 95 deletions crates/sandlock-core/src/cow/seccomp.rs

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions crates/sandlock-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub mod netlink;
pub(crate) mod procfs;
pub(crate) mod port_remap;
pub mod pipeline;
pub mod transaction;
pub mod policy_fn;
pub mod image;
pub mod fork;
Expand All @@ -41,6 +42,10 @@ pub use sandbox::{
};
pub use result::{RunResult, ExitStatus};
pub use pipeline::{Stage, Pipeline, Gather};
pub use transaction::{AbortReason, Transaction, TxnError, TxnOutcome};
// Recovery of COW branch storage that was preserved rather than reclaimed. The
// rest of `cow` is internal; these are what an out-of-band sweep needs.
pub use cow::seccomp::{list_preserved, read_preserved, PreserveReason, PreservedBranch};
pub use dry_run::{Change, ChangeKind, DryRunResult};
// Sectioned-profile parsing types: ProfileInput is the top-level deserialization
// target; ProgramSpec carries [program].exec/args (not a Sandbox field).
Expand Down
32 changes: 31 additions & 1 deletion crates/sandlock-core/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
//! Data flows through kernel pipe buffers between stages; the parent process
//! never reads inter-stage data.
//!
//! For stages that instead share a workspace and commit all-or-nothing, see
//! [`Transaction`](crate::transaction::Transaction): different execution model,
//! different type.
//!
//! ```ignore
//! let result = (
//! Stage::new(&policy_a, &["echo", "hello"])
Expand Down Expand Up @@ -76,8 +80,12 @@ impl std::ops::BitOr<Stage> for Stage {
///
/// Minimum 2 stages. Each stage runs concurrently in its own sandbox.
/// Only the last stage's stdout and stderr are captured.
///
/// The stages are private: "these are connected by pipes" is the whole meaning
/// of a `Pipeline`, and a bare `Vec<Stage>` has lost it. Taking them out is
/// [`into_stages`](Self::into_stages), which a caller has to name.
pub struct Pipeline {
pub stages: Vec<Stage>,
stages: Vec<Stage>,
}

impl Pipeline {
Expand All @@ -91,6 +99,28 @@ impl Pipeline {
Ok(Self { stages })
}

/// How many stages the chain has.
pub fn len(&self) -> usize {
self.stages.len()
}

/// Always false — a `Pipeline` has at least 2 stages by construction.
pub fn is_empty(&self) -> bool {
self.stages.is_empty()
}

/// Take the stages out of the chain, dropping the pipes between them.
///
/// The pipes are not a property of a `Stage`; they exist only while the
/// stages are held as a `Pipeline`. Handing the result to something that
/// runs stages another way — [`Transaction`](crate::transaction::Transaction)
/// runs them sequentially over a shared workdir — therefore runs commands
/// that were written to stream into each other, without the stream. That is
/// a decision, so it has a name rather than a public field.
pub fn into_stages(self) -> Vec<Stage> {
self.stages
}

/// Run the pipeline. Returns the last stage's exit status and captured output.
///
/// `timeout` applies to the entire pipeline.
Expand Down
99 changes: 87 additions & 12 deletions crates/sandlock-core/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,28 @@ struct Runtime {
on_bind: Option<Box<dyn Fn(&HashMap<u16, u16>) + Send + Sync>>,
handlers: Vec<(i64, Arc<dyn crate::seccomp::dispatch::Handler>)>,
ready_w: Option<std::os::fd::OwnedFd>,
/// Set when this sandbox is a stage of a [`Transaction`](crate::transaction::Transaction)
/// that shares one COW upper across all stages. When present, `do_create_stdio`
/// reuses this `CowState` (instead of building its own branch), and neither
/// `wait()` nor `Drop` take/commit/abort the branch — the transaction
/// coordinator owns the single commit/abort. See [`SharedCow`].
shared_cow: Option<SharedCow>,
}

/// A COW branch (one `upper` over the workdir) shared by every stage of a
/// [`Transaction`](crate::transaction::Transaction). Cloned into each stage's
/// `Runtime` so sequential stages accumulate writes in the same upper
/// (read-committed), while the coordinator retains the original to commit/abort
/// once at the end.
#[derive(Clone)]
pub(crate) struct SharedCow {
/// The shared supervisor COW state (holds the single `SeccompCowBranch`).
pub(crate) state: Arc<tokio::sync::Mutex<crate::seccomp::state::CowState>>,
/// The branch's upper dir. Granted to each stage's Landlock ruleset for the
/// same reason a single sandbox grants its own upper: Landlock checks
/// EXECUTE against a file's real path, which for anything written inside the
/// workdir is the upper. Cached here to avoid locking `state` to read it.
pub(crate) upper_dir: PathBuf,
}

/// Lifecycle state for the runtime.
Expand Down Expand Up @@ -782,9 +804,15 @@ impl Sandbox {
if let Some(h) = rt.throttle_handle.take() { h.abort(); }
if let Some(h) = rt.loadavg_handle.take() { h.abort(); }

if let Some(ref cow_state) = self.rt().supervisor_cow.clone() {
let mut cow = cow_state.lock().await;
self.rt_mut().seccomp_cow = cow.branch.take();
// A transactional-pipeline stage leaves the branch in the shared COW
// state for the next stage / the coordinator's single commit — don't
// take it out (that would strip the upper from later stages) and don't
// let Drop commit/abort it (`seccomp_cow` stays None).
if self.rt().shared_cow.is_none() {
if let Some(ref cow_state) = self.rt().supervisor_cow.clone() {
let mut cow = cow_state.lock().await;
self.rt_mut().seccomp_cow = cow.branch.take();
}
}

let stdout = self.rt_mut()._stdout_read.take().map(sandbox_read_fd_to_end);
Expand Down Expand Up @@ -1162,12 +1190,18 @@ impl Sandbox {
}

/// Dry-run: create, start, wait, collect filesystem changes, then abort.
///
/// The branch action is forced to `Abort`, not `Keep`: a dry run must never
/// merge, and must not leave its upper on disk either — the changes are read
/// out of the branch here and returned, so nothing needs preserving. `Keep`
/// would additionally ask the branch to survive an abandoned run (`?` on
/// create/wait below), which for a dry run is a pure leak.
pub async fn dry_run(
&mut self,
cmd: &[&str],
) -> Result<crate::dry_run::DryRunResult, crate::error::SandlockError> {
self.on_exit = BranchAction::Keep;
self.on_error = BranchAction::Keep;
self.on_exit = BranchAction::Abort;
self.on_error = BranchAction::Abort;
self.do_create(cmd, true).await?;
self.do_start()?;
let run_result = self.wait().await?;
Expand All @@ -1176,13 +1210,13 @@ impl Sandbox {
Ok(crate::dry_run::DryRunResult { run_result, changes })
}

/// Dry-run with inherited stdio.
/// Dry-run with inherited stdio. Same branch handling as [`Self::dry_run`].
pub async fn dry_run_interactive(
&mut self,
cmd: &[&str],
) -> Result<crate::dry_run::DryRunResult, crate::error::SandlockError> {
self.on_exit = BranchAction::Keep;
self.on_error = BranchAction::Keep;
self.on_exit = BranchAction::Abort;
self.on_error = BranchAction::Abort;
self.do_create(cmd, false).await?;
self.do_start()?;
let run_result = self.wait().await?;
Expand Down Expand Up @@ -1328,6 +1362,7 @@ impl Sandbox {
on_bind: None,
handlers: Vec::new(),
ready_w: None,
shared_cow: None,
}));
clones.push(clone_sb);
}
Expand Down Expand Up @@ -1422,10 +1457,21 @@ impl Sandbox {
on_bind: None,
handlers: Vec::new(),
ready_w: None,
shared_cow: None,
}));
Ok(())
}

/// Attach a transaction's shared COW branch to this sandbox before `create`.
/// The stage reuses the shared upper instead of building its own, and leaves
/// commit/abort to the transaction coordinator. Internal — used only by
/// [`Transaction`](crate::transaction::Transaction).
pub(crate) fn set_shared_cow(&mut self, shared: SharedCow) -> Result<(), crate::error::SandlockError> {
self.ensure_runtime()?;
self.rt_mut().shared_cow = Some(shared);
Ok(())
}

// ================================================================
// Internal: collect_changes / do_abort
// ================================================================
Expand Down Expand Up @@ -1575,12 +1621,32 @@ impl Sandbox {
// workdir live in the upper dir, and Landlock checks EXECUTE on the
// file's real path at execve time — so the upper dir must be granted
// read+execute (READ_ACCESS) or `./created-binary` fails with EACCES.
let seccomp_cow_branch = if !no_supervisor && self.workdir.is_some() {
// A transactional-pipeline stage reuses one shared upper across stages;
// it must not build its own branch. Grant Landlock read+exec on the
// shared upper so a binary created in the workdir stays executable.
let shared_cow = self.rt().shared_cow.clone();
let seccomp_cow_branch = if let Some(ref shared) = shared_cow {
self.fs_readable.push(shared.upper_dir.clone());
None
} else if !no_supervisor && self.workdir.is_some() {
let workdir = self.workdir.as_ref().unwrap().clone();
let storage = self.fs_storage.clone();
let max_disk = self.max_disk.map(|b| b.0).unwrap_or(0);
match crate::cow::seccomp::SeccompCowBranch::create(&workdir, storage.as_deref(), max_disk) {
Ok(branch) => {
Ok(mut branch) => {
// `Keep` must survive a sandbox that is never `wait()`ed:
// the branch only reaches `Sandbox`'s own disposition after
// a completed `wait()`, and the branch's `Drop` would
// otherwise reclaim the upper the caller asked to keep.
// Commit and Abort are NOT carried over that way — an
// abandoned run has no exit status and merging its writes
// is not something it can ask for, so those keep the
// reclaiming default. With no exit status there is also no
// choice between the two actions, so either one asking for
// `Keep` preserves.
branch.set_keep_if_abandoned(
self.on_exit == BranchAction::Keep || self.on_error == BranchAction::Keep,
);
self.fs_readable.push(branch.upper_dir().to_path_buf());
Some(branch)
}
Expand Down Expand Up @@ -1847,6 +1913,10 @@ impl Sandbox {

let mut cow_state = CowState::new();
cow_state.branch = seccomp_cow_branch;
// A shared transactional-pipeline branch overrides the per-stage one:
// reuse the coordinator's single COW state so every stage writes into
// (and reads through) the same upper.
let shared_cow_state = shared_cow.as_ref().map(|s| Arc::clone(&s.state));

let mut policy_fn_state = PolicyFnState::new();

Expand Down Expand Up @@ -1895,7 +1965,10 @@ impl Sandbox {
let res_state = Arc::new(tokio::sync::Mutex::new(res_state));
self.rt_mut().supervisor_resource = Some(Arc::clone(&res_state));

let cow_state = Arc::new(tokio::sync::Mutex::new(cow_state));
let cow_state = match shared_cow_state {
Some(shared) => shared,
None => Arc::new(tokio::sync::Mutex::new(cow_state)),
};
self.rt_mut().supervisor_cow = Some(Arc::clone(&cow_state));

let net_state = Arc::new(tokio::sync::Mutex::new(net_state));
Expand Down Expand Up @@ -2091,7 +2164,9 @@ impl Drop for Sandbox {
match action {
BranchAction::Commit => { let _ = cow.commit(); }
BranchAction::Abort => { let _ = cow.abort(); }
BranchAction::Keep => {}
// Mark kept so the branch's Drop backstop preserves the upper
// instead of cleaning it as an undisposed leak.
BranchAction::Keep => cow.keep(),
}
}
}
Expand Down
Loading
Loading