diff --git a/CHECKS.md b/CHECKS.md index 8f39e50..1231f36 100644 --- a/CHECKS.md +++ b/CHECKS.md @@ -48,9 +48,9 @@ Inspect how the in-process executor assigns a session identifier to each check's Every check runs against a copy-on-write clone of the working tree, so a misbehaving agent cannot mutate the user's real files. Both checks below must pass for this requirement to be satisfied. -## Check Sandbox Exists and Is Platform-Gated +## Check Sandbox Is Cross-Platform and Platform-Gated -Inspect the sandboxing code. Confirm there is a sandbox abstraction (a trait such as `Sandbox`) with a macOS copy-on-write implementation built on APFS `clonefile`, and that operating-system-specific code is selected with `cfg` attributes so the crate still compiles on non-macOS targets. The check fails if sandboxing is compiled unconditionally for a single operating system in a way that would break the build on other targets. +Inspect the sandboxing code. Confirm there is a sandbox abstraction (a trait such as `Sandbox`) with a working copy-on-write implementation on **both** macOS and Linux: macOS built on APFS `clonefile`, and Linux built on reflinks (the `FICLONE` ioctl) with a plain-copy fallback for filesystems that lack reflink support. Operating-system-specific code must be selected with `cfg` attributes so the crate still compiles on every target. The check fails if a real sandbox is created on only one of macOS or Linux (for example, if Linux falls through to a stub that returns an "unsupported platform" error instead of cloning the working tree), or if sandboxing is compiled unconditionally for a single operating system in a way that would break the build on other targets. ## Check Execution Uses the Sandbox diff --git a/src/checks/sandbox/linux.rs b/src/checks/sandbox/linux.rs new file mode 100644 index 0000000..a040d49 --- /dev/null +++ b/src/checks/sandbox/linux.rs @@ -0,0 +1,134 @@ +//! Linux copy-on-write sandbox via reflinks (the `FICLONE` ioctl). +//! +//! Linux has no whole-tree clone syscall like macOS's `clonefile`, so we +//! recreate the directory structure under a fresh temp directory and clone each +//! regular file with `FICLONE` — a reflink, i.e. a metadata-only copy-on-write +//! share of the file's extents. `FICLONE` is only supported on copy-on-write +//! filesystems (Btrfs, XFS with `reflink=1`, bcachefs, ...); on others (ext4, +//! tmpfs) it fails and we fall back to a plain byte copy, so the sandbox is +//! still an independent clone of the tree — just without the CoW space savings. +//! The clone lives under a temp directory and is removed when the +//! [`SandboxHandle`] is dropped. + +use std::fs::{self, File, OpenOptions}; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::os::unix::io::AsRawFd; +use std::path::Path; + +use async_trait::async_trait; +use miette::{IntoDiagnostic, Result, miette}; + +use super::{Sandbox, SandboxHandle}; + +/// Reflink (`FICLONE`)-backed sandbox with a plain-copy fallback. +#[derive(Default)] +pub struct ReflinkSandbox; + +impl ReflinkSandbox { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Sandbox for ReflinkSandbox { + async fn create(&self, source: &Path) -> Result { + let source = source.to_path_buf(); + // Cloning a tree is blocking filesystem work — keep it off the runtime. + tokio::task::spawn_blocking(move || clone_tree(&source)) + .await + .into_diagnostic()? + } +} + +fn clone_tree(source: &Path) -> Result { + let temp = tempfile::Builder::new() + .prefix("multi-check-") + .tempdir() + .into_diagnostic()?; + // Mirror the macOS layout: the clone root is a `sandbox` directory under the + // temp directory, which owns teardown. + let dest = temp.path().join("sandbox"); + + copy_dir(source, &dest)?; + + Ok(SandboxHandle { + root: dest, + _temp: Some(temp), + }) +} + +/// Recursively recreate `source` at `dest`, reflinking regular files where the +/// filesystem supports it and copying them otherwise. Directories are recreated +/// and symlinks are copied verbatim (the link, not its target). +fn copy_dir(source: &Path, dest: &Path) -> Result<()> { + fs::create_dir_all(dest).into_diagnostic()?; + // Carry the directory's permission bits across. + let perms = fs::metadata(source).into_diagnostic()?.permissions(); + fs::set_permissions(dest, perms).into_diagnostic()?; + + for entry in fs::read_dir(source).into_diagnostic()? { + let entry = entry.into_diagnostic()?; + let file_type = entry.file_type().into_diagnostic()?; + let src_path = entry.path(); + let dst_path = dest.join(entry.file_name()); + + if file_type.is_dir() { + copy_dir(&src_path, &dst_path)?; + } else if file_type.is_symlink() { + let target = fs::read_link(&src_path).into_diagnostic()?; + std::os::unix::fs::symlink(target, &dst_path).into_diagnostic()?; + } else { + reflink_or_copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +/// Clone one regular file with a reflink, falling back to a byte copy when the +/// filesystem does not support `FICLONE`. +fn reflink_or_copy(src: &Path, dst: &Path) -> Result<()> { + if reflink(src, dst).is_ok() { + return Ok(()); + } + // Fallback: a plain copy still yields an independent file (just no CoW). + // `fs::copy` carries the permission bits across as well. + fs::copy(src, dst).map_err(|err| { + miette!( + "copying {} to {} failed: {err}", + src.display(), + dst.display() + ) + })?; + Ok(()) +} + +/// Attempt a `FICLONE` reflink of `src` into a freshly created `dst`. Returns an +/// error (leaving no destination file behind) if the filesystem or targets do +/// not support reflinking. +fn reflink(src: &Path, dst: &Path) -> std::io::Result<()> { + let src_file = File::open(src)?; + let perms = src_file.metadata()?.permissions(); + let dst_file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(perms.mode()) + .open(dst)?; + + // SAFETY: both descriptors are valid and owned for the duration of the call. + // `FICLONE` links the source fd's extents into the (empty) destination fd and + // retains neither descriptor. + let ret = unsafe { libc::ioctl(dst_file.as_raw_fd(), libc::FICLONE, src_file.as_raw_fd()) }; + if ret != 0 { + let err = std::io::Error::last_os_error(); + // Discard the empty destination we created so the copy fallback can + // recreate it with `create_new`. + drop(dst_file); + let _ = fs::remove_file(dst); + return Err(err); + } + // The open above applied the umask to `mode`; restore the source's exact + // permission bits so the clone matches (as `fs::copy` does on the fallback). + dst_file.set_permissions(perms)?; + Ok(()) +} diff --git a/src/checks/sandbox/mod.rs b/src/checks/sandbox/mod.rs index 83fe3d6..1a34612 100644 --- a/src/checks/sandbox/mod.rs +++ b/src/checks/sandbox/mod.rs @@ -4,8 +4,9 @@ //! and modify files freely without corrupting the real working directory. The //! abstraction is a boxed trait object (mirroring `BoxedIngress` etc.); the //! concrete implementation is selected per platform via `cfg`. macOS ships an -//! APFS `clonefile` implementation; other targets get a stub that errors, so the -//! crate still builds everywhere. +//! APFS `clonefile` implementation and Linux a reflink (`FICLONE`) +//! implementation; any remaining target gets a stub that errors, so the crate +//! still builds everywhere. use std::path::{Path, PathBuf}; @@ -15,7 +16,10 @@ use miette::Result; #[cfg(target_os = "macos")] mod macos; -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] +mod linux; + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] mod fallback; /// A copy-on-write sandbox factory. @@ -47,15 +51,19 @@ impl SandboxHandle { /// Select the platform sandbox implementation. /// -/// macOS → the real APFS CoW sandbox. Other platforms → an unsupported stub -/// that fails with a clear diagnostic (CoW support for Linux/Windows is tracked -/// under *Future work*). +/// macOS → the APFS `clonefile` CoW sandbox. Linux → the reflink (`FICLONE`) CoW +/// sandbox. Any other platform → an unsupported stub that fails with a clear +/// diagnostic (Windows CoW support is tracked under *Future work*). pub fn select_sandbox() -> BoxedSandbox { #[cfg(target_os = "macos")] { Box::new(macos::ApfsSandbox::new()) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + { + Box::new(linux::ReflinkSandbox::new()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { Box::new(fallback::UnsupportedSandbox) } @@ -85,7 +93,7 @@ mod tests { assert_obj_safe!(Sandbox); #[tokio::test] - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] async fn clone_is_independent_of_source() { use std::fs; let src = tempfile::TempDir::new().unwrap();