From d37d48d11d86cf9c9c5456d862d4ef887514fbf6 Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 12 May 2026 13:39:45 +0200 Subject: [PATCH] feat(pi-iso): added unified pi-iso backend resolver with auto probe - Added unified isolation primitives (BackendKind, ProbeResult, IsoError) and resolve fallback selection logic. - Added Diff, FileChange, and ChangeKind with default_diff choosing git mode or filesystem walk based on repository state. - Added APFS/btrfs/zfs/reflink/overlayfs/projfs/rcopy/block-clone backends with platform-aware start, stop, and probe. - Mapped backend operations to canonicalized paths, recursive clone helpers, rollback cleanup, and unavailable error mapping. - Added unified native exports isoBackend/isoProbe/isoResolve/isoStart/isoStop/isoDiff and removed projfsOverlay APIs. - Replaced task isolation resolver flow with ensureIsolation/cleanupIsolation and migrated mode handling to auto plus legacy-mode aliases. --- Cargo.lock | 14 +- crates/pi-iso/Cargo.toml | 33 ++ crates/pi-iso/src/apfs.rs | 148 ++++++ crates/pi-iso/src/btrfs.rs | 254 ++++++++++ crates/pi-iso/src/diff.rs | 425 ++++++++++++++++ crates/pi-iso/src/lib.rs | 312 ++++++++++++ crates/pi-iso/src/linux_reflink.rs | 273 +++++++++++ crates/pi-iso/src/overlayfs.rs | 328 +++++++++++++ .../src/projfs.rs} | 174 ++++--- crates/pi-iso/src/rcopy.rs | 452 ++++++++++++++++++ crates/pi-iso/src/windows_block_clone.rs | 375 +++++++++++++++ crates/pi-iso/src/zfs.rs | 334 +++++++++++++ crates/pi-natives/Cargo.toml | 8 +- crates/pi-natives/src/iso.rs | 236 +++++++++ crates/pi-natives/src/lib.rs | 2 +- packages/coding-agent/CHANGELOG.md | 13 + .../src/config/settings-schema.ts | 39 +- packages/coding-agent/src/config/settings.ts | 19 +- packages/coding-agent/src/task/index.ts | 62 +-- .../src/task/isolation-backend.ts | 94 ---- packages/coding-agent/src/task/worktree.ts | 259 ++++------ .../task/issue-949-windows-arm-projfs.test.ts | 29 -- .../coding-agent/test/task/worktree.test.ts | 28 +- packages/natives/CHANGELOG.md | 12 + packages/natives/native/index.d.ts | 128 ++++- packages/natives/native/index.js | 25 +- 26 files changed, 3620 insertions(+), 456 deletions(-) create mode 100644 crates/pi-iso/Cargo.toml create mode 100644 crates/pi-iso/src/apfs.rs create mode 100644 crates/pi-iso/src/btrfs.rs create mode 100644 crates/pi-iso/src/diff.rs create mode 100644 crates/pi-iso/src/lib.rs create mode 100644 crates/pi-iso/src/linux_reflink.rs create mode 100644 crates/pi-iso/src/overlayfs.rs rename crates/{pi-natives/src/projfs_overlay.rs => pi-iso/src/projfs.rs} (86%) create mode 100644 crates/pi-iso/src/rcopy.rs create mode 100644 crates/pi-iso/src/windows_block_clone.rs create mode 100644 crates/pi-iso/src/zfs.rs create mode 100644 crates/pi-natives/src/iso.rs delete mode 100644 packages/coding-agent/src/task/isolation-backend.ts delete mode 100644 packages/coding-agent/test/task/issue-949-windows-arm-projfs.test.ts diff --git a/Cargo.lock b/Cargo.lock index f5f7aea3c..be372a555 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2437,6 +2437,18 @@ dependencies = [ "tree-sitter-zig", ] +[[package]] +name = "pi-iso" +version = "14.9.8" +dependencies = [ + "async-trait", + "libc", + "parking_lot", + "similar 3.1.0", + "tokio", + "windows-sys 0.61.2", +] + [[package]] name = "pi-natives" version = "14.9.8" @@ -2464,6 +2476,7 @@ dependencies = [ "parking_lot", "phf 0.13.1", "pi-ast", + "pi-iso", "pi-shell", "portable-pty", "rayon", @@ -2480,7 +2493,6 @@ dependencies = [ "unicode-segmentation", "unicode-width", "webp", - "windows-sys 0.61.2", "winreg 0.56.0", "xxhash-rust", ] diff --git a/crates/pi-iso/Cargo.toml b/crates/pi-iso/Cargo.toml new file mode 100644 index 000000000..a63032d85 --- /dev/null +++ b/crates/pi-iso/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "pi-iso" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = "0.1" +tokio = { version = "1", features = ["fs", "process", "rt", "macros", "io-util"] } +similar = "3.1.0" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(target_os = "linux")'.dependencies] +parking_lot = "0.12.5" + +[target.'cfg(windows)'.dependencies] +parking_lot = "0.12.5" +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_Storage_ProjectedFileSystem", + "Win32_System_Com", + "Win32_System_LibraryLoader", + "Win32_System_IO", + "Win32_System_Ioctl", +] } diff --git a/crates/pi-iso/src/apfs.rs b/crates/pi-iso/src/apfs.rs new file mode 100644 index 000000000..ae85c4ebb --- /dev/null +++ b/crates/pi-iso/src/apfs.rs @@ -0,0 +1,148 @@ +//! macOS APFS clonefile-based isolation. +//! +//! `clonefile(2)` recursively reflinks an entire directory tree in a single +//! syscall. Both paths share the same on-disk blocks until either side is +//! modified; the kernel handles per-block copy-on-write. The destination is +//! a fully independent directory tree from the caller's perspective — there +//! is no mount to undo, so [`stop`](IsolationBackend::stop) is a recursive +//! remove. + +use std::path::Path; + +use async_trait::async_trait; + +#[cfg(not(target_os = "macos"))] +use crate::IsoError; +use crate::{BackendKind, IsoResult, IsolationBackend, ProbeResult}; + +pub struct ApfsBackend; + +pub fn backend() -> &'static dyn IsolationBackend { + &ApfsBackend +} + +#[async_trait] +impl IsolationBackend for ApfsBackend { + fn kind(&self) -> BackendKind { + BackendKind::Apfs + } + + fn probe(&self) -> ProbeResult { + #[cfg(target_os = "macos")] + { + ProbeResult::available() + } + #[cfg(not(target_os = "macos"))] + { + ProbeResult::unavailable("APFS clonefile isolation is only available on macOS") + } + } + + fn start(&self, lower: &Path, merged: &Path) -> IsoResult<()> { + #[cfg(target_os = "macos")] + { + imp::start(lower, merged) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (lower, merged); + Err(IsoError::unavailable("APFS clonefile isolation is only available on macOS")) + } + } + + fn stop(&self, merged: &Path) -> IsoResult<()> { + #[cfg(target_os = "macos")] + { + imp::stop(merged) + } + #[cfg(not(target_os = "macos"))] + { + let _ = merged; + Ok(()) + } + } +} + +#[cfg(target_os = "macos")] +mod imp { + use std::{ + ffi::CString, + fs, + os::unix::ffi::OsStrExt, + path::{Path, PathBuf}, + }; + + use crate::{IsoError, IsoResult}; + + pub fn start(lower: &Path, merged: &Path) -> IsoResult<()> { + let lower = canonical_existing_dir(lower)?; + if let Some(parent) = merged.parent() { + fs::create_dir_all(parent).map_err(|err| { + IsoError::other(format!("unable to create parent of {}: {err}", merged.display())) + })?; + } + // `clonefile` refuses to overwrite. Drop any stale tree first. + if merged.exists() { + fs::remove_dir_all(merged).map_err(|err| { + IsoError::other(format!("unable to clear {} before clone: {err}", merged.display())) + })?; + } + + let src_c = to_cstring(lower.as_os_str().as_bytes(), "lower")?; + let dst_c = to_cstring(merged.as_os_str().as_bytes(), "merged")?; + + // SAFETY: both pointers are valid CStrings whose backing storage lives + // until after the call. `clonefile` with `flags = 0` performs a + // recursive reflink clone and does not retain the pointers past the + // syscall. + let rc = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) }; + if rc == 0 { + return Ok(()); + } + let err = std::io::Error::last_os_error(); + if let Some(code) = err.raw_os_error() + && matches!(code, libc::ENOTSUP | libc::EOPNOTSUPP | libc::EXDEV) + { + return Err(IsoError::unavailable(format!( + "APFS clonefile unsupported on this volume ({err}); {} -> {}", + lower.display(), + merged.display() + ))); + } + Err(IsoError::other(format!("clonefile {} -> {}: {err}", lower.display(), merged.display()))) + } + + pub fn stop(merged: &Path) -> IsoResult<()> { + match fs::remove_dir_all(merged) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(IsoError::other(format!( + "unable to remove cloned tree {}: {err}", + merged.display() + ))), + } + } + + fn canonical_existing_dir(path: &Path) -> IsoResult { + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path)) + }; + let meta = fs::metadata(&resolved).map_err(|err| { + IsoError::other(format!("invalid clone source {}: {err}", resolved.display())) + })?; + if !meta.is_dir() { + return Err(IsoError::other(format!( + "clone source {} is not a directory", + resolved.display() + ))); + } + Ok(fs::canonicalize(&resolved).unwrap_or(resolved)) + } + + fn to_cstring(bytes: &[u8], label: &str) -> IsoResult { + CString::new(bytes) + .map_err(|err| IsoError::other(format!("{label} path contains NUL byte: {err}"))) + } +} diff --git a/crates/pi-iso/src/btrfs.rs b/crates/pi-iso/src/btrfs.rs new file mode 100644 index 000000000..7e87ed5fe --- /dev/null +++ b/crates/pi-iso/src/btrfs.rs @@ -0,0 +1,254 @@ +//! Linux btrfs subvolume snapshot isolation. +//! +//! When `lower` is a btrfs subvolume, `btrfs subvolume snapshot` creates an +//! O(1) writable snapshot at `merged`. The CLI owns the filesystem-specific +//! details; this backend only validates paths, invokes it without a shell, and +//! removes the snapshot on [`stop`](IsolationBackend::stop). + +use std::path::Path; + +use async_trait::async_trait; + +#[cfg(not(target_os = "linux"))] +use crate::IsoError; +use crate::{BackendKind, IsoResult, IsolationBackend, ProbeResult}; + +pub struct BtrfsBackend; + +pub fn backend() -> &'static dyn IsolationBackend { + &BtrfsBackend +} + +#[async_trait] +impl IsolationBackend for BtrfsBackend { + fn kind(&self) -> BackendKind { + BackendKind::Btrfs + } + + fn probe(&self) -> ProbeResult { + #[cfg(target_os = "linux")] + { + imp::probe() + } + #[cfg(not(target_os = "linux"))] + { + ProbeResult::unavailable("btrfs snapshot isolation is only available on Linux") + } + } + + fn start(&self, lower: &Path, merged: &Path) -> IsoResult<()> { + #[cfg(target_os = "linux")] + { + imp::start(lower, merged) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (lower, merged); + Err(IsoError::unavailable("btrfs snapshot isolation is only available on Linux")) + } + } + + fn stop(&self, merged: &Path) -> IsoResult<()> { + #[cfg(target_os = "linux")] + { + imp::stop(merged) + } + #[cfg(not(target_os = "linux"))] + { + let _ = merged; + Ok(()) + } + } +} + +#[cfg(target_os = "linux")] +mod imp { + use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Stdio}, + }; + + use crate::{IsoError, IsoResult, ProbeResult}; + + pub fn probe() -> ProbeResult { + match Command::new("btrfs") + .arg("version") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(status) if status.success() => ProbeResult::available(), + Ok(status) => ProbeResult::unavailable(format!( + "btrfs CLI probe failed with exit {}", + status.code().unwrap_or(-1) + )), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + ProbeResult::unavailable("`btrfs` CLI not on PATH") + }, + Err(err) => ProbeResult::unavailable(format!("unable to probe btrfs CLI: {err}")), + } + } + + pub fn start(lower: &Path, merged: &Path) -> IsoResult<()> { + let lower = canonical_existing_dir(lower)?; + prepare_destination(merged)?; + + let output = Command::new("btrfs") + .args(["subvolume", "snapshot"]) + .arg(&lower) + .arg(merged) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + IsoError::unavailable("`btrfs` CLI not on PATH") + } else { + IsoError::other(format!("spawn btrfs subvolume snapshot: {err}")) + } + })?; + + if output.status.success() { + return Ok(()); + } + + let _ = delete_subvolume_or_tree(merged); + let message = command_message(&output.stderr, &output.stdout); + if is_unsupported_btrfs_failure(&message) { + return Err(IsoError::unavailable(format!( + "btrfs snapshot unsupported for {} -> {}: {message}", + lower.display(), + merged.display() + ))); + } + Err(IsoError::other(format!( + "btrfs subvolume snapshot {} -> {} (exit {}): {message}", + lower.display(), + merged.display(), + output.status.code().unwrap_or(-1) + ))) + } + + pub fn stop(merged: &Path) -> IsoResult<()> { + delete_subvolume_or_tree(merged) + } + + fn canonical_existing_dir(path: &Path) -> IsoResult { + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + }; + let meta = fs::metadata(&resolved).map_err(|err| { + IsoError::other(format!("invalid btrfs snapshot source {}: {err}", resolved.display())) + })?; + if !meta.is_dir() { + return Err(IsoError::other(format!( + "btrfs snapshot source {} is not a directory", + resolved.display() + ))); + } + Ok(fs::canonicalize(&resolved).unwrap_or(resolved)) + } + + fn prepare_destination(merged: &Path) -> IsoResult<()> { + if let Some(parent) = merged.parent() { + fs::create_dir_all(parent).map_err(|err| { + IsoError::other(format!("create parent of {}: {err}", merged.display())) + })?; + } + delete_subvolume_or_tree(merged).map_err(|err| match err { + IsoError::Other(message) => IsoError::other(format!( + "unable to clear {} before btrfs snapshot: {message}", + merged.display() + )), + other => other, + }) + } + + fn delete_subvolume_or_tree(path: &Path) -> IsoResult<()> { + if !path_exists(path)? { + return Ok(()); + } + + match Command::new("btrfs") + .args(["subvolume", "delete"]) + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => { + let message = command_message(&output.stderr, &output.stdout); + if is_not_subvolume_failure(&message) || is_unsupported_btrfs_failure(&message) { + remove_tree_if_present(path) + } else { + Err(IsoError::other(format!( + "btrfs subvolume delete {} (exit {}): {message}", + path.display(), + output.status.code().unwrap_or(-1) + ))) + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => remove_tree_if_present(path), + Err(err) => Err(IsoError::other(format!("spawn btrfs subvolume delete: {err}"))), + } + } + + fn remove_tree_if_present(path: &Path) -> IsoResult<()> { + match fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() => fs::remove_dir_all(path) + .map_err(|err| IsoError::other(format!("remove {}: {err}", path.display()))), + Ok(_) => fs::remove_file(path) + .map_err(|err| IsoError::other(format!("remove {}: {err}", path.display()))), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(IsoError::other(format!("inspect {}: {err}", path.display()))), + } + } + + fn path_exists(path: &Path) -> IsoResult { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(IsoError::other(format!("inspect {}: {err}", path.display()))), + } + } + + fn command_message(stderr: &[u8], stdout: &[u8]) -> String { + let stderr = String::from_utf8_lossy(stderr).trim().to_string(); + if !stderr.is_empty() { + return stderr; + } + let stdout = String::from_utf8_lossy(stdout).trim().to_string(); + if stdout.is_empty() { + "no command output".to_string() + } else { + stdout + } + } + + fn is_unsupported_btrfs_failure(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("not a btrfs filesystem") + || message.contains("not a btrfs file system") + || message.contains("not a subvolume") + || message.contains("not btrfs") + || message.contains("invalid argument") + || message.contains("operation not supported") + || message.contains("inappropriate ioctl") + } + + fn is_not_subvolume_failure(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("not a subvolume") + || message.contains("not a btrfs filesystem") + || message.contains("not a btrfs file system") + } +} diff --git a/crates/pi-iso/src/diff.rs b/crates/pi-iso/src/diff.rs new file mode 100644 index 000000000..ac9118e7b --- /dev/null +++ b/crates/pi-iso/src/diff.rs @@ -0,0 +1,425 @@ +//! Backend-agnostic change capture. +//! +//! Two code paths, both producing a [`Diff`] = list of [`FileChange`]: +//! +//! - **Git mode.** When `merged/.git` exists we shell `git diff --no-color +//! HEAD` plus `git ls-files --others --exclude-standard` (for untracked), +//! split the output on `diff --git` headers, and emit one [`FileChange`] per +//! file. Binary entries surface as `diff: None`. +//! - **Plain mode.** No `.git`; we walk both trees in parallel, short-circuit +//! on `(size, mtime-truncated-to-seconds)` equality, and emit a unified diff +//! for each surviving pair via `similar`. NUL within the first 8 KiB +//! classifies the file as binary → `diff: None`. +//! +//! Per the PAL contract: for binary files we don't materialize the bytes +//! in the patch — callers that want them read directly from `merged` +//! (for `Added`/`Modified`) or `lower` (for `Removed`). + +use std::{ + collections::BTreeMap, + fs::Metadata, + path::{Path, PathBuf}, + time::SystemTime, +}; + +use tokio::process::Command; + +use crate::{IsoError, IsoResult}; + +/// Captured changes between a `lower` baseline and a `merged` view. +#[derive(Debug, Clone, Default)] +pub struct Diff { + pub files: Vec, +} + +impl Diff { + pub const fn is_empty(&self) -> bool { + self.files.is_empty() + } + + /// Concatenated unified-diff text for every text-representable entry. + /// Binary entries are skipped — enumerate via [`files`](Self::files) + /// and copy them out-of-band if you need their contents. + pub fn unified_text(&self) -> String { + let mut out = String::new(); + for file in &self.files { + let Some(diff) = &file.diff else { continue }; + if diff.is_empty() { + continue; + } + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(diff); + } + out + } +} + +/// One entry in a [`Diff`]. +/// +/// `path` is relative to `merged`. `diff = None` means the file is binary +/// or otherwise text-unrepresentable — copy the contents from the merged +/// tree if you need them (or skip if you only care about text). +#[derive(Debug, Clone)] +pub struct FileChange { + pub path: PathBuf, + pub op: ChangeKind, + pub diff: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChangeKind { + Added, + Modified, + Removed, +} + +/// Default backend diff: git when available, mtime-skipped walk otherwise. +pub async fn default_diff(lower: &Path, merged: &Path) -> IsoResult { + if is_git_tree(merged).await { + git_diff(merged).await + } else { + walk_diff(lower, merged).await + } +} + +async fn is_git_tree(merged: &Path) -> bool { + tokio::fs::symlink_metadata(merged.join(".git")) + .await + .is_ok() +} + +// ─── git mode ─────────────────────────────────────────────────────────────── + +async fn git_diff(merged: &Path) -> IsoResult { + // `--no-color`: keep ANSI out of patch text. + // No `--binary`: we *want* git's `Binary files … differ` placeholder + // so we can map it to `diff: None`. + let tracked = + git_run(merged, &["-c", "core.quotepath=off", "diff", "--no-color", "HEAD"]).await?; + + let untracked_list = git_run(merged, &[ + "-c", + "core.quotepath=off", + "ls-files", + "--others", + "--exclude-standard", + "-z", + ]) + .await?; + + let mut files = parse_git_diff(&tracked); + + let mut untracked_paths: Vec<&[u8]> = untracked_list + .split(|b| *b == 0) + .filter(|s| !s.is_empty()) + .collect(); + untracked_paths.sort_unstable(); + + for path_bytes in untracked_paths { + let path_str = std::str::from_utf8(path_bytes) + .map_err(|err| IsoError::other(format!("untracked path is not valid UTF-8: {err}")))?; + let one = git_run_allow_exit1(merged, &[ + "-c", + "core.quotepath=off", + "diff", + "--no-color", + "--no-index", + git_null_path(), + path_str, + ]) + .await?; + files.extend(parse_git_diff(&one)); + } + + files.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(Diff { files }) +} + +#[cfg(windows)] +const fn git_null_path() -> &'static str { + "NUL" +} + +#[cfg(not(windows))] +const fn git_null_path() -> &'static str { + "/dev/null" +} + +async fn git_run(cwd: &Path, args: &[&str]) -> IsoResult> { + let output = git_spawn(cwd, args).await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(IsoError::other(format!( + "git {} (exit {}): {stderr}", + args.join(" "), + output + .status + .code() + .map_or_else(|| "?".into(), |c| c.to_string()) + ))); + } + Ok(output.stdout) +} + +/// `git diff --no-index` returns exit code 1 when files differ — that's +/// not an error for us, treat it as success with the produced patch. +async fn git_run_allow_exit1(cwd: &Path, args: &[&str]) -> IsoResult> { + let output = git_spawn(cwd, args).await?; + if output.status.success() || output.status.code() == Some(1) { + return Ok(output.stdout); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(IsoError::other(format!( + "git {} (exit {}): {stderr}", + args.join(" "), + output + .status + .code() + .map_or_else(|| "?".into(), |c| c.to_string()) + ))) +} + +async fn git_spawn(cwd: &Path, args: &[&str]) -> IsoResult { + let mut cmd = Command::new("git"); + cmd.arg("-C").arg(cwd).args(args); + cmd.stdin(std::process::Stdio::null()); + cmd.output().await.map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + IsoError::unavailable("`git` not on PATH; cannot capture diff for git-tracked tree") + } else { + IsoError::other(format!("spawn git: {err}")) + } + }) +} + +/// Split a `git diff` blob into per-file [`FileChange`] entries. Each +/// entry covers exactly one `diff --git a/ b/` block. Binary +/// blocks are emitted with `diff: None`; the rest carry their original +/// unified-diff slice unchanged so `git apply` produces byte-identical +/// results downstream. +fn parse_git_diff(blob: &[u8]) -> Vec { + let Ok(text) = std::str::from_utf8(blob) else { + return Vec::new(); + }; + let mut out = Vec::::new(); + let iter = text.split_inclusive('\n'); + let mut buf = String::new(); + let mut header_path: Option = None; + let mut header_kind = ChangeKind::Modified; + let mut header_binary = false; + + let flush = |buf: &mut String, + path: &mut Option, + kind: &mut ChangeKind, + binary: &mut bool, + out: &mut Vec| { + if let Some(p) = path.take() { + let diff = if *binary { + None + } else { + Some(std::mem::take(buf)) + }; + out.push(FileChange { path: p, op: *kind, diff }); + } + buf.clear(); + *kind = ChangeKind::Modified; + *binary = false; + }; + + for line in iter { + if let Some(rest) = line.strip_prefix("diff --git ") { + flush(&mut buf, &mut header_path, &mut header_kind, &mut header_binary, &mut out); + let trimmed = rest.trim_end_matches('\n'); + if let Some((_, b)) = trimmed.split_once(' ') { + let path = b.strip_prefix("b/").unwrap_or(b); + header_path = Some(PathBuf::from(path)); + } + buf.push_str(line); + continue; + } + if header_path.is_some() { + if line.starts_with("new file mode ") { + header_kind = ChangeKind::Added; + } else if line.starts_with("deleted file mode ") { + header_kind = ChangeKind::Removed; + } else if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") { + header_binary = true; + } + buf.push_str(line); + } + } + flush(&mut buf, &mut header_path, &mut header_kind, &mut header_binary, &mut out); + out +} + +// ─── plain mode ───────────────────────────────────────────────────────────── + +async fn walk_diff(lower: &Path, merged: &Path) -> IsoResult { + let lower = lower.to_path_buf(); + let merged = merged.to_path_buf(); + tokio::task::spawn_blocking(move || walk_diff_blocking(&lower, &merged)) + .await + .map_err(|err| IsoError::other(format!("walk_diff join: {err}")))? +} + +fn walk_diff_blocking(lower: &Path, merged: &Path) -> IsoResult { + let lower_index = index_tree(lower)?; + let merged_index = index_tree(merged)?; + + let mut files: Vec = Vec::new(); + + for (rel, m_meta) in &merged_index { + match lower_index.get(rel) { + None => files.push(plain_change(merged, rel, ChangeKind::Added, None)?), + Some(l_meta) => { + if metas_equal(l_meta, m_meta) { + continue; + } + files.push(plain_change(merged, rel, ChangeKind::Modified, Some(lower))?); + }, + } + } + for rel in lower_index.keys() { + if !merged_index.contains_key(rel) { + files.push(plain_change(lower, rel, ChangeKind::Removed, None)?); + } + } + + files.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(Diff { files }) +} + +fn metas_equal(a: &Metadata, b: &Metadata) -> bool { + if a.len() != b.len() { + return false; + } + match (a.modified(), b.modified()) { + (Ok(ma), Ok(mb)) => systime_eq(ma, mb), + _ => false, + } +} + +fn systime_eq(a: SystemTime, b: SystemTime) -> bool { + // Filesystems carry mtime at different resolutions (HFS+ seconds, APFS + // nanos, FAT 2 seconds). Compare at second granularity so a metadata- + // preserving copy that flushed through a coarse layer doesn't look + // modified. + let to_secs = |t: SystemTime| { + t.duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) + }; + to_secs(a) == to_secs(b) +} + +fn index_tree(root: &Path) -> IsoResult> { + let mut out = BTreeMap::new(); + if !root.exists() { + return Ok(out); + } + walk(root, root, &mut out)?; + Ok(out) +} + +fn walk(root: &Path, dir: &Path, out: &mut BTreeMap) -> IsoResult<()> { + let entries = std::fs::read_dir(dir) + .map_err(|err| IsoError::other(format!("read_dir {}: {err}", dir.display())))?; + for entry in entries { + let entry = + entry.map_err(|err| IsoError::other(format!("dir entry in {}: {err}", dir.display())))?; + let path = entry.path(); + let meta = entry + .metadata() + .map_err(|err| IsoError::other(format!("metadata {}: {err}", path.display())))?; + if meta.is_symlink() { + let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf(); + out.insert(rel, meta); + continue; + } + if meta.is_dir() { + walk(root, &path, out)?; + continue; + } + let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf(); + out.insert(rel, meta); + } + Ok(()) +} + +/// Build a [`FileChange`] for an entry observed by [`walk_diff_blocking`]. +/// +/// `op == Modified` requires `peer_root = Some(lower)` so we can read the +/// counterpart; `Added`/`Removed` only need the side we already know about. +fn plain_change( + side: &Path, + rel: &Path, + op: ChangeKind, + peer_root: Option<&Path>, +) -> IsoResult { + let full = side.join(rel); + let primary = std::fs::read(&full) + .map_err(|err| IsoError::other(format!("read {}: {err}", full.display())))?; + if looks_binary(&primary) { + return Ok(FileChange { path: rel.to_path_buf(), op, diff: None }); + } + let (old_bytes, new_bytes) = match op { + ChangeKind::Added => (Vec::new(), primary), + ChangeKind::Removed => (primary, Vec::new()), + ChangeKind::Modified => { + let peer = peer_root.expect("modified change requires peer root"); + let peer_full = peer.join(rel); + let peer_bytes = std::fs::read(&peer_full) + .map_err(|err| IsoError::other(format!("read {}: {err}", peer_full.display())))?; + if looks_binary(&peer_bytes) { + return Ok(FileChange { path: rel.to_path_buf(), op, diff: None }); + } + (peer_bytes, primary) + }, + }; + let (Ok(old_text), Ok(new_text)) = + (std::str::from_utf8(&old_bytes), std::str::from_utf8(&new_bytes)) + else { + return Ok(FileChange { path: rel.to_path_buf(), op, diff: None }); + }; + Ok(FileChange { + path: rel.to_path_buf(), + op, + diff: Some(render_unified(rel, op, old_text, new_text)), + }) +} + +fn render_unified(rel: &Path, op: ChangeKind, old: &str, new: &str) -> String { + let rel_str = rel.to_string_lossy(); + let (from_label, to_label) = match op { + ChangeKind::Added => (String::from("/dev/null"), format!("b/{rel_str}")), + ChangeKind::Removed => (format!("a/{rel_str}"), String::from("/dev/null")), + ChangeKind::Modified => (format!("a/{rel_str}"), format!("b/{rel_str}")), + }; + use std::fmt::Write as _; + let mut out = String::new(); + let _ = writeln!(out, "diff --git a/{rel_str} b/{rel_str}"); + match op { + ChangeKind::Added => { + let _ = writeln!(out, "new file mode 100644"); + }, + ChangeKind::Removed => { + let _ = writeln!(out, "deleted file mode 100644"); + }, + ChangeKind::Modified => {}, + } + let body = similar::TextDiff::from_lines(old, new) + .unified_diff() + .context_radius(3) + .header(&from_label, &to_label) + .to_string(); + out.push_str(&body); + if !out.ends_with('\n') { + out.push('\n'); + } + out +} + +fn looks_binary(bytes: &[u8]) -> bool { + bytes.iter().take(8192).any(|&b| b == 0) +} diff --git a/crates/pi-iso/src/lib.rs b/crates/pi-iso/src/lib.rs new file mode 100644 index 000000000..af778899d --- /dev/null +++ b/crates/pi-iso/src/lib.rs @@ -0,0 +1,312 @@ +//! Cross-platform isolation PAL. +//! +//! A backend gives the caller a writable "merged" view of a read-only +//! "lower" tree without paying for a deep copy: +//! +//! - **macOS** uses `clonefile(2)` to seed an APFS copy-on-write clone. +//! - **Linux** mounts a kernel `overlay` filesystem, falling back to +//! `fuse-overlayfs` when the syscall is denied. +//! - **Windows** projects an existing tree through `ProjFS`. +//! - **`Rcopy`** is the cross-platform fallback: `git worktree` if `lower` is a +//! git repo, plain recursive copy otherwise. +//! +//! Every backend also knows how to surface the changes the workload made. +//! When `merged` is a git repository — true for every git-backed task in +//! omp regardless of which lifecycle backend was used — +//! [`IsolationBackend::diff`] delegates to `git diff` so the output is +//! byte-identical to what `git apply` consumes downstream. For non-git trees +//! (only reachable via `Rcopy`) it walks both trees, using `(size, mtime)` as a +//! cheap short-circuit before doing a content diff. + +#![cfg_attr( + not(any(target_os = "macos", target_os = "linux", windows)), + allow(unused_imports, dead_code, reason = "platform without an isolation backend") +)] + +use std::{fmt, path::Path}; + +use async_trait::async_trait; + +mod apfs; +mod btrfs; +mod diff; +mod linux_reflink; +mod overlayfs; +mod projfs; +mod rcopy; +mod windows_block_clone; +mod zfs; + +pub use diff::{ChangeKind, Diff, FileChange}; + +/// Stable identifier for which backend a build was compiled with. +/// +/// Exposed to callers so they can render diagnostics or pick mode-specific +/// configuration without re-implementing the per-OS branching. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BackendKind { + /// APFS `clonefile(2)` reflink clone (macOS). + Apfs, + /// btrfs `subvolume snapshot` clone (Linux + btrfs). + Btrfs, + /// ZFS dataset snapshot+clone (Linux/FreeBSD/macOS + a ZFS pool). + Zfs, + /// Linux `FICLONE` per-file reflink tree (btrfs, XFS+reflink, bcachefs, …). + LinuxReflink, + /// Kernel `overlay` filesystem (Linux), with optional `fuse-overlayfs` + /// fallback. + Overlayfs, + /// Windows `FSCTL_DUPLICATE_EXTENTS_TO_FILE` block clone tree (NTFS/ReFS). + WindowsBlockClone, + /// Windows Projected File System. + Projfs, + /// `git worktree` when `lower` is a git repo, otherwise plain recursive + /// copy. Always available; the universal fallback. + Rcopy, +} + +impl BackendKind { + /// Short, stable string identifier. Used by the napi shim. + pub const fn as_str(self) -> &'static str { + match self { + Self::Apfs => "apfs", + Self::Btrfs => "btrfs", + Self::Zfs => "zfs", + Self::LinuxReflink => "linux-reflink", + Self::Overlayfs => "overlayfs", + Self::WindowsBlockClone => "windows-block-clone", + Self::Projfs => "projfs", + Self::Rcopy => "rcopy", + } + } + + /// Parse the inverse of [`Self::as_str`]. Returns `None` for unknown + /// strings so callers can surface a precise error. + pub fn from_str(s: &str) -> Option { + Some(match s { + "apfs" => Self::Apfs, + "btrfs" => Self::Btrfs, + "zfs" => Self::Zfs, + "linux-reflink" | "reflink" => Self::LinuxReflink, + "overlayfs" => Self::Overlayfs, + "windows-block-clone" | "block-clone" => Self::WindowsBlockClone, + "projfs" => Self::Projfs, + "rcopy" => Self::Rcopy, + _ => return None, + }) + } + + /// Backend chosen for the current build target when the caller doesn't + /// specify one. Platform-native `CoW` first, [`Rcopy`](Self::Rcopy) as the + /// last resort. + pub const fn native() -> Self { + #[cfg(target_os = "macos")] + { + Self::Apfs + } + #[cfg(target_os = "linux")] + { + Self::Overlayfs + } + #[cfg(windows)] + { + Self::Projfs + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + { + Self::Rcopy + } + } +} + +impl fmt::Display for BackendKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Result of a backend probe. +/// +/// `available == false` means [`IsolationBackend::start`] will fail with +/// [`IsoError::Unavailable`]; `reason` is a human-readable explanation +/// suitable for surfacing in a UI. +#[derive(Debug, Clone)] +pub struct ProbeResult { + pub available: bool, + pub reason: Option, +} + +impl ProbeResult { + pub const fn available() -> Self { + Self { available: true, reason: None } + } + + pub fn unavailable(reason: impl Into) -> Self { + Self { available: false, reason: Some(reason.into()) } + } +} + +/// Error returned by every backend operation. +/// +/// `Unavailable` is the only variant callers are expected to treat specially — +/// it indicates the platform prerequisite is missing (no `ProjFS` DLL, no +/// `overlay` support, etc.) and the workload should fall back rather than +/// surface a hard failure. +#[derive(Debug, Clone)] +pub enum IsoError { + Unavailable(String), + Other(String), +} + +impl IsoError { + pub fn unavailable(msg: impl Into) -> Self { + Self::Unavailable(msg.into()) + } + + pub fn other(msg: impl Into) -> Self { + Self::Other(msg.into()) + } + + pub const fn is_unavailable(&self) -> bool { + matches!(self, Self::Unavailable(_)) + } + + pub fn message(&self) -> &str { + match self { + Self::Unavailable(m) | Self::Other(m) => m, + } + } +} + +impl fmt::Display for IsoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.message()) + } +} + +impl std::error::Error for IsoError {} + +pub type IsoResult = Result; + +/// Backend contract. +/// +/// `lower` is the read-only source tree; `merged` is the destination where +/// the writable view is materialised. Implementations are responsible for +/// creating any auxiliary directories (e.g. overlayfs upper/work dirs) and +/// for tearing them down in [`stop`](Self::stop). +/// +/// `start` / `stop` are synchronous because the platform primitives they +/// wrap (`mount`, `clonefile`, `PrjStartVirtualizing`) are blocking +/// syscalls that callers are expected to drive from `spawn_blocking`. +/// [`diff`](Self::diff) is async because it does heavy I/O — walking +/// trees, reading files, spawning git — and benefits from the runtime +/// interleaving requests with other work. +#[async_trait] +pub trait IsolationBackend: Send + Sync { + fn kind(&self) -> BackendKind; + + fn probe(&self) -> ProbeResult; + + fn start(&self, lower: &Path, merged: &Path) -> IsoResult<()>; + + fn stop(&self, merged: &Path) -> IsoResult<()>; + + /// Capture the changes between `lower` and the current state of + /// `merged`. The default implementation delegates to `git diff` when + /// `merged` is a git working tree, otherwise walks both trees using + /// `(size, mtime)` to skip equal files before falling back to a + /// content comparison. + /// + /// Backends are free to override when they know a cheaper path — + /// overlayfs can scan the upper dir, `ProjFS` can query the placeholder + /// set — but the default is correct everywhere. + async fn diff(&self, lower: &Path, merged: &Path) -> IsoResult { + diff::default_diff(lower, merged).await + } +} + +/// Returns the backend selected for the current build target. +/// +/// Each backend is a unit struct with no per-call state, so we hand out a +/// `&'static` reference and avoid the indirection of building a fresh trait +/// object on every call. +pub fn default_backend() -> &'static dyn IsolationBackend { + backend(BackendKind::native()) +} + +/// Look up a backend by [`BackendKind`]. +/// +/// Every kind is dispatchable in every build; backends that aren't compiled +/// in for the current target (`Apfs` off Linux/macOS, `Projfs` off Windows…) +/// return their own platform stub which fails +/// [`probe`](IsolationBackend::probe) with `available = false` and rejects +/// [`start`](IsolationBackend::start) with [`IsoError::Unavailable`]. This way +/// the napi shim can mirror the user's `task.isolation.mode` setting without an +/// extra "is this platform" check. +pub fn backend(kind: BackendKind) -> &'static dyn IsolationBackend { + match kind { + BackendKind::Apfs => apfs::backend(), + BackendKind::Btrfs => btrfs::backend(), + BackendKind::Zfs => zfs::backend(), + BackendKind::LinuxReflink => linux_reflink::backend(), + BackendKind::Overlayfs => overlayfs::backend(), + BackendKind::WindowsBlockClone => windows_block_clone::backend(), + BackendKind::Projfs => projfs::backend(), + BackendKind::Rcopy => &rcopy::RcopyBackend, + } +} + +/// Convenience accessor for [`default_backend`]'s [`BackendKind`]. +pub fn backend_kind() -> BackendKind { + default_backend().kind() +} + +/// Outcome of [`resolve`]. `kind` is the backend that will actually be +/// used; `fell_back` is `true` when a `preferred` choice (or the +/// platform-native pick) was unusable and the resolver downgraded. +/// `reason` carries the original probe's explanation when available. +#[derive(Debug, Clone)] +pub struct Resolution { + pub kind: BackendKind, + pub fell_back: bool, + pub reason: Option, +} + +/// Pick the best backend available right now. +/// +/// Caller priority: +/// 1. If `preferred` is `Some` and its [`probe`](IsolationBackend::probe) +/// reports `available`, use it as-is. +/// 2. Otherwise try the platform-native backend ([`BackendKind::native`]); if +/// it differs from `preferred` and probes available, use it. +/// 3. Otherwise fall back to [`BackendKind::Rcopy`], which is always available. +/// +/// The unavailable probe's `reason` is carried through `Resolution::reason` +/// so callers can surface it to the user instead of guessing. +pub fn resolve(preferred: Option) -> Resolution { + if let Some(p) = preferred { + let probe = backend(p).probe(); + if probe.available { + return Resolution { kind: p, fell_back: false, reason: None }; + } + let original_reason = probe.reason; + let native = BackendKind::native(); + if native != p { + let np = backend(native).probe(); + if np.available { + return Resolution { kind: native, fell_back: true, reason: original_reason }; + } + } + return Resolution { + kind: BackendKind::Rcopy, + fell_back: true, + reason: original_reason, + }; + } + let native = BackendKind::native(); + let probe = backend(native).probe(); + if probe.available { + return Resolution { kind: native, fell_back: false, reason: None }; + } + Resolution { kind: BackendKind::Rcopy, fell_back: true, reason: probe.reason } +} diff --git a/crates/pi-iso/src/linux_reflink.rs b/crates/pi-iso/src/linux_reflink.rs new file mode 100644 index 000000000..ea81337c2 --- /dev/null +++ b/crates/pi-iso/src/linux_reflink.rs @@ -0,0 +1,273 @@ +//! Linux FICLONE-based copy-on-write tree materialisation. +//! +//! This backend recursively builds a writable directory tree at `merged` from +//! `lower`. Directories and symlinks are recreated, while regular files are +//! cloned with the Linux `FICLONE` ioctl so filesystems such as btrfs, XFS, +//! OCFS2, and bcachefs can share extents until either side is modified. There +//! is no mount or kernel state to undo, so [`stop`](IsolationBackend::stop) is +//! a recursive remove. + +use std::path::Path; + +use async_trait::async_trait; + +#[cfg(not(target_os = "linux"))] +use crate::IsoError; +use crate::{BackendKind, IsoResult, IsolationBackend, ProbeResult}; + +pub struct LinuxReflinkBackend; + +pub fn backend() -> &'static dyn IsolationBackend { + &LinuxReflinkBackend +} + +#[async_trait] +impl IsolationBackend for LinuxReflinkBackend { + fn kind(&self) -> BackendKind { + BackendKind::LinuxReflink + } + + fn probe(&self) -> ProbeResult { + #[cfg(target_os = "linux")] + { + ProbeResult::available() + } + #[cfg(not(target_os = "linux"))] + { + ProbeResult::unavailable("Linux FICLONE reflink isolation is only available on Linux") + } + } + + fn start(&self, lower: &Path, merged: &Path) -> IsoResult<()> { + #[cfg(target_os = "linux")] + { + imp::start(lower, merged) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (lower, merged); + Err(IsoError::unavailable("Linux FICLONE reflink isolation is only available on Linux")) + } + } + + fn stop(&self, merged: &Path) -> IsoResult<()> { + #[cfg(target_os = "linux")] + { + imp::stop(merged) + } + #[cfg(not(target_os = "linux"))] + { + let _ = merged; + Ok(()) + } + } +} + +#[cfg(target_os = "linux")] +mod imp { + use std::{ + ffi::CString, + fs::{self, File, OpenOptions}, + os::{ + fd::AsRawFd, + unix::{ + ffi::OsStrExt, + fs::{MetadataExt, PermissionsExt}, + }, + }, + path::{Path, PathBuf}, + }; + + use crate::{IsoError, IsoResult}; + + const FICLONE: libc::c_ulong = 0x4004_9409; + + pub fn start(lower: &Path, merged: &Path) -> IsoResult<()> { + let lower = canonical_existing_dir(lower)?; + prepare_destination(merged)?; + + let result = recursive_reflink(&lower, merged); + if result.is_err() { + let _ = fs::remove_dir_all(merged); + } + result + } + + pub fn stop(merged: &Path) -> IsoResult<()> { + match fs::remove_dir_all(merged) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(IsoError::other(format!( + "unable to remove reflink tree {}: {err}", + merged.display() + ))), + } + } + + fn canonical_existing_dir(path: &Path) -> IsoResult { + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + }; + let meta = fs::metadata(&resolved).map_err(|err| { + IsoError::other(format!("invalid reflink source {}: {err}", resolved.display())) + })?; + if !meta.is_dir() { + return Err(IsoError::other(format!( + "reflink source {} is not a directory", + resolved.display() + ))); + } + Ok(fs::canonicalize(&resolved).unwrap_or(resolved)) + } + + fn prepare_destination(merged: &Path) -> IsoResult<()> { + if let Some(parent) = merged.parent() { + fs::create_dir_all(parent).map_err(|err| { + IsoError::other(format!("create parent of {}: {err}", merged.display())) + })?; + } + match fs::symlink_metadata(merged) { + Ok(meta) if meta.is_dir() => fs::remove_dir_all(merged).map_err(|err| { + IsoError::other(format!( + "unable to clear {} before reflink clone: {err}", + merged.display() + )) + })?, + Ok(_) => fs::remove_file(merged).map_err(|err| { + IsoError::other(format!( + "unable to clear {} before reflink clone: {err}", + merged.display() + )) + })?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}, + Err(err) => { + return Err(IsoError::other(format!( + "unable to inspect {} before reflink clone: {err}", + merged.display() + ))); + }, + } + Ok(()) + } + + fn recursive_reflink(src: &Path, dst: &Path) -> IsoResult<()> { + let meta = fs::symlink_metadata(src) + .map_err(|err| IsoError::other(format!("symlink_metadata {}: {err}", src.display())))?; + fs::create_dir(dst) + .map_err(|err| IsoError::other(format!("create {}: {err}", dst.display())))?; + + let entries = fs::read_dir(src) + .map_err(|err| IsoError::other(format!("read_dir {}: {err}", src.display())))?; + for entry in entries { + let entry = entry + .map_err(|err| IsoError::other(format!("dir entry in {}: {err}", src.display())))?; + let file_type = entry.file_type().map_err(|err| { + IsoError::other(format!("file_type {}: {err}", entry.path().display())) + })?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if file_type.is_symlink() { + clone_symlink(&src_path, &dst_path)?; + } else if file_type.is_dir() { + recursive_reflink(&src_path, &dst_path)?; + } else if file_type.is_file() { + clone_file(&src_path, &dst_path)?; + } else { + return Err(IsoError::other(format!( + "unsupported file type in reflink source: {}", + src_path.display() + ))); + } + } + + preserve_permissions(dst, &meta)?; + let _ = set_times_nofollow(dst, &meta); + Ok(()) + } + + fn clone_symlink(src: &Path, dst: &Path) -> IsoResult<()> { + let target = fs::read_link(src) + .map_err(|err| IsoError::other(format!("read_link {}: {err}", src.display())))?; + std::os::unix::fs::symlink(target, dst) + .map_err(|err| IsoError::other(format!("symlink {}: {err}", dst.display())))?; + if let Ok(meta) = fs::symlink_metadata(src) { + let _ = set_times_nofollow(dst, &meta); + } + Ok(()) + } + + fn clone_file(src: &Path, dst: &Path) -> IsoResult<()> { + let meta = fs::symlink_metadata(src) + .map_err(|err| IsoError::other(format!("symlink_metadata {}: {err}", src.display())))?; + let src_file = File::open(src) + .map_err(|err| IsoError::other(format!("open {}: {err}", src.display())))?; + let dst_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(dst) + .map_err(|err| IsoError::other(format!("create {}: {err}", dst.display())))?; + + // SAFETY: both file descriptors are valid for the duration of the call. + // FICLONE copies metadata into `dst_file` and does not retain either fd. + let rc = unsafe { libc::ioctl(dst_file.as_raw_fd(), FICLONE, src_file.as_raw_fd()) }; + if rc != 0 { + let err = std::io::Error::last_os_error(); + let _ = fs::remove_file(dst); + return Err(map_clone_error(src, dst, err)); + } + + preserve_permissions(dst, &meta)?; + let _ = set_times_nofollow(dst, &meta); + Ok(()) + } + + fn map_clone_error(src: &Path, dst: &Path, err: std::io::Error) -> IsoError { + if let Some(code) = err.raw_os_error() + && matches!( + code, + libc::EXDEV | libc::EOPNOTSUPP | libc::ENOTTY | libc::EINVAL | libc::ENOSYS + ) { + return IsoError::unavailable(format!( + "FICLONE unsupported for {} -> {}: {err}", + src.display(), + dst.display() + )); + } + IsoError::other(format!("FICLONE {} -> {}: {err}", src.display(), dst.display())) + } + + fn preserve_permissions(path: &Path, meta: &fs::Metadata) -> IsoResult<()> { + let mode = meta.permissions().mode(); + fs::set_permissions(path, fs::Permissions::from_mode(mode)) + .map_err(|err| IsoError::other(format!("set permissions on {}: {err}", path.display()))) + } + + fn set_times_nofollow(path: &Path, meta: &fs::Metadata) -> std::io::Result<()> { + let times = [ + libc::timespec { + tv_sec: meta.atime() as libc::time_t, + tv_nsec: meta.atime_nsec() as libc::c_long, + }, + libc::timespec { + tv_sec: meta.mtime() as libc::time_t, + tv_nsec: meta.mtime_nsec() as libc::c_long, + }, + ]; + let c_path = CString::new(path.as_os_str().as_bytes())?; + // SAFETY: `c_path` and `times` live until the syscall returns; the + // kernel does not retain either pointer. AT_SYMLINK_NOFOLLOW preserves + // symlink timestamps instead of mutating the link target. + let rc = unsafe { + libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), libc::AT_SYMLINK_NOFOLLOW) + }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + } +} diff --git a/crates/pi-iso/src/overlayfs.rs b/crates/pi-iso/src/overlayfs.rs new file mode 100644 index 000000000..f649632e1 --- /dev/null +++ b/crates/pi-iso/src/overlayfs.rs @@ -0,0 +1,328 @@ +//! Linux overlayfs-based isolation. +//! +//! Tries to stack a kernel `overlay` filesystem at `merged` over the +//! read-only `lower` tree. The mount uses sibling `upper` and `work` +//! directories derived from `merged.parent()` so a single caller-owned base +//! directory cleans up with one `rm -rf`. +//! +//! When the kernel rejects the mount (typically `EPERM` outside a user +//! namespace, or `ENODEV` if the module is absent) we fall back to +//! `fuse-overlayfs(1)` because that is what the project shipped before and +//! existing user environments rely on it. +//! +//! Backend selection is remembered per-mount so +//! [`stop`](IsolationBackend::stop) dispatches to the correct teardown path +//! (`umount2` vs `fusermount[3] -u`). + +use std::path::Path; + +use async_trait::async_trait; + +#[cfg(not(target_os = "linux"))] +use crate::IsoError; +use crate::{BackendKind, IsoResult, IsolationBackend, ProbeResult}; + +pub struct OverlayfsBackend; + +pub fn backend() -> &'static dyn IsolationBackend { + &OverlayfsBackend +} + +#[async_trait] +impl IsolationBackend for OverlayfsBackend { + fn kind(&self) -> BackendKind { + BackendKind::Overlayfs + } + + fn probe(&self) -> ProbeResult { + #[cfg(target_os = "linux")] + { + imp::probe() + } + #[cfg(not(target_os = "linux"))] + { + ProbeResult::unavailable("overlayfs isolation is only available on Linux") + } + } + + fn start(&self, lower: &Path, merged: &Path) -> IsoResult<()> { + #[cfg(target_os = "linux")] + { + imp::start(lower, merged) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (lower, merged); + Err(IsoError::unavailable("overlayfs isolation is only available on Linux")) + } + } + + fn stop(&self, merged: &Path) -> IsoResult<()> { + #[cfg(target_os = "linux")] + { + imp::stop(merged) + } + #[cfg(not(target_os = "linux"))] + { + let _ = merged; + Ok(()) + } + } +} + +#[cfg(target_os = "linux")] +mod imp { + use std::{ + collections::BTreeMap, + ffi::CString, + fs, + os::unix::ffi::OsStrExt, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::LazyLock, + }; + + use parking_lot::Mutex; + + use crate::{IsoError, IsoResult, ProbeResult}; + + #[derive(Clone, Copy)] + enum MountFlavor { + Kernel, + Fuse, + } + + static ACTIVE_MOUNTS: LazyLock>> = + LazyLock::new(|| Mutex::new(BTreeMap::new())); + + pub fn probe() -> ProbeResult { + if kernel_overlay_supported() { + return ProbeResult::available(); + } + if fuse_overlayfs_available() { + return ProbeResult::available(); + } + ProbeResult::unavailable( + "overlay filesystem unavailable: kernel `overlay` module missing and `fuse-overlayfs` \ + not on PATH", + ) + } + + pub fn start(lower: &Path, merged: &Path) -> IsoResult<()> { + let lower = canonical_existing_dir(lower)?; + let merged = absolutize(merged); + let base = merged.parent().ok_or_else(|| { + IsoError::other(format!("merged path has no parent: {}", merged.display())) + })?; + let upper = base.join("upper"); + let work = base.join("work"); + + fs::create_dir_all(&upper) + .map_err(|err| IsoError::other(format!("create upper dir {}: {err}", upper.display())))?; + fs::create_dir_all(&work) + .map_err(|err| IsoError::other(format!("create work dir {}: {err}", work.display())))?; + fs::create_dir_all(&merged).map_err(|err| { + IsoError::other(format!("create merged dir {}: {err}", merged.display())) + })?; + + let opts = format!( + "lowerdir={},upperdir={},workdir={}", + lower.display(), + upper.display(), + work.display() + ); + + match kernel_mount(&merged, &opts) { + Ok(()) => { + ACTIVE_MOUNTS + .lock() + .insert(merged.clone(), MountFlavor::Kernel); + Ok(()) + }, + Err(err) if err.is_unavailable() => { + fuse_mount(&lower, &upper, &work, &merged)?; + ACTIVE_MOUNTS + .lock() + .insert(merged.clone(), MountFlavor::Fuse); + Ok(()) + }, + Err(err) => Err(err), + } + } + + pub fn stop(merged: &Path) -> IsoResult<()> { + let merged = absolutize(merged); + let flavor = ACTIVE_MOUNTS.lock().remove(&merged); + match flavor { + Some(MountFlavor::Fuse) => fuse_umount(&merged), + Some(MountFlavor::Kernel) | None => { + // `None` covers callers that skipped `start` (probe-style flow) + // or processes that re-attached after a crash; try a kernel + // umount first, fall back to fusermount so we don't silently + // leak a mount. + kernel_umount(&merged).or_else(|err| { + if err.is_unavailable() { + fuse_umount(&merged) + } else { + Err(err) + } + }) + }, + } + } + + fn kernel_mount(merged: &Path, opts: &str) -> IsoResult<()> { + let target = to_cstring(merged.as_os_str().as_bytes(), "merged")?; + let source = CString::new("overlay").expect("static source"); + let fstype = CString::new("overlay").expect("static fstype"); + let opts_c = to_cstring(opts.as_bytes(), "overlay options")?; + + // SAFETY: all pointers are valid CString-backed and outlive the call. + let rc = unsafe { + libc::mount( + source.as_ptr(), + target.as_ptr(), + fstype.as_ptr(), + 0, + opts_c.as_ptr().cast::(), + ) + }; + if rc == 0 { + return Ok(()); + } + let err = std::io::Error::last_os_error(); + let raw = err.raw_os_error(); + if matches!( + raw, + Some(libc::EPERM | libc::EACCES | libc::ENODEV | libc::ENOENT | libc::EINVAL) + ) { + return Err(IsoError::unavailable(format!( + "kernel overlay mount denied ({err}); falling back to fuse-overlayfs" + ))); + } + Err(IsoError::other(format!("overlay mount {}: {err}", merged.display()))) + } + + fn kernel_umount(merged: &Path) -> IsoResult<()> { + let target = to_cstring(merged.as_os_str().as_bytes(), "merged")?; + // SAFETY: `target` lives until after the syscall returns. + let rc = unsafe { libc::umount2(target.as_ptr(), libc::MNT_DETACH) }; + if rc == 0 { + return Ok(()); + } + let err = std::io::Error::last_os_error(); + match err.raw_os_error() { + Some(libc::EINVAL | libc::ENOENT) => { + // Nothing mounted there — already torn down. + Ok(()) + }, + Some(libc::EPERM | libc::EACCES) => { + Err(IsoError::unavailable(format!("kernel umount denied: {err}"))) + }, + _ => Err(IsoError::other(format!("umount {}: {err}", merged.display()))), + } + } + + fn fuse_mount(lower: &Path, upper: &Path, work: &Path, merged: &Path) -> IsoResult<()> { + let opts = format!( + "lowerdir={},upperdir={},workdir={}", + lower.display(), + upper.display(), + work.display() + ); + let output = Command::new("fuse-overlayfs") + .args(["-o", &opts]) + .arg(merged) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output(); + let output = match output { + Ok(out) => out, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(IsoError::unavailable( + "fuse-overlayfs not found on PATH; install it to enable overlay isolation", + )); + }, + Err(err) => return Err(IsoError::other(format!("spawn fuse-overlayfs: {err}"))), + }; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(IsoError::other(format!( + "fuse-overlayfs mount failed (exit {}): {stderr}", + output.status.code().unwrap_or(-1) + ))) + } + + fn fuse_umount(merged: &Path) -> IsoResult<()> { + for binary in ["fusermount3", "fusermount"] { + let result = Command::new(binary) + .arg("-u") + .arg(merged) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output(); + match result { + Ok(out) if out.status.success() => return Ok(()), + Ok(_) => continue, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(err) => return Err(IsoError::other(format!("spawn {binary}: {err}"))), + } + } + // Last resort — try the lazy kernel umount; it works for both kernel + // overlay and any fuse mount the user can reach. + kernel_umount(merged) + } + + fn kernel_overlay_supported() -> bool { + let text = match fs::read_to_string("/proc/filesystems") { + Ok(text) => text, + Err(_) => return false, + }; + text + .lines() + .any(|line| line.split_whitespace().any(|word| word == "overlay")) + } + + fn fuse_overlayfs_available() -> bool { + Command::new("fuse-overlayfs") + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() + } + + fn canonical_existing_dir(path: &Path) -> IsoResult { + let resolved = absolutize(path); + let meta = fs::metadata(&resolved).map_err(|err| { + IsoError::other(format!("invalid overlay lower {}: {err}", resolved.display())) + })?; + if !meta.is_dir() { + return Err(IsoError::other(format!( + "overlay lower {} is not a directory", + resolved.display() + ))); + } + Ok(fs::canonicalize(&resolved).unwrap_or(resolved)) + } + + fn absolutize(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + } + } + + fn to_cstring(bytes: &[u8], label: &str) -> IsoResult { + CString::new(bytes) + .map_err(|err| IsoError::other(format!("{label} path contains NUL byte: {err}"))) + } +} diff --git a/crates/pi-natives/src/projfs_overlay.rs b/crates/pi-iso/src/projfs.rs similarity index 86% rename from crates/pi-natives/src/projfs_overlay.rs rename to crates/pi-iso/src/projfs.rs index 19508d85b..8dfe63c35 100644 --- a/crates/pi-natives/src/projfs_overlay.rs +++ b/crates/pi-iso/src/projfs.rs @@ -1,58 +1,105 @@ -//! Windows ProjFS-backed overlay lifecycle for task isolation. +//! Windows Projected File System backend. +//! +//! Ported from the original `pi_natives::projfs_overlay`; the napi-derived +//! types and the `Result<()>` alias from `napi::bindgen_prelude` are +//! replaced with the platform-neutral [`crate::IsoError`] / +//! [`crate::ProbeResult`]. -use napi::bindgen_prelude::*; -use napi_derive::napi; +use std::path::Path; -const PROJFS_UNAVAILABLE_PREFIX: &str = "PROJFS_UNAVAILABLE:"; - -/// Result of probing Windows Projected File System (`ProjFS`) support for -/// overlay workflows. -#[napi(object)] -pub struct ProjfsOverlayProbeResult { - /// True when `ProjFS` APIs are available and loaded. - pub available: bool, - /// Human-readable reason when `available` is false (e.g. wrong OS or missing - /// DLL). - pub reason: Option, -} - -/// Probe whether `ProjFS` overlay virtualization can be started on this system. -#[napi] -pub fn projfs_overlay_probe() -> ProjfsOverlayProbeResult { - imp::probe() -} - -/// Start a `ProjFS` overlay: `projection_root` shows the merged view; -/// `lower_root` is the backing tree. -#[napi] -pub fn projfs_overlay_start(lower_root: String, projection_root: String) -> Result<()> { - imp::start(&lower_root, &projection_root) -} - -/// Stop `ProjFS` virtualization for an active `projection_root` session. -#[napi] -pub fn projfs_overlay_stop(projection_root: String) -> Result<()> { - imp::stop(&projection_root); - Ok(()) -} +use async_trait::async_trait; #[cfg(not(windows))] -mod imp { - use napi::bindgen_prelude::*; +use crate::IsoError; +use crate::{BackendKind, IsoResult, IsolationBackend, ProbeResult}; - use super::{PROJFS_UNAVAILABLE_PREFIX, ProjfsOverlayProbeResult}; +pub struct ProjfsBackend; - const UNSUPPORTED_REASON: &str = "Windows ProjFS is unavailable on this platform"; +pub fn backend() -> &'static dyn IsolationBackend { + &ProjfsBackend +} - pub fn probe() -> ProjfsOverlayProbeResult { - ProjfsOverlayProbeResult { available: false, reason: Some(UNSUPPORTED_REASON.to_string()) } +#[async_trait] +impl IsolationBackend for ProjfsBackend { + fn kind(&self) -> BackendKind { + BackendKind::Projfs } - pub fn start(_lower_root: &str, _projection_root: &str) -> Result<()> { - Err(Error::from_reason(format!("{PROJFS_UNAVAILABLE_PREFIX} {UNSUPPORTED_REASON}"))) + fn probe(&self) -> ProbeResult { + #[cfg(windows)] + { + // ProjFS's native bindings misbehave when the process is x64 + // running under Windows ARM64 emulation — `LoadLibrary` succeeds + // but the callbacks crash on first invocation. Refuse early so + // `resolve()` falls back to a different backend instead of + // surfacing a hard crash to the caller. + if x64_under_arm64_emulation() { + return ProbeResult::unavailable( + "ProjFS is disabled on Windows ARM64 under x64 emulation (use a native ARM64 build)", + ); + } + imp::probe() + } + #[cfg(not(windows))] + { + ProbeResult::unavailable("ProjFS isolation is only available on Windows") + } } - pub const fn stop(_projection_root: &str) {} + fn start(&self, lower: &Path, merged: &Path) -> IsoResult<()> { + #[cfg(windows)] + { + imp::start(&lower.to_string_lossy(), &merged.to_string_lossy()) + } + #[cfg(not(windows))] + { + let _ = (lower, merged); + Err(IsoError::unavailable("ProjFS isolation is only available on Windows")) + } + } + + fn stop(&self, merged: &Path) -> IsoResult<()> { + #[cfg(windows)] + { + imp::stop(&merged.to_string_lossy()); + Ok(()) + } + #[cfg(not(windows))] + { + let _ = merged; + Ok(()) + } + } +} + +/// `true` when the current process is x64 running under Windows ARM64 +/// emulation (WOW64-on-ARM64). Detected by the `PROCESSOR_ARCHITEW6432` +/// environment variable Windows sets in WOW64 children, plus the legacy +/// `PROCESSOR_ARCHITECTURE` slot for completeness. Off Windows the +/// answer is always `false`. +#[cfg_attr(not(windows), allow(dead_code, reason = "windows-only ARM64 emulation guard"))] +fn x64_under_arm64_emulation() -> bool { + if !cfg!(windows) || !cfg!(target_arch = "x86_64") { + return false; + } + let matches_arm64 = |var: &str| { + std::env::var(var) + .ok() + .is_some_and(|v| v.eq_ignore_ascii_case("ARM64")) + }; + matches_arm64("PROCESSOR_ARCHITEW6432") || matches_arm64("PROCESSOR_ARCHITECTURE") +} + +#[cfg(test)] +mod tests { + use super::x64_under_arm64_emulation; + + #[test] + fn returns_false_off_windows_or_non_x64() { + // Sanity: just calling it shouldn't panic. The result depends on + // build target + ambient env vars, both of which are stable in CI. + let _ = x64_under_arm64_emulation(); + } } #[cfg(windows)] @@ -76,7 +123,6 @@ mod imp { sync::{Arc, LazyLock}, }; - use napi::bindgen_prelude::*; use parking_lot::Mutex; use windows_sys::{ Win32::{ @@ -104,7 +150,7 @@ mod imp { core::{GUID, HRESULT, PCSTR, PCWSTR}, }; - use super::{PROJFS_UNAVAILABLE_PREFIX, ProjfsOverlayProbeResult}; + use crate::{IsoError, IsoResult, ProbeResult}; const EMPTY_WIDE: [u16; 1] = [0]; const MAX_READ_CHUNK: usize = 1024 * 1024; @@ -263,14 +309,14 @@ mod imp { static PROJFS_SESSIONS: LazyLock>> = LazyLock::new(|| Mutex::new(BTreeMap::new())); - pub fn probe() -> ProjfsOverlayProbeResult { + pub fn probe() -> ProbeResult { match ProjfsApi::load() { - Ok(_) => ProjfsOverlayProbeResult { available: true, reason: None }, - Err(reason) => ProjfsOverlayProbeResult { available: false, reason: Some(reason) }, + Ok(_) => ProbeResult { available: true, reason: None }, + Err(reason) => ProbeResult { available: false, reason: Some(reason) }, } } - pub fn start(lower_root: &str, projection_root: &str) -> Result<()> { + pub fn start(lower_root: &str, projection_root: &str) -> IsoResult<()> { let api = Arc::new(ProjfsApi::load().map_err(unavailable_error)?); let lower_root_path = resolve_existing_dir(lower_root)?; let projection_root_path = resolve_projection_root(projection_root)?; @@ -279,7 +325,7 @@ mod imp { { let mut sessions = PROJFS_SESSIONS.lock(); if sessions.contains_key(&projection_key) { - return Err(Error::from_reason(format!( + return Err(IsoError::other(format!( "ProjFS overlay is already active for {}", projection_root_path.display() ))); @@ -291,7 +337,7 @@ mod imp { let guid_hr = unsafe { CoCreateGuid(&raw mut instance_id) }; if is_failed(guid_hr) { PROJFS_SESSIONS.lock().remove(&projection_key); - return Err(Error::from_reason(format!( + return Err(IsoError::other(format!( "Unable to create ProjFS instance identifier ({})", format_hresult(guid_hr) ))); @@ -378,7 +424,7 @@ mod imp { } }; stop_projfs_session(started_session); - Err(Error::from_reason(error_message)) + Err(IsoError::other(error_message)) } pub fn stop(projection_root: &str) { @@ -721,13 +767,13 @@ mod imp { } } - fn resolve_existing_dir(path: &str) -> Result { + fn resolve_existing_dir(path: &str) -> crate::IsoResult { let resolved = resolve_absolute_path(Path::new(path)); let metadata = fs::metadata(&resolved).map_err(|err| { - Error::from_reason(format!("Invalid ProjFS lower root {}: {err}", resolved.display())) + IsoError::other(format!("Invalid ProjFS lower root {}: {err}", resolved.display())) })?; if !metadata.is_dir() { - return Err(Error::from_reason(format!( + return Err(IsoError::other(format!( "Invalid ProjFS lower root {}: path is not a directory", resolved.display() ))); @@ -735,22 +781,22 @@ mod imp { Ok(fs::canonicalize(&resolved).unwrap_or(resolved)) } - fn resolve_projection_root(path: &str) -> Result { + fn resolve_projection_root(path: &str) -> crate::IsoResult { let resolved = resolve_absolute_path(Path::new(path)); fs::create_dir_all(&resolved).map_err(|err| { - Error::from_reason(format!( + IsoError::other(format!( "Unable to create ProjFS projection root {}: {err}", resolved.display() )) })?; let metadata = fs::metadata(&resolved).map_err(|err| { - Error::from_reason(format!( + IsoError::other(format!( "Unable to access ProjFS projection root {}: {err}", resolved.display() )) })?; if !metadata.is_dir() { - return Err(Error::from_reason(format!( + return Err(IsoError::other(format!( "Invalid ProjFS projection root {}: path is not a directory", resolved.display() ))); @@ -776,16 +822,16 @@ mod imp { encoded } - fn unavailable_error(reason: String) -> Error { - Error::from_reason(format!("{PROJFS_UNAVAILABLE_PREFIX} {reason}")) + fn unavailable_error(reason: String) -> IsoError { + IsoError::unavailable(reason) } - fn classify_start_error(phase: &str, hr: HRESULT) -> Error { + fn classify_start_error(phase: &str, hr: HRESULT) -> IsoError { let detail = format!("ProjFS {phase} failed ({})", format_hresult(hr)); if is_unavailable_hresult(hr) { return unavailable_error(detail); } - Error::from_reason(detail) + IsoError::other(detail) } const fn is_unavailable_hresult(hr: HRESULT) -> bool { diff --git a/crates/pi-iso/src/rcopy.rs b/crates/pi-iso/src/rcopy.rs new file mode 100644 index 000000000..95f72d111 --- /dev/null +++ b/crates/pi-iso/src/rcopy.rs @@ -0,0 +1,452 @@ +//! Cross-platform fallback isolation: git worktree, or plain recursive copy. +//! +//! When `lower` is a git working tree, [`start`](IsolationBackend::start) +//! materializes `merged` via `git worktree add --detach HEAD`. +//! Stop tears it down with `git worktree remove --force`. This lets git +//! itself manage refs/index/HEAD inside `merged`, keeping +//! [`diff`](IsolationBackend::diff) on the `git diff` path. +//! +//! Otherwise we do a vanilla recursive copy, preserving file modes and +//! mtimes so the default mtime-skipping diff path stays fast. There is no +//! file-system magic; the caller pays full filesystem-copy cost up front +//! and an `rm -rf` on teardown. + +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; + +use crate::{BackendKind, IsoError, IsoResult, IsolationBackend, ProbeResult}; + +pub struct RcopyBackend; + +#[async_trait] +impl IsolationBackend for RcopyBackend { + fn kind(&self) -> BackendKind { + BackendKind::Rcopy + } + + fn probe(&self) -> ProbeResult { + // Pure-stdlib fallback path is always available. We don't probe for + // `git` here because the non-git branch doesn't need it; the git + // branch will surface a clear unavailable-error if `lower` is a git + // tree but `git` is missing from PATH. + ProbeResult::available() + } + + fn start(&self, lower: &Path, merged: &Path) -> IsoResult<()> { + let lower = canonical_existing_dir(lower)?; + prepare_destination(merged)?; + if is_git_worktree(&lower) { + git_worktree_add(&lower, merged)?; + // `worktree add --detach HEAD` lands on a clean checkout. omp + // (and friends) expect `merged` to mirror `lower`'s **live** + // working tree, so seed the index + working tree + untracked + // files exactly as they exist in lower. No applyBaseline call + // in the caller — every backend's post-`start` invariant is + // the same. + seed_dirty_state(&lower, merged) + } else { + recursive_copy(&lower, merged) + } + } + + fn stop(&self, merged: &Path) -> IsoResult<()> { + // Best-effort: if we recognise this path as a registered worktree, + // use git to remove it (so the parent repo's worktree list stays + // consistent). Otherwise just rm -rf. + if is_git_worktree(merged) { + let _ = git_worktree_remove(merged); + } + match std::fs::remove_dir_all(merged) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(IsoError::other(format!("unable to remove {}: {err}", merged.display()))), + } + } +} + +fn canonical_existing_dir(path: &Path) -> IsoResult { + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path)) + }; + let meta = std::fs::metadata(&resolved).map_err(|err| { + IsoError::other(format!("invalid rcopy source {}: {err}", resolved.display())) + })?; + if !meta.is_dir() { + return Err(IsoError::other(format!( + "rcopy source {} is not a directory", + resolved.display() + ))); + } + Ok(std::fs::canonicalize(&resolved).unwrap_or(resolved)) +} + +fn prepare_destination(merged: &Path) -> IsoResult<()> { + if let Some(parent) = merged.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| IsoError::other(format!("create parent of {}: {err}", merged.display())))?; + } + match std::fs::remove_dir_all(merged) { + Ok(()) => {}, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}, + Err(err) => { + return Err(IsoError::other(format!( + "unable to clear {} before rcopy: {err}", + merged.display() + ))); + }, + } + Ok(()) +} + +fn is_git_worktree(path: &Path) -> bool { + // A regular working tree has `.git` as a dir; a linked worktree has it + // as a `gitdir: …` text file. Either way, presence of `.git` is the + // signal git itself uses. + std::fs::symlink_metadata(path.join(".git")).is_ok() +} + +fn git_worktree_add(lower: &Path, merged: &Path) -> IsoResult<()> { + let output = std::process::Command::new("git") + .arg("-C") + .arg(lower) + .args(["worktree", "add", "--detach"]) + .arg(merged) + .arg("HEAD") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + IsoError::unavailable( + "`git` not on PATH; rcopy cannot materialise a worktree from a git source", + ) + } else { + IsoError::other(format!("spawn git worktree add: {err}")) + } + })?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(IsoError::other(format!( + "git worktree add (exit {}): {stderr}", + output.status.code().unwrap_or(-1) + ))) +} + +fn git_worktree_remove(merged: &Path) -> IsoResult<()> { + let output = std::process::Command::new("git") + .arg("-C") + .arg(merged) + .args(["worktree", "remove", "--force"]) + .arg(merged) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|err| IsoError::other(format!("spawn git worktree remove: {err}")))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(IsoError::other(format!( + "git worktree remove (exit {}): {stderr}", + output.status.code().unwrap_or(-1) + ))) +} + +/// Replicate `lower`'s live working tree on top of a freshly-checked-out +/// worktree at `merged`. Three passes mirror what `git status` would +/// report at `lower`: +/// +/// 1. **Staged** — `git diff --binary --cached` from lower, applied to both +/// the index and the working tree of `merged`. +/// 2. **Unstaged** — `git diff --binary` from lower, applied to the working +/// tree only. +/// 3. **Untracked** — every path listed by `git ls-files --others +/// --exclude-standard -z` from lower, recursively copied into the same +/// relative location under `merged`. +/// +/// Result: `git status` inside `merged` reports the same dirty set as +/// `lower` at the moment `start()` was called, so the rest of the PAL +/// contract ("merged mirrors lower's live working tree") holds for +/// rcopy on git inputs too. +fn seed_dirty_state(lower: &Path, merged: &Path) -> IsoResult<()> { + let staged = git_capture(lower, &["diff", "--binary", "--no-color", "--cached"])?; + if !staged.is_empty() { + git_apply(merged, &staged, &["--cached"])?; + git_apply(merged, &staged, &[])?; + } + + let unstaged = git_capture(lower, &["diff", "--binary", "--no-color"])?; + if !unstaged.is_empty() { + git_apply(merged, &unstaged, &[])?; + } + + let untracked = git_capture(lower, &["ls-files", "--others", "--exclude-standard", "-z"])?; + for path_bytes in untracked.split(|b| *b == 0) { + if path_bytes.is_empty() { + continue; + } + let rel = std::str::from_utf8(path_bytes) + .map_err(|err| IsoError::other(format!("untracked path is not valid UTF-8: {err}")))?; + let src = lower.join(rel); + let dst = merged.join(rel); + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| IsoError::other(format!("create {}: {err}", parent.display())))?; + } + copy_path(&src, &dst)?; + } + + Ok(()) +} + +fn git_capture(cwd: &Path, args: &[&str]) -> IsoResult> { + let output = std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + IsoError::unavailable( + "`git` not on PATH; rcopy cannot seed dirty state from a git source", + ) + } else { + IsoError::other(format!("spawn git {}: {err}", args.first().unwrap_or(&""))) + } + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(IsoError::other(format!( + "git {} (exit {}): {stderr}", + args.join(" "), + output.status.code().unwrap_or(-1) + ))); + } + Ok(output.stdout) +} + +fn git_apply(cwd: &Path, patch: &[u8], extra: &[&str]) -> IsoResult<()> { + use std::io::Write as _; + let mut child = std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .args(["apply", "--binary", "--whitespace=nowarn"]) + .args(extra) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + IsoError::unavailable( + "`git` not on PATH; rcopy cannot seed dirty state from a git source", + ) + } else { + IsoError::other(format!("spawn git apply: {err}")) + } + })?; + { + let stdin = child + .stdin + .as_mut() + .ok_or_else(|| IsoError::other("git apply: child stdin was not piped".to_string()))?; + stdin + .write_all(patch) + .map_err(|err| IsoError::other(format!("write patch to git apply: {err}")))?; + } + let output = child + .wait_with_output() + .map_err(|err| IsoError::other(format!("wait git apply: {err}")))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(IsoError::other(format!( + "git apply (exit {}): {stderr}", + output.status.code().unwrap_or(-1) + ))) +} + +/// Copy a single path (regular file, symlink, or directory) from `src` +/// to `dst`, preserving mode and mtime on supported platforms. Used by +/// the untracked-files pass; directories are recursed via the existing +/// [`copy_dir_contents`] helper. +fn copy_path(src: &Path, dst: &Path) -> IsoResult<()> { + let meta = std::fs::symlink_metadata(src) + .map_err(|err| IsoError::other(format!("stat {}: {err}", src.display())))?; + if meta.file_type().is_symlink() { + copy_symlink(src, dst) + } else if meta.file_type().is_dir() { + std::fs::create_dir_all(dst) + .map_err(|err| IsoError::other(format!("create {}: {err}", dst.display())))?; + copy_dir_contents(src, dst)?; + copy_dir_mtime(src, dst); + Ok(()) + } else { + std::fs::copy(src, dst).map_err(|err| { + IsoError::other(format!("copy {} -> {}: {err}", src.display(), dst.display())) + })?; + copy_file_mtime(src, dst); + Ok(()) + } +} + +/// Recursive copy preserving file modes (unix) and mtimes on both unix +/// and windows. We don't use `std::fs::copy` for the final mtime fix-up +/// because `copy` already preserves mtime on the macOS/Linux platforms we +/// care about — but we still set it explicitly to keep behaviour +/// consistent across hosts where the stdlib promise is weaker. +fn recursive_copy(lower: &Path, merged: &Path) -> IsoResult<()> { + std::fs::create_dir_all(merged) + .map_err(|err| IsoError::other(format!("create {}: {err}", merged.display())))?; + copy_dir_contents(lower, merged) +} + +fn copy_dir_contents(src: &Path, dst: &Path) -> IsoResult<()> { + let entries = std::fs::read_dir(src) + .map_err(|err| IsoError::other(format!("read_dir {}: {err}", src.display())))?; + for entry in entries { + let entry = + entry.map_err(|err| IsoError::other(format!("dir entry in {}: {err}", src.display())))?; + let file_type = entry + .file_type() + .map_err(|err| IsoError::other(format!("file_type {}: {err}", entry.path().display())))?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if file_type.is_symlink() { + copy_symlink(&src_path, &dst_path)?; + } else if file_type.is_dir() { + std::fs::create_dir_all(&dst_path) + .map_err(|err| IsoError::other(format!("create {}: {err}", dst_path.display())))?; + copy_dir_contents(&src_path, &dst_path)?; + copy_dir_mtime(&src_path, &dst_path); + } else { + std::fs::copy(&src_path, &dst_path).map_err(|err| { + IsoError::other(format!("copy {} -> {}: {err}", src_path.display(), dst_path.display())) + })?; + copy_file_mtime(&src_path, &dst_path); + } + } + Ok(()) +} + +#[cfg(unix)] +fn copy_symlink(src: &Path, dst: &Path) -> IsoResult<()> { + let target = std::fs::read_link(src) + .map_err(|err| IsoError::other(format!("read_link {}: {err}", src.display())))?; + std::os::unix::fs::symlink(target, dst) + .map_err(|err| IsoError::other(format!("symlink {}: {err}", dst.display()))) +} + +#[cfg(windows)] +fn copy_symlink(src: &Path, dst: &Path) -> IsoResult<()> { + let target = std::fs::read_link(src) + .map_err(|err| IsoError::other(format!("read_link {}: {err}", src.display())))?; + let meta = std::fs::symlink_metadata(src) + .map_err(|err| IsoError::other(format!("symlink_metadata {}: {err}", src.display())))?; + let res = if meta.file_type().is_dir() { + std::os::windows::fs::symlink_dir(target, dst) + } else { + std::os::windows::fs::symlink_file(target, dst) + }; + res.map_err(|err| IsoError::other(format!("symlink {}: {err}", dst.display()))) +} + +#[cfg(not(any(unix, windows)))] +fn copy_symlink(_src: &Path, _dst: &Path) -> IsoResult<()> { + Err(IsoError::other("symlink copy unsupported on this platform")) +} + +/// Mirror `src`'s mtime onto `dst`. Failures are silently ignored — the +/// mtime hint is an optimisation for [`crate::diff`], not a correctness +/// requirement. +fn copy_file_mtime(src: &Path, dst: &Path) { + let Ok(meta) = std::fs::metadata(src) else { + return; + }; + let Ok(mtime) = meta.modified() else { return }; + let _ = filetime_set(dst, mtime); +} + +fn copy_dir_mtime(src: &Path, dst: &Path) { + let Ok(meta) = std::fs::metadata(src) else { + return; + }; + let Ok(mtime) = meta.modified() else { return }; + let _ = filetime_set(dst, mtime); +} + +#[cfg(unix)] +fn filetime_set(path: &Path, mtime: std::time::SystemTime) -> std::io::Result<()> { + use std::os::unix::ffi::OsStrExt; + let dur = mtime + .duration_since(std::time::UNIX_EPOCH) + .map_err(|err| std::io::Error::other(err.to_string()))?; + let times = + [libc::timespec { tv_sec: dur.as_secs() as libc::time_t, tv_nsec: 0 }, libc::timespec { + tv_sec: dur.as_secs() as libc::time_t, + tv_nsec: dur.subsec_nanos() as libc::c_long, + }]; + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())?; + // SAFETY: `c_path` and `times` outlive the syscall; the kernel does + // not retain the pointers. + let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(windows)] +fn filetime_set(path: &Path, mtime: std::time::SystemTime) -> std::io::Result<()> { + use std::{ + fs::OpenOptions, + os::windows::{fs::OpenOptionsExt, io::AsRawHandle}, + }; + + use windows_sys::Win32::{ + Foundation::FILETIME, + Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, SetFileTime}, + }; + + let dur = mtime + .duration_since(std::time::UNIX_EPOCH) + .map_err(|err| std::io::Error::other(err.to_string()))?; + // Windows FILETIME = 100-ns ticks since 1601-01-01. + const EPOCH_DIFF_100NS: u64 = 116_444_736_000_000_000; + let ticks = EPOCH_DIFF_100NS + dur.as_secs() * 10_000_000 + u64::from(dur.subsec_nanos() / 100); + let ft = FILETIME { + dwLowDateTime: (ticks & 0xffff_ffff) as u32, + dwHighDateTime: (ticks >> 32) as u32, + }; + + let mut opts = OpenOptions::new(); + opts.write(true); + opts.custom_flags(FILE_FLAG_BACKUP_SEMANTICS); + let file = opts.open(path)?; + // SAFETY: file owns the HANDLE for the duration of the call. + let ok = unsafe { + SetFileTime(file.as_raw_handle() as _, std::ptr::null(), std::ptr::null(), &raw const ft) + }; + if ok != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(not(any(unix, windows)))] +fn filetime_set(_path: &Path, _mtime: std::time::SystemTime) -> std::io::Result<()> { + Ok(()) +} diff --git a/crates/pi-iso/src/windows_block_clone.rs b/crates/pi-iso/src/windows_block_clone.rs new file mode 100644 index 000000000..5e0bb31ed --- /dev/null +++ b/crates/pi-iso/src/windows_block_clone.rs @@ -0,0 +1,375 @@ +//! Windows block-clone based isolation. +//! +//! `FSCTL_DUPLICATE_EXTENTS_TO_FILE` asks NTFS/ReFS to share file extents +//! copy-on-write between a source file and a destination file. The backend +//! recursively materializes the directory tree and block-clones each regular +//! file. There is no mount/session state to undo, so +//! [`stop`](IsolationBackend::stop) is a recursive remove. + +use std::path::Path; + +use async_trait::async_trait; + +#[cfg(not(windows))] +use crate::IsoError; +use crate::{BackendKind, IsoResult, IsolationBackend, ProbeResult}; + +pub struct WindowsBlockCloneBackend; + +pub fn backend() -> &'static dyn IsolationBackend { + &WindowsBlockCloneBackend +} + +#[async_trait] +impl IsolationBackend for WindowsBlockCloneBackend { + fn kind(&self) -> BackendKind { + BackendKind::WindowsBlockClone + } + + fn probe(&self) -> ProbeResult { + #[cfg(windows)] + { + ProbeResult::available() + } + #[cfg(not(windows))] + { + ProbeResult::unavailable("Windows block-clone isolation is only available on Windows") + } + } + + fn start(&self, lower: &Path, merged: &Path) -> IsoResult<()> { + #[cfg(windows)] + { + imp::start(lower, merged) + } + #[cfg(not(windows))] + { + let _ = (lower, merged); + Err(IsoError::unavailable("Windows block-clone isolation is only available on Windows")) + } + } + + fn stop(&self, merged: &Path) -> IsoResult<()> { + #[cfg(windows)] + { + imp::stop(merged) + } + #[cfg(not(windows))] + { + let _ = merged; + Ok(()) + } + } +} + +#[cfg(windows)] +mod imp { + use std::{ + fs::{self, File, OpenOptions}, + io, + os::windows::{ + fs::{FileTypeExt, OpenOptionsExt}, + io::AsRawHandle, + }, + path::{Path, PathBuf}, + }; + + use windows_sys::Win32::{ + Foundation::{ + ERROR_ACCESS_DENIED, ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, + ERROR_NOT_SAME_DEVICE, ERROR_NOT_SUPPORTED, FILETIME, + }, + Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, SetFileTime, + }, + System::{ + IO::DeviceIoControl, + Ioctl::{DUPLICATE_EXTENTS_DATA, FSCTL_DUPLICATE_EXTENTS_TO_FILE}, + }, + }; + + use crate::{IsoError, IsoResult}; + + pub fn start(lower: &Path, merged: &Path) -> IsoResult<()> { + let lower = canonical_existing_dir(lower)?; + prepare_destination(merged)?; + + let result = recursive_block_clone(&lower, merged); + if result.is_err() { + let _ = remove_path(merged); + } + result + } + + pub fn stop(merged: &Path) -> IsoResult<()> { + remove_path(merged).map_err(|err| { + IsoError::other(format!("unable to remove block-cloned tree {}: {err}", merged.display())) + }) + } + + fn canonical_existing_dir(path: &Path) -> IsoResult { + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + }; + let meta = fs::metadata(&resolved).map_err(|err| { + IsoError::other(format!("invalid block-clone source {}: {err}", resolved.display())) + })?; + if !meta.is_dir() { + return Err(IsoError::other(format!( + "block-clone source {} is not a directory", + resolved.display() + ))); + } + Ok(fs::canonicalize(&resolved).unwrap_or(resolved)) + } + + fn prepare_destination(merged: &Path) -> IsoResult<()> { + if let Some(parent) = merged.parent() { + fs::create_dir_all(parent).map_err(|err| { + IsoError::other(format!("create parent of {}: {err}", merged.display())) + })?; + } + remove_path(merged).map_err(|err| { + IsoError::other(format!("unable to clear {} before block clone: {err}", merged.display())) + })?; + Ok(()) + } + + fn remove_path(path: &Path) -> io::Result<()> { + let meta = match fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + let file_type = meta.file_type(); + if file_type.is_dir() && !file_type.is_symlink() { + for entry in fs::read_dir(path)? { + remove_path(&entry?.path())?; + } + clear_readonly(path, &meta); + fs::remove_dir(path) + } else { + clear_readonly(path, &meta); + fs::remove_file(path) + } + } + + fn clear_readonly(path: &Path, meta: &fs::Metadata) { + if meta.file_type().is_symlink() { + return; + } + let mut permissions = meta.permissions(); + if permissions.readonly() { + permissions.set_readonly(false); + let _ = fs::set_permissions(path, permissions); + } + } + + fn recursive_block_clone(lower: &Path, merged: &Path) -> IsoResult<()> { + fs::create_dir_all(merged) + .map_err(|err| IsoError::other(format!("create {}: {err}", merged.display())))?; + clone_dir_contents(lower, merged)?; + copy_metadata_best_effort(lower, merged); + Ok(()) + } + + fn clone_dir_contents(src: &Path, dst: &Path) -> IsoResult<()> { + let entries = fs::read_dir(src) + .map_err(|err| IsoError::other(format!("read_dir {}: {err}", src.display())))?; + for entry in entries { + let entry = entry + .map_err(|err| IsoError::other(format!("dir entry in {}: {err}", src.display())))?; + let file_type = entry.file_type().map_err(|err| { + IsoError::other(format!("file_type {}: {err}", entry.path().display())) + })?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if file_type.is_symlink() { + clone_symlink(&src_path, &dst_path)?; + copy_metadata_best_effort(&src_path, &dst_path); + } else if file_type.is_dir() { + fs::create_dir_all(&dst_path) + .map_err(|err| IsoError::other(format!("create {}: {err}", dst_path.display())))?; + clone_dir_contents(&src_path, &dst_path)?; + copy_metadata_best_effort(&src_path, &dst_path); + } else if file_type.is_file() { + clone_regular_file(&src_path, &dst_path)?; + copy_metadata_best_effort(&src_path, &dst_path); + } else { + return Err(IsoError::other(format!( + "unsupported filesystem entry for block clone: {}", + src_path.display() + ))); + } + } + Ok(()) + } + + fn clone_symlink(src: &Path, dst: &Path) -> IsoResult<()> { + let target = fs::read_link(src) + .map_err(|err| IsoError::other(format!("read_link {}: {err}", src.display())))?; + let file_type = fs::symlink_metadata(src) + .map_err(|err| IsoError::other(format!("symlink_metadata {}: {err}", src.display())))? + .file_type(); + let res = if file_type.is_symlink_dir() { + std::os::windows::fs::symlink_dir(target, dst) + } else { + std::os::windows::fs::symlink_file(target, dst) + }; + res.map_err(|err| IsoError::other(format!("symlink {}: {err}", dst.display()))) + } + + fn clone_regular_file(src: &Path, dst: &Path) -> IsoResult<()> { + let src_meta = fs::metadata(src) + .map_err(|err| IsoError::other(format!("metadata {}: {err}", src.display())))?; + let len = src_meta.len(); + + let src_file = OpenOptions::new().read(true).open(src).map_err(|err| { + IsoError::other(format!("open block-clone source {}: {err}", src.display())) + })?; + let dst_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(dst) + .map_err(|err| { + IsoError::other(format!("create block-clone destination {}: {err}", dst.display())) + })?; + dst_file + .set_len(len) + .map_err(|err| IsoError::other(format!("set_len {} to {len}: {err}", dst.display())))?; + + if len != 0 { + duplicate_extents(&src_file, &dst_file, len, src, dst)?; + } + Ok(()) + } + + fn duplicate_extents( + src_file: &File, + dst_file: &File, + len: u64, + src: &Path, + dst: &Path, + ) -> IsoResult<()> { + let byte_count = i64::try_from(len).map_err(|_| { + IsoError::other(format!("{} is too large for Windows block clone", src.display())) + })?; + let data = DUPLICATE_EXTENTS_DATA { + FileHandle: src_file.as_raw_handle() as _, + SourceFileOffset: 0, + TargetFileOffset: 0, + ByteCount: byte_count, + }; + let mut returned = 0u32; + let in_size = u32::try_from(std::mem::size_of::()) + .expect("DUPLICATE_EXTENTS_DATA size fits u32"); + + // SAFETY: `dst_file` and `src_file` own valid handles for the duration of + // the call. `data` points to an initialized DUPLICATE_EXTENTS_DATA buffer, + // and no output buffer is required by FSCTL_DUPLICATE_EXTENTS_TO_FILE. + let ok = unsafe { + DeviceIoControl( + dst_file.as_raw_handle() as _, + FSCTL_DUPLICATE_EXTENTS_TO_FILE, + &raw const data as *const _, + in_size, + std::ptr::null_mut(), + 0, + &raw mut returned, + std::ptr::null_mut(), + ) + }; + if ok != 0 { + return Ok(()); + } + + let err = io::Error::last_os_error(); + if is_unavailable_error(&err) { + Err(IsoError::unavailable(format!( + "Windows block clone unsupported for {} -> {}: {err}", + src.display(), + dst.display() + ))) + } else { + Err(IsoError::other(format!( + "FSCTL_DUPLICATE_EXTENTS_TO_FILE {} -> {}: {err}", + src.display(), + dst.display() + ))) + } + } + + fn is_unavailable_error(err: &io::Error) -> bool { + let Some(code) = err.raw_os_error() else { + return false; + }; + let code = code as u32; + matches!( + code, + ERROR_INVALID_FUNCTION + | ERROR_NOT_SUPPORTED + | ERROR_NOT_SAME_DEVICE + | ERROR_INVALID_PARAMETER + | ERROR_ACCESS_DENIED + ) + } + + fn copy_metadata_best_effort(src: &Path, dst: &Path) { + let Ok(meta) = fs::symlink_metadata(src) else { + return; + }; + set_times_best_effort(dst, &meta); + if !meta.file_type().is_symlink() { + let _ = fs::set_permissions(dst, meta.permissions()); + } + } + + fn set_times_best_effort(path: &Path, meta: &fs::Metadata) { + let created = meta.created().ok().and_then(system_time_to_filetime); + let accessed = meta.accessed().ok().and_then(system_time_to_filetime); + let modified = meta.modified().ok().and_then(system_time_to_filetime); + if created.is_none() && accessed.is_none() && modified.is_none() { + return; + } + + let mut opts = OpenOptions::new(); + opts.write(true); + opts.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT); + let Ok(file) = opts.open(path) else { return }; + // SAFETY: `file` owns the HANDLE for the duration of the call. The optional + // FILETIME pointers either reference stack locals that outlive the call or + // are null when the corresponding timestamp is unavailable. + let _ = unsafe { + SetFileTime( + file.as_raw_handle() as _, + created + .as_ref() + .map_or(std::ptr::null(), |ft| ft as *const FILETIME), + accessed + .as_ref() + .map_or(std::ptr::null(), |ft| ft as *const FILETIME), + modified + .as_ref() + .map_or(std::ptr::null(), |ft| ft as *const FILETIME), + ) + }; + } + + fn system_time_to_filetime(time: std::time::SystemTime) -> Option { + let dur = time.duration_since(std::time::UNIX_EPOCH).ok()?; + // Windows FILETIME = 100-ns ticks since 1601-01-01. + const EPOCH_DIFF_100NS: u64 = 116_444_736_000_000_000; + let ticks = EPOCH_DIFF_100NS + .checked_add(dur.as_secs().checked_mul(10_000_000)?)? + .checked_add(u64::from(dur.subsec_nanos() / 100))?; + Some(FILETIME { + dwLowDateTime: (ticks & 0xffff_ffff) as u32, + dwHighDateTime: (ticks >> 32) as u32, + }) + } +} diff --git a/crates/pi-iso/src/zfs.rs b/crates/pi-iso/src/zfs.rs new file mode 100644 index 000000000..bc5d34863 --- /dev/null +++ b/crates/pi-iso/src/zfs.rs @@ -0,0 +1,334 @@ +//! ZFS snapshot/clone-based isolation. +//! +//! ZFS can create a writable clone from a point-in-time snapshot without +//! copying file data. This backend only accepts a `lower` path that exactly +//! matches a mounted ZFS dataset mountpoint, snapshots that dataset, and clones +//! it to a sibling dataset mounted at `merged`. + +use std::path::Path; + +use async_trait::async_trait; + +#[cfg(not(unix))] +use crate::IsoError; +use crate::{BackendKind, IsoResult, IsolationBackend, ProbeResult}; + +pub struct ZfsBackend; + +pub fn backend() -> &'static dyn IsolationBackend { + &ZfsBackend +} + +#[async_trait] +impl IsolationBackend for ZfsBackend { + fn kind(&self) -> BackendKind { + BackendKind::Zfs + } + + fn probe(&self) -> ProbeResult { + #[cfg(unix)] + { + imp::probe() + } + #[cfg(not(unix))] + { + ProbeResult::unavailable("ZFS clone isolation is only available on Unix platforms") + } + } + + fn start(&self, lower: &Path, merged: &Path) -> IsoResult<()> { + #[cfg(unix)] + { + imp::start(lower, merged) + } + #[cfg(not(unix))] + { + let _ = (lower, merged); + Err(IsoError::unavailable("ZFS clone isolation is only available on Unix platforms")) + } + } + + fn stop(&self, merged: &Path) -> IsoResult<()> { + #[cfg(unix)] + { + imp::stop(merged) + } + #[cfg(not(unix))] + { + let _ = merged; + Ok(()) + } + } +} + +#[cfg(unix)] +mod imp { + use std::{ + fs, io, + path::{Path, PathBuf}, + process::{Command, Output}, + }; + + use crate::{IsoError, IsoResult, ProbeResult}; + + const SNAP_PREFIX: &str = "pi-iso-"; + + pub fn probe() -> ProbeResult { + if command_available(["version"]) || command_available(["list", "-H"]) { + ProbeResult::available() + } else { + ProbeResult::unavailable("zfs CLI is unavailable or cannot list datasets") + } + } + + pub fn start(lower: &Path, merged: &Path) -> IsoResult<()> { + ensure_zfs_available()?; + + let lower = canonical_existing_dir(lower)?; + let merged = absolute_path(merged); + let source = dataset_for_mountpoint(&lower)?.ok_or_else(|| { + IsoError::unavailable(format!( + "{} is not exactly a mounted ZFS dataset mountpoint", + lower.display() + )) + })?; + + if let Some(parent) = merged.parent() { + fs::create_dir_all(parent).map_err(|err| { + IsoError::other(format!("unable to create parent of {}: {err}", merged.display())) + })?; + } + + stop(&merged)?; + + let suffix = dataset_suffix(&merged); + let snapshot = format!("{source}@{SNAP_PREFIX}{suffix}"); + let clone = sibling_dataset(&source, &format!("{SNAP_PREFIX}{suffix}")); + + clear_stale_clone(&clone, &source)?; + let _ = run_zfs_status(["destroy", snapshot.as_str()]); + + run_zfs_other(["snapshot", snapshot.as_str()])?; + let mountpoint = merged.to_string_lossy(); + let mount_opt = format!("mountpoint={mountpoint}"); + match run_zfs_other(["clone", "-o", mount_opt.as_str(), snapshot.as_str(), clone.as_str()]) { + Ok(()) => Ok(()), + Err(err) => { + let _ = run_zfs_status(["destroy", snapshot.as_str()]); + Err(err) + }, + } + } + + pub fn stop(merged: &Path) -> IsoResult<()> { + let merged = absolute_path(merged); + if !merged.exists() { + return Ok(()); + } + match dataset_for_mountpoint(&merged)? { + Some(dataset) => { + let origin = zfs_get_value("origin", &dataset)?; + run_zfs_other(["destroy", dataset.as_str()])?; + if is_own_snapshot(&origin) { + run_zfs_other(["destroy", origin.as_str()])?; + } + Ok(()) + }, + None => match fs::remove_dir_all(&merged) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(IsoError::other(format!( + "unable to remove ZFS clone mountpoint {}: {err}", + merged.display() + ))), + }, + } + } + + fn ensure_zfs_available() -> IsoResult<()> { + if command_available(["version"]) || command_available(["list", "-H"]) { + Ok(()) + } else { + Err(IsoError::unavailable("zfs CLI is unavailable or cannot list datasets")) + } + } + + fn canonical_existing_dir(path: &Path) -> IsoResult { + let resolved = absolute_path(path); + let meta = fs::metadata(&resolved).map_err(|err| { + IsoError::unavailable(format!("invalid ZFS clone source {}: {err}", resolved.display())) + })?; + if !meta.is_dir() { + return Err(IsoError::unavailable(format!( + "ZFS clone source {} is not a directory", + resolved.display() + ))); + } + Ok(fs::canonicalize(&resolved).unwrap_or(resolved)) + } + + fn dataset_for_mountpoint(path: &Path) -> IsoResult> { + let wanted = normalize_path(path); + let output = run_zfs_output(["list", "-H", "-o", "name,mountpoint", "-t", "filesystem"])?; + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let mut fields = line.splitn(2, '\t'); + let Some(name) = fields.next() else { continue }; + let Some(mountpoint) = fields.next() else { + continue; + }; + if mountpoint == "-" || mountpoint == "none" || mountpoint == "legacy" { + continue; + } + if normalize_path(Path::new(mountpoint)) == wanted { + return Ok(Some(name.to_owned())); + } + } + Ok(None) + } + + fn clear_stale_clone(clone: &str, source: &str) -> IsoResult<()> { + let output = run_zfs_status(["get", "-H", "-o", "value", "origin", clone]); + match output { + Ok(output) if output.status.success() => { + let origin = trim_stdout(&output); + if origin.starts_with(source) + && origin[source.len()..].starts_with('@') + && is_own_snapshot(origin) + { + run_zfs_other(["destroy", "-r", clone]) + } else { + Err(IsoError::other(format!( + "refusing to destroy existing non-stale ZFS dataset {clone}" + ))) + } + }, + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr_is_unavailable(&stderr) { + Ok(()) + } else { + Err(IsoError::other(format!("zfs get origin {clone}: {}", stderr.trim()))) + } + }, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + Err(IsoError::unavailable("zfs CLI is unavailable")) + }, + Err(err) => Err(IsoError::other(format!("zfs get origin {clone}: {err}"))), + } + } + + fn zfs_get_value(property: &str, dataset: &str) -> IsoResult { + let output = run_zfs_output(["get", "-H", "-o", "value", property, dataset])?; + Ok(trim_stdout(&output).to_owned()) + } + + fn run_zfs_other(args: [&str; N]) -> IsoResult<()> { + let output = run_zfs_status(args).map_err(|err| { + if err.kind() == io::ErrorKind::NotFound { + IsoError::unavailable("zfs CLI is unavailable") + } else { + IsoError::other(format!("unable to execute zfs: {err}")) + } + })?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr_is_unavailable(&stderr) { + Err(IsoError::unavailable(stderr.trim().to_owned())) + } else { + Err(IsoError::other(format!("zfs failed: {}", stderr.trim()))) + } + } + + fn run_zfs_output(args: [&str; N]) -> IsoResult { + let output = run_zfs_status(args).map_err(|err| { + if err.kind() == io::ErrorKind::NotFound { + IsoError::unavailable("zfs CLI is unavailable") + } else { + IsoError::other(format!("unable to execute zfs: {err}")) + } + })?; + if output.status.success() { + return Ok(output); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr_is_unavailable(&stderr) { + Err(IsoError::unavailable(stderr.trim().to_owned())) + } else { + Err(IsoError::other(format!("zfs failed: {}", stderr.trim()))) + } + } + + fn run_zfs_status(args: [&str; N]) -> io::Result { + Command::new("zfs").args(args).output() + } + + fn command_available(args: [&str; N]) -> bool { + matches!(run_zfs_status(args), Ok(output) if output.status.success()) + } + + fn stderr_is_unavailable(stderr: &str) -> bool { + let stderr = stderr.to_ascii_lowercase(); + stderr.contains("dataset does not exist") + || stderr.contains("no datasets available") + || stderr.contains("not a zfs filesystem") + || stderr.contains("not a zfs file system") + || stderr.contains("operation not supported") + || stderr.contains("not supported") + || stderr.contains("no such pool") + || stderr.contains("modules are not loaded") + || stderr.contains("failed to initialize libzfs") + } + + fn sibling_dataset(source: &str, child: &str) -> String { + match source.rsplit_once('/') { + Some((parent, _)) => format!("{parent}/{child}"), + None => format!("{source}/{child}"), + } + } + + fn dataset_suffix(path: &Path) -> String { + let normalized = normalize_path(&absolute_path(path)); + let bytes = normalized.as_bytes(); + let a = fnv1a64(bytes, 0xcbf29ce484222325); + let b = fnv1a64(bytes, 0x84222325cbf29ce4 ^ bytes.len() as u64); + format!("{a:016x}-{b:016x}") + } + + fn fnv1a64(bytes: &[u8], seed: u64) -> u64 { + let mut hash = seed; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash + } + + fn is_own_snapshot(snapshot: &str) -> bool { + let Some((_, name)) = snapshot.rsplit_once('@') else { + return false; + }; + name.starts_with(SNAP_PREFIX) + && name[SNAP_PREFIX.len()..] + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + } + + fn absolute_path(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path)) + } + } + + fn normalize_path(path: &Path) -> String { + path.to_string_lossy().trim_end_matches('/').to_owned() + } + + fn trim_stdout(output: &Output) -> &str { + std::str::from_utf8(&output.stdout).unwrap_or("").trim() + } +} diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index d40c64992..ad83950a8 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -33,6 +33,7 @@ ast-grep-core = { version = "0.39", default-features = false, features = [ "tree-sitter", ] } pi-ast = { path = "../pi-ast" } +pi-iso = { path = "../pi-iso" } inferno = { version = "0.12", default-features = false } image = { version = "0.25", default-features = false, features = [ "png", @@ -72,13 +73,6 @@ libc = "0.2" [target.'cfg(windows)'.dependencies] winreg = "0.56" - -windows-sys = { version = "0.61", features = [ - "Win32_Foundation", - "Win32_Storage_ProjectedFileSystem", - "Win32_System_Com", - "Win32_System_LibraryLoader", -] } [build-dependencies] napi-build = "2" serde = { version = "1.0", features = ["derive"] } diff --git a/crates/pi-natives/src/iso.rs b/crates/pi-natives/src/iso.rs new file mode 100644 index 000000000..5f67f7467 --- /dev/null +++ b/crates/pi-natives/src/iso.rs @@ -0,0 +1,236 @@ +//! napi shim for the `pi-iso` PAL. +//! +//! Mirrors [`pi_iso::IsolationBackend`] across the FFI boundary: +//! +//! - `iso_backend()` — kind enum of the platform-native backend. +//! - `iso_resolve(preferred?)` — let the PAL pick the best backend (or honour a +//! hint) and report any fallback to the caller. +//! - `iso_probe(kind?)` — backend availability, with an optional explicit kind +//! override; falls back to the native backend when omitted. +//! - `iso_start(kind?, lower, merged)` / `iso_stop(kind?, merged)` — sync +//! syscalls wrapped in `spawn_blocking` so the JS side gets a normal Promise. +//! - `iso_diff(lower, merged)` — backend-agnostic diff capture; emits one +//! [`IsoFileChange`] per file. `diff` is `Some(unified)` for text files and +//! `None` for binary files — callers copy the bytes from `merged` directly if +//! they need them. +//! +//! `IsoError::Unavailable` is serialised with the `ISO_UNAVAILABLE:` +//! prefix so TS callers can distinguish "this backend isn't installed" +//! from a hard failure. + +use napi::bindgen_prelude::*; +use napi_derive::napi; +use pi_iso::{BackendKind, ChangeKind, Diff, FileChange, IsoError, IsolationBackend}; + +const ISO_UNAVAILABLE_PREFIX: &str = "ISO_UNAVAILABLE:"; + +/// Isolation backend identifier. Numeric so the JS side can `switch` on +/// the enum without string comparisons. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[napi] +pub enum IsoBackendKind { + Apfs = 0, + Btrfs = 1, + Zfs = 2, + LinuxReflink = 3, + Overlayfs = 4, + WindowsBlockClone = 5, + Projfs = 6, + Rcopy = 7, +} + +/// How a single file changed between `lower` and `merged`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[napi] +pub enum IsoChangeKind { + Added = 0, + Modified = 1, + Removed = 2, +} + +/// Probe result for a specific isolation backend. +#[napi(object)] +pub struct IsoProbeResult { + /// True when the backend's prerequisites are satisfied. + pub available: bool, + /// Human-readable explanation when `available` is false. + pub reason: Option, + /// Resolved backend kind. + pub kind: IsoBackendKind, +} + +/// Outcome of [`iso_resolve`]. +#[napi(object)] +pub struct IsoResolveResult { + /// Backend that will actually be used. + pub kind: IsoBackendKind, + /// True when the resolver fell back from `preferred` (or from the + /// platform native) to a different backend. + pub fell_back: bool, + /// Human-readable reason for the fallback, if any. + pub reason: Option, +} + +/// One entry in an [`IsoDiff`]. +#[napi(object)] +pub struct IsoFileChange { + /// Path relative to `merged`. + pub path: String, + pub op: IsoChangeKind, + /// Unified-diff text. `None` (`null` in JS) means the file is binary; + /// read it directly from `merged` if you need the bytes. + pub diff: Option, +} + +#[napi(object)] +pub struct IsoDiff { + pub files: Vec, +} + +/// Kind enum of the backend selected by default for this build target. +#[napi] +pub const fn iso_backend() -> IsoBackendKind { + to_napi_kind(BackendKind::native()) +} + +/// Probe whether the requested backend can start on this host. Pass +/// `null`/omit `kind` to probe the platform-native backend. +#[napi] +pub fn iso_probe(kind: Option) -> IsoProbeResult { + let resolved = kind.map_or_else(BackendKind::native, from_napi_kind); + let backend = pi_iso::backend(resolved); + let probe = backend.probe(); + IsoProbeResult { + available: probe.available, + reason: probe.reason, + kind: to_napi_kind(resolved), + } +} + +/// Pick the best backend available right now. `preferred` is treated as +/// a hint — see [`pi_iso::resolve`] for the exact priority rules. +#[napi] +pub fn iso_resolve(preferred: Option) -> IsoResolveResult { + let resolution = pi_iso::resolve(preferred.map(from_napi_kind)); + IsoResolveResult { + kind: to_napi_kind(resolution.kind), + fell_back: resolution.fell_back, + reason: resolution.reason, + } +} + +/// Materialise `merged` as a writable view of `lower` using the requested +/// backend. `kind` defaults to the native backend. +#[napi] +pub async fn iso_start(kind: Option, lower: String, merged: String) -> Result<()> { + let resolved = kind.map_or_else(BackendKind::native, from_napi_kind); + let lower_path = std::path::PathBuf::from(lower); + let merged_path = std::path::PathBuf::from(merged); + tokio::task::spawn_blocking(move || pi_iso::backend(resolved).start(&lower_path, &merged_path)) + .await + .map_err(|err| Error::from_reason(format!("iso_start join: {err}")))? + .map_err(to_napi_error) +} + +/// Tear down a previously started backend at `merged`. +#[napi] +pub async fn iso_stop(kind: Option, merged: String) -> Result<()> { + let resolved = kind.map_or_else(BackendKind::native, from_napi_kind); + let merged_path = std::path::PathBuf::from(merged); + tokio::task::spawn_blocking(move || pi_iso::backend(resolved).stop(&merged_path)) + .await + .map_err(|err| Error::from_reason(format!("iso_stop join: {err}")))? + .map_err(to_napi_error) +} + +/// Capture the changes between `lower` and `merged`. +/// +/// Uses [`pi_iso::IsolationBackend::diff`]'s default implementation — +/// `git diff` when `merged/.git` exists, otherwise a mtime-skipped tree +/// walk. The backend selection only affects the lifecycle methods; diff +/// behaviour is uniform. +#[napi] +pub async fn iso_diff(lower: String, merged: String) -> Result { + let lower_path = std::path::PathBuf::from(lower); + let merged_path = std::path::PathBuf::from(merged); + // Every backend inherits the same default `diff()` body, so we pick + // Rcopy as the always-available host. + let backend = pi_iso::backend(BackendKind::Rcopy); + let diff = backend + .diff(&lower_path, &merged_path) + .await + .map_err(to_napi_error)?; + Ok(into_iso_diff(diff)) +} + +/// True if `message` is an error message produced by [`IsoError::Unavailable`]. +/// Use this to distinguish "this backend isn't installed" from a hard +/// failure when handling caught errors on the JS side. +#[napi] +pub fn iso_is_unavailable_error(message: String) -> bool { + message.starts_with(ISO_UNAVAILABLE_PREFIX) + || message.contains(&format!(" {ISO_UNAVAILABLE_PREFIX}")) +} + +const fn to_napi_kind(kind: BackendKind) -> IsoBackendKind { + match kind { + BackendKind::Apfs => IsoBackendKind::Apfs, + BackendKind::Btrfs => IsoBackendKind::Btrfs, + BackendKind::Zfs => IsoBackendKind::Zfs, + BackendKind::LinuxReflink => IsoBackendKind::LinuxReflink, + BackendKind::Overlayfs => IsoBackendKind::Overlayfs, + BackendKind::WindowsBlockClone => IsoBackendKind::WindowsBlockClone, + BackendKind::Projfs => IsoBackendKind::Projfs, + BackendKind::Rcopy => IsoBackendKind::Rcopy, + } +} + +const fn from_napi_kind(kind: IsoBackendKind) -> BackendKind { + match kind { + IsoBackendKind::Apfs => BackendKind::Apfs, + IsoBackendKind::Btrfs => BackendKind::Btrfs, + IsoBackendKind::Zfs => BackendKind::Zfs, + IsoBackendKind::LinuxReflink => BackendKind::LinuxReflink, + IsoBackendKind::Overlayfs => BackendKind::Overlayfs, + IsoBackendKind::WindowsBlockClone => BackendKind::WindowsBlockClone, + IsoBackendKind::Projfs => BackendKind::Projfs, + IsoBackendKind::Rcopy => BackendKind::Rcopy, + } +} + +const fn to_napi_change_kind(kind: ChangeKind) -> IsoChangeKind { + match kind { + ChangeKind::Added => IsoChangeKind::Added, + ChangeKind::Modified => IsoChangeKind::Modified, + ChangeKind::Removed => IsoChangeKind::Removed, + } +} + +fn to_napi_error(err: IsoError) -> Error { + match err { + IsoError::Unavailable(msg) => Error::from_reason(format!("{ISO_UNAVAILABLE_PREFIX} {msg}")), + IsoError::Other(msg) => Error::from_reason(msg), + } +} + +fn into_iso_diff(diff: Diff) -> IsoDiff { + IsoDiff { + files: diff + .files + .into_iter() + .map(|f| IsoFileChange { + path: f.path.to_string_lossy().into_owned(), + op: to_napi_change_kind(f.op), + diff: f.diff, + }) + .collect(), + } +} + +#[allow(dead_code, reason = "compile-time check that the trait stays dyn-compatible")] +fn _assert_backend_object_safe() { + fn _is_object_safe(_: &dyn IsolationBackend) {} + let backend = pi_iso::default_backend(); + _is_object_safe(backend); + let _: FileChange; +} diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index da9a2295a..2e392bb19 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -41,8 +41,8 @@ pub use pi_ast::language; pub mod power; +pub mod iso; pub mod prof; -pub mod projfs_overlay; pub mod ps; pub mod pty; pub mod shell; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 19deb9aaa..bee84668c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,19 @@ # Changelog ## [Unreleased] +### Added + +- Added new `task.isolation.mode` values `auto`, `apfs`, `btrfs`, `zfs`, `reflink`, `overlayfs`, `projfs`, `block-clone`, and `rcopy` for native PAL-backed task isolation backends +- Added automatic PAL-backed isolation backend selection so `task.isolation.mode` uses the host's best-available backend + +### Changed + +- Changed `task.isolation.enabled=true` migration to map to `task.isolation.mode = "auto"` instead of legacy `worktree` isolation +- Updated isolation configuration UI labels and descriptions to expose new back-end names (`overlayfs`, `projfs`, etc.) and removed references to deprecated values in guidance text + +### Fixed + +- Mapped legacy `task.isolation.mode` values `worktree`, `fuse-overlay`, and `fuse-projfs` to their new equivalents during settings migration to preserve behavior with older configs ## [14.9.8] - 2026-05-12 diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 7542fab37..4c94a4355 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -2067,25 +2067,46 @@ export const SETTINGS_SCHEMA = { // Delegation "task.isolation.mode": { type: "enum", - values: ["none", "worktree", "fuse-overlay", "fuse-projfs"] as const, + values: [ + "none", + "auto", + "apfs", + "btrfs", + "zfs", + "reflink", + "overlayfs", + "projfs", + "block-clone", + "rcopy", + ] as const, default: "none", ui: { tab: "tasks", label: "Isolation Mode", description: - "Isolation mode for subagents (none, git worktree, fuse-overlayfs on Unix, or ProjFS on Windows via fuse-projfs; unsupported modes fall back to worktree)", + 'Isolation backend for subagents. "auto" lets the native PAL pick the best available backend (CoW-aware filesystems, then overlayfs/ProjFS, then a git worktree / recursive-copy fallback).', options: [ { value: "none", label: "None", description: "No isolation" }, - { value: "worktree", label: "Worktree", description: "Git worktree isolation" }, + { value: "auto", label: "Auto", description: "Let the PAL pick the best available backend" }, + { value: "apfs", label: "APFS", description: "macOS clonefile reflink (APFS)" }, + { value: "btrfs", label: "btrfs", description: "btrfs subvolume snapshot" }, + { value: "zfs", label: "ZFS", description: "ZFS snapshot + clone" }, + { value: "reflink", label: "Reflink", description: "Linux FICLONE per-file reflink" }, { - value: "fuse-overlay", - label: "Fuse Overlay", - description: "COW overlay via fuse-overlayfs (Unix only)", + value: "overlayfs", + label: "Overlayfs", + description: "Linux kernel overlay (or fuse-overlayfs fallback)", + }, + { value: "projfs", label: "ProjFS", description: "Windows Projected File System" }, + { + value: "block-clone", + label: "Block clone", + description: "Windows FSCTL_DUPLICATE_EXTENTS_TO_FILE (NTFS/ReFS)", }, { - value: "fuse-projfs", - label: "Fuse ProjFS", - description: "COW overlay via ProjFS (Windows only; falls back to worktree if unavailable)", + value: "rcopy", + label: "Recursive copy", + description: "git worktree if available, otherwise recursive copy", }, ], }, diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index f18f5bce3..9335f66f1 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -598,11 +598,28 @@ export class Settings { const isolationObj = taskObj?.isolation as Record | undefined; if (isolationObj && "enabled" in isolationObj) { if (typeof isolationObj.enabled === "boolean") { - isolationObj.mode = isolationObj.enabled ? "worktree" : "none"; + isolationObj.mode = isolationObj.enabled ? "auto" : "none"; } delete isolationObj.enabled; } + // task.isolation.mode: legacy values from before the pi-iso PAL refactor. + // `worktree` was git worktree → now lives under `rcopy`. `fuse-overlay` + // and `fuse-projfs` are now the platform-named `overlayfs` / `projfs` + // kinds; the PAL falls back internally when the chosen one isn't + // available, so we don't need the old TS-side platform guards. + if (isolationObj && typeof isolationObj.mode === "string") { + const legacy: Record = { + worktree: "rcopy", + "fuse-overlay": "overlayfs", + "fuse-projfs": "projfs", + }; + const mapped = legacy[isolationObj.mode as string]; + if (mapped !== undefined) { + isolationObj.mode = mapped; + } + } + // edit.mode: removed "atom" variant is now "hashline" const editObj = raw.edit as Record | undefined; if (editObj) { diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index b25cb7cf5..ea5ca5152 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -36,7 +36,6 @@ import { generateCommitMessage } from "../utils/commit-message-generator"; import * as git from "../utils/git"; import { discoverAgents, getAgent } from "./discovery"; import { runSubprocess } from "./executor"; -import { resolveIsolationBackendForTaskExecution } from "./isolation-backend"; import { AgentOutputManager } from "./output-manager"; import { mapWithConcurrencyLimit, Semaphore } from "./parallel"; import { renderResult, renderCall as renderTaskCall } from "./render"; @@ -50,20 +49,17 @@ import { type TaskToolDetails, } from "./types"; import { - applyBaseline, applyNestedPatches, captureBaseline, captureDeltaPatch, - cleanupFuseOverlay, - cleanupProjfsOverlay, + cleanupIsolation, cleanupTaskBranches, - cleanupWorktree, commitToBranch, - ensureFuseOverlay, - ensureProjfsOverlay, - ensureWorktree, + ensureIsolation, getRepoRoot, + type IsolationHandle, mergeTaskBranches, + parseIsolationMode, type WorktreeBaseline, } from "./worktree"; @@ -530,7 +526,7 @@ export class TaskTool implements AgentTool { content: [ { type: "text", - text: "Task isolation is disabled. Remove the isolated argument or set task.isolation.mode to 'worktree', 'fuse-overlay', or 'fuse-projfs'.", + text: "Task isolation is disabled.", }, ], details: { @@ -700,28 +696,7 @@ export class TaskTool implements AgentTool { } } - let effectiveIsolationMode = isolationMode; - let isolationBackendWarning = ""; - try { - const resolvedIsolation = await resolveIsolationBackendForTaskExecution(isolationMode, isIsolated, repoRoot); - effectiveIsolationMode = resolvedIsolation.effectiveIsolationMode; - isolationBackendWarning = resolvedIsolation.warning; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - content: [ - { - type: "text", - text: message, - }, - ], - details: { - projectAgentsDir, - results: [], - totalDurationMs: Date.now() - startTime, - }, - }; - } + const preferredIsolationBackend = parseIsolationMode(isolationMode); // Derive artifacts directory const sessionFile = this.session.getSessionFile(); @@ -891,21 +866,15 @@ export class TaskTool implements AgentTool { } const taskStart = Date.now(); - let isolationDir: string | undefined; + let isolationHandle: IsolationHandle | undefined; try { if (!repoRoot || !baseline) { throw new Error("Isolated task execution not initialized."); } const taskBaseline = structuredClone(baseline); - if (effectiveIsolationMode === "fuse-overlay") { - isolationDir = await ensureFuseOverlay(repoRoot, task.id); - } else if (effectiveIsolationMode === "fuse-projfs") { - isolationDir = await ensureProjfsOverlay(repoRoot, task.id); - } else { - isolationDir = await ensureWorktree(repoRoot, task.id); - await applyBaseline(isolationDir, taskBaseline); - } + isolationHandle = await ensureIsolation(repoRoot, task.id, preferredIsolationBackend); + const isolationDir = isolationHandle.mergedDir; const result = await runSubprocess({ cwd: this.session.cwd, @@ -1017,14 +986,8 @@ export class TaskTool implements AgentTool { error: message, }; } finally { - if (isolationDir) { - if (effectiveIsolationMode === "fuse-overlay") { - await cleanupFuseOverlay(isolationDir); - } else if (effectiveIsolationMode === "fuse-projfs") { - await cleanupProjfsOverlay(isolationDir); - } else { - await cleanupWorktree(isolationDir); - } + if (isolationHandle) { + await cleanupIsolation(isolationHandle); } } }; @@ -1256,7 +1219,6 @@ export class TaskTool implements AgentTool { }); const outputIds = results.filter(r => !r.aborted || r.output.trim()).map(r => `agent://${r.id}`); - const backendSummaryPrefix = isolationBackendWarning ? `\n\n${isolationBackendWarning}` : ""; const summary = prompt.render(taskSummaryTemplate, { successCount, totalCount: results.length, @@ -1266,7 +1228,7 @@ export class TaskTool implements AgentTool { summaries, outputIds, agentName, - mergeSummary: `${backendSummaryPrefix}${mergeSummary}`, + mergeSummary, }); // Cleanup temp directory if used diff --git a/packages/coding-agent/src/task/isolation-backend.ts b/packages/coding-agent/src/task/isolation-backend.ts deleted file mode 100644 index e27164139..000000000 --- a/packages/coding-agent/src/task/isolation-backend.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { projfsOverlayProbe } from "@oh-my-pi/pi-natives"; -import { Snowflake } from "@oh-my-pi/pi-utils"; -import { cleanupProjfsOverlay, ensureProjfsOverlay, isProjfsUnavailableError } from "./worktree"; - -export type TaskIsolationMode = "none" | "worktree" | "fuse-overlay" | "fuse-projfs"; - -export interface IsolationBackendResolution { - effectiveIsolationMode: TaskIsolationMode; - warning: string; -} - -type ProcessorEnv = Partial>; - -function isWindowsArm64HostUnderX64Emulation( - platform: NodeJS.Platform, - arch: NodeJS.Architecture, - env: ProcessorEnv, -): boolean { - if (platform !== "win32" || arch !== "x64") return false; - return ( - env.PROCESSOR_ARCHITECTURE?.toUpperCase() === "ARM64" || env.PROCESSOR_ARCHITEW6432?.toUpperCase() === "ARM64" - ); -} - -export async function resolveIsolationBackendForTaskExecution( - requestedMode: TaskIsolationMode, - isIsolated: boolean, - repoRoot: string | null, - platform: NodeJS.Platform = process.platform, - arch: NodeJS.Architecture = process.arch, - env: ProcessorEnv = process.env as ProcessorEnv, -): Promise { - let effectiveIsolationMode = requestedMode; - let warning = ""; - if (!(isIsolated && repoRoot)) { - return { effectiveIsolationMode, warning }; - } - - if (requestedMode === "fuse-overlay" && platform === "win32") { - effectiveIsolationMode = "worktree"; - warning = - 'fuse-overlay isolation is unavailable on Windows. Use task.isolation.mode = "fuse-projfs" for ProjFS. Falling back to worktree isolation.'; - return { effectiveIsolationMode, warning }; - } - - if (requestedMode === "fuse-projfs" && platform !== "win32") { - effectiveIsolationMode = "worktree"; - warning = - "fuse-projfs isolation is only available on Windows. Falling back to worktree isolation."; - return { effectiveIsolationMode, warning }; - } - - if (!(requestedMode === "fuse-projfs" && platform === "win32")) { - return { effectiveIsolationMode, warning }; - } - - if (isWindowsArm64HostUnderX64Emulation(platform, arch, env)) { - effectiveIsolationMode = "worktree"; - warning = - "ProjFS isolation is disabled on Windows ARM64 x64 emulation. Falling back to worktree isolation."; - return { effectiveIsolationMode, warning }; - } - - const probe = projfsOverlayProbe(); - if (!probe.available) { - effectiveIsolationMode = "worktree"; - const reason = probe.reason ? ` Reason: ${probe.reason}` : ""; - warning = `ProjFS is unavailable on this host. Falling back to worktree isolation.${reason}`; - return { effectiveIsolationMode, warning }; - } - - const probeIsolationId = `probe-${Snowflake.next()}`; - let probeIsolationDir: string | null = null; - try { - probeIsolationDir = await ensureProjfsOverlay(repoRoot, probeIsolationId); - } catch (err) { - if (isProjfsUnavailableError(err)) { - effectiveIsolationMode = "worktree"; - const raw = err instanceof Error ? err.message : String(err); - const reason = raw.replace(/^PROJFS_UNAVAILABLE:\s*/, ""); - const detail = reason ? ` Reason: ${reason}` : ""; - warning = `ProjFS prerequisites are unavailable for this repository. Falling back to worktree isolation.${detail}`; - } else { - const message = err instanceof Error ? err.message : String(err); - throw new Error(`ProjFS isolation initialization failed. ${message}`); - } - } finally { - if (probeIsolationDir) { - await cleanupProjfsOverlay(probeIsolationDir); - } - } - - return { effectiveIsolationMode, warning }; -} diff --git a/packages/coding-agent/src/task/worktree.ts b/packages/coding-agent/src/task/worktree.ts index ca49543c2..e295b0d01 100644 --- a/packages/coding-agent/src/task/worktree.ts +++ b/packages/coding-agent/src/task/worktree.ts @@ -2,9 +2,8 @@ import type { Dirent } from "node:fs"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { projfsOverlayStart, projfsOverlayStop } from "@oh-my-pi/pi-natives"; -import { $which, getWorktreeDir, isEnoent, logger, Snowflake } from "@oh-my-pi/pi-utils"; -import { $ } from "bun"; +import { IsoBackendKind, isoResolve, isoStart, isoStop } from "@oh-my-pi/pi-natives"; +import { getWorktreeDir, logger, Snowflake } from "@oh-my-pi/pi-utils"; import * as git from "../utils/git"; /** Baseline state for a single git repository. */ @@ -36,28 +35,12 @@ export async function getRepoRoot(cwd: string): Promise { return repoRoot; } -const PROJFS_UNAVAILABLE_PREFIX = "PROJFS_UNAVAILABLE:"; const GIT_NO_INDEX_NULL_PATH = process.platform === "win32" ? "NUL" : "/dev/null"; -export function isProjfsUnavailableError(err: unknown): boolean { - return err instanceof Error && err.message.includes(PROJFS_UNAVAILABLE_PREFIX); -} - export function getGitNoIndexNullPath(): string { return GIT_NO_INDEX_NULL_PATH; } -export async function ensureWorktree(baseCwd: string, id: string): Promise { - const repoRoot = await getRepoRoot(baseCwd); - const encodedProject = getEncodedProjectName(repoRoot); - const worktreeDir = getWorktreeDir(encodedProject, id); - await fs.mkdir(path.dirname(worktreeDir), { recursive: true }); - await git.worktree.tryRemove(repoRoot, worktreeDir); - await fs.rm(worktreeDir, { recursive: true, force: true }); - await git.worktree.add(repoRoot, worktreeDir, "HEAD", { detach: true }); - return worktreeDir; -} - /** Find nested git repositories (non-submodule) under the given root. */ async function discoverNestedRepos(repoRoot: string): Promise { // Get submodule paths so we can exclude them @@ -115,56 +98,6 @@ export async function captureBaseline(repoRoot: string): Promise { - await git.patch.applyText(worktreeDir, rb.staged, { cached: true }); - await git.patch.applyText(worktreeDir, rb.staged); - await git.patch.applyText(worktreeDir, rb.unstaged); - - for (const entry of rb.untracked) { - const source = path.join(sourceRoot, entry); - const destination = path.join(worktreeDir, entry); - try { - await fs.mkdir(path.dirname(destination), { recursive: true }); - await fs.cp(source, destination, { recursive: true }); - } catch (err) { - if (isEnoent(err)) continue; - throw err; - } - } -} - -export async function applyBaseline(worktreeDir: string, baseline: WorktreeBaseline): Promise { - await applyRepoBaseline(worktreeDir, baseline.root, baseline.root.repoRoot); - - // Restore nested repos into the worktree - for (const entry of baseline.nested) { - const nestedDir = path.join(worktreeDir, entry.relativePath); - // Copy the nested repo wholesale (it's not managed by root git) - const sourceDir = path.join(baseline.root.repoRoot, entry.relativePath); - try { - await fs.cp(sourceDir, nestedDir, { recursive: true }); - } catch (err) { - if (isEnoent(err)) continue; - throw err; - } - // Apply any uncommitted changes from the nested baseline - await applyRepoBaseline(nestedDir, entry.baseline, entry.baseline.repoRoot); - // Commit baseline state so captureRepoDeltaPatch can cleanly subtract it. - // Without this, `git add -A && git commit` by the task would include - // baseline untracked files in the diff-tree output. - if ((await git.status(nestedDir)).trim().length > 0) { - await git.stage.files(nestedDir); - await git.commit(nestedDir, "omp-baseline", { allowEmpty: true }); - // Update baseline to reflect the committed state — prevents double-apply - // in captureRepoDeltaPatch's temp-index path - entry.baseline.headCommit = (await git.head.sha(nestedDir)) ?? ""; - entry.baseline.staged = ""; - entry.baseline.unstaged = ""; - entry.baseline.untracked = []; - } - } -} - async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise { // Check if HEAD advanced (task committed changes) const currentHead = (await git.head.sha(repoDir)) ?? ""; @@ -318,119 +251,125 @@ export async function applyNestedPatches( } } -export async function cleanupWorktree(dir: string): Promise { - try { - const repository = await git.repo.resolve(dir); - const commonDir = repository?.commonDir ?? ""; - if (commonDir && path.basename(commonDir) === ".git") { - const repoRoot = path.dirname(commonDir); - await git.worktree.tryRemove(repoRoot, dir); - } - } finally { - await fs.rm(dir, { recursive: true, force: true }); +// ═══════════════════════════════════════════════════════════════════════════ +// Unified isolation lifecycle — picks the best backend via the PAL and +// returns the merged-view path together with the resolved kind. +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * User-facing isolation mode names exposed by the `task.isolation.mode` + * setting. Mapped to a backend-kind hint via {@link parseIsolationMode}; + * the PAL's `iso_resolve` then falls back through the kind order + * whenever the hint isn't available on the current host. + */ +export type TaskIsolationMode = + | "none" + | "auto" + | "apfs" + | "btrfs" + | "zfs" + | "reflink" + | "overlayfs" + | "projfs" + | "block-clone" + | "rcopy" + // Legacy values, accepted for back-compat with pre-PAL settings files. + | "worktree" + | "fuse-overlay" + | "fuse-projfs"; + +/** + * Translate a {@link TaskIsolationMode} string to an [`IsoBackendKind`] + * the PAL can act on. `"none"` returns `null` (caller skips isolation + * entirely); `"auto"` returns `undefined` (no hint — let the resolver + * pick). Anything else returns the matching kind. + */ +export function parseIsolationMode(mode: TaskIsolationMode): IsoBackendKind | undefined { + switch (mode) { + case "none": + case "auto": + return undefined; + case "apfs": + return IsoBackendKind.Apfs; + case "btrfs": + return IsoBackendKind.Btrfs; + case "zfs": + return IsoBackendKind.Zfs; + case "reflink": + return IsoBackendKind.LinuxReflink; + case "overlayfs": + case "fuse-overlay": + return IsoBackendKind.Overlayfs; + case "projfs": + case "fuse-projfs": + return IsoBackendKind.Projfs; + case "block-clone": + return IsoBackendKind.WindowsBlockClone; + case "rcopy": + case "worktree": + return IsoBackendKind.Rcopy; } } -// ═══════════════════════════════════════════════════════════════════════════ -// Fuse-overlay isolation (Unix) -// ═══════════════════════════════════════════════════════════════════════════ - -export async function ensureFuseOverlay(baseCwd: string, id: string): Promise { - if (process.platform === "win32") { - throw new Error('fuse-overlay isolation is unsupported on Windows. Use task.isolation.mode = "fuse-projfs".'); - } - - const repoRoot = await getRepoRoot(baseCwd); - const encodedProject = getEncodedProjectName(repoRoot); - const baseDir = getWorktreeDir(encodedProject, id); - const upperDir = path.join(baseDir, "upper"); - const workDir = path.join(baseDir, "work"); - const mergedDir = path.join(baseDir, "merged"); - - // Clean up any stale mount at this path (linux only) - const fusermount = $which("fusermount3") ?? $which("fusermount"); - if (fusermount) { - await $`${fusermount} -u ${mergedDir}`.quiet().nothrow(); - } - - await fs.rm(baseDir, { recursive: true, force: true }); - await fs.mkdir(upperDir, { recursive: true }); - await fs.mkdir(workDir, { recursive: true }); - await fs.mkdir(mergedDir, { recursive: true }); - - const binary = $which("fuse-overlayfs"); - if (!binary) { - await fs.rm(baseDir, { recursive: true, force: true }); - throw new Error( - "fuse-overlayfs not found. Install it (e.g. `apt install fuse-overlayfs` or `pacman -S fuse-overlayfs`) to use fuse-overlay isolation.", - ); - } - - const result = await $`${binary} -o lowerdir=${repoRoot},upperdir=${upperDir},workdir=${workDir} ${mergedDir}` - .quiet() - .nothrow(); - if (result.exitCode !== 0) { - const stderr = result.stderr.toString().trim(); - await fs.rm(baseDir, { recursive: true, force: true }); - throw new Error(`fuse-overlayfs mount failed (exit ${result.exitCode}): ${stderr}`); - } - - return mergedDir; +export interface IsolationHandle { + /** Merged view materialised by the backend; pass this to the task. */ + mergedDir: string; + /** Backend the PAL actually used. */ + backend: IsoBackendKind; + /** True when the resolver downgraded from `preferred` to `backend`. */ + fellBack: boolean; + /** Optional reason associated with `fellBack`. */ + fallbackReason: string | null; } -export async function cleanupFuseOverlay(mergedDir: string): Promise { - try { - const fusermount = $which("fusermount3") ?? $which("fusermount"); - if (fusermount) { - await $`${fusermount} -u ${mergedDir}`.quiet().nothrow(); - } - } finally { - // baseDir is the parent of the merged directory - const baseDir = path.dirname(mergedDir); - await fs.rm(baseDir, { recursive: true, force: true }); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// ProjFS isolation (Windows) -// ═══════════════════════════════════════════════════════════════════════════ - -export async function ensureProjfsOverlay(baseCwd: string, id: string): Promise { - if (process.platform !== "win32") { - throw new Error("fuse-projfs isolation is only available on Windows."); - } - +/** + * Materialise `merged` for a single task. `preferred` is a hint — when + * its prerequisites are missing the PAL silently falls back, and the + * caller learns about that through `IsolationHandle.fellBack` + + * `fallbackReason`. + */ +export async function ensureIsolation( + baseCwd: string, + id: string, + preferred?: IsoBackendKind, +): Promise { const repoRoot = await getRepoRoot(baseCwd); const encodedProject = getEncodedProjectName(repoRoot); const baseDir = getWorktreeDir(encodedProject, id); const mergedDir = path.join(baseDir, "merged"); + const resolution = isoResolve(preferred ?? null); + await fs.rm(baseDir, { recursive: true, force: true }); - await fs.mkdir(mergedDir, { recursive: true }); try { - projfsOverlayStart(repoRoot, mergedDir); - return mergedDir; + await isoStart(resolution.kind, repoRoot, mergedDir); + return { + mergedDir, + backend: resolution.kind, + fellBack: resolution.fellBack, + fallbackReason: resolution.reason ?? null, + }; } catch (err) { await fs.rm(baseDir, { recursive: true, force: true }); throw err; } } -export async function cleanupProjfsOverlay(mergedDir: string): Promise { +/** Tear down a handle returned by {@link ensureIsolation}. */ +export async function cleanupIsolation(handle: IsolationHandle): Promise { try { - if (process.platform === "win32") { - try { - projfsOverlayStop(mergedDir); - } catch (err) { - logger.warn("ProjFS overlay stop failed during cleanup", { - mergedDir, - error: err instanceof Error ? err.message : String(err), - }); - } + try { + await isoStop(handle.backend, handle.mergedDir); + } catch (err) { + logger.warn("isolation backend stop failed during cleanup", { + backend: handle.backend, + mergedDir: handle.mergedDir, + error: err instanceof Error ? err.message : String(err), + }); } } finally { // baseDir is the parent of the merged directory - const baseDir = path.dirname(mergedDir); + const baseDir = path.dirname(handle.mergedDir); await fs.rm(baseDir, { recursive: true, force: true }); } } diff --git a/packages/coding-agent/test/task/issue-949-windows-arm-projfs.test.ts b/packages/coding-agent/test/task/issue-949-windows-arm-projfs.test.ts deleted file mode 100644 index 771486974..000000000 --- a/packages/coding-agent/test/task/issue-949-windows-arm-projfs.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it, vi } from "bun:test"; -import { resolveIsolationBackendForTaskExecution } from "../../src/task/isolation-backend"; - -const projfsOverlayProbeMock = vi.fn(() => { - throw new Error("ProjFS native probe should not be called on Windows ARM64 under x64 emulation"); -}); -const projfsOverlayStartMock = vi.fn(); -const projfsOverlayStopMock = vi.fn(); - -vi.mock("@oh-my-pi/pi-natives", () => ({ - projfsOverlayProbe: projfsOverlayProbeMock, - projfsOverlayStart: projfsOverlayStartMock, - projfsOverlayStop: projfsOverlayStopMock, -})); - -describe("issue 949: Windows ARM64 avoids ProjFS native overlay under x64 emulation", () => { - it("falls back to worktree before probing ProjFS on Windows ARM64", async () => { - const result = await resolveIsolationBackendForTaskExecution("fuse-projfs", true, "C:\\repo", "win32", "x64", { - PROCESSOR_ARCHITEW6432: "ARM64", - PROCESSOR_ARCHITECTURE: "AMD64", - }); - - expect(result.effectiveIsolationMode).toBe("worktree"); - expect(result.warning).toContain("Windows ARM64"); - expect(result.warning).toContain("x64 emulation"); - expect(projfsOverlayProbeMock).not.toHaveBeenCalled(); - expect(projfsOverlayStartMock).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/coding-agent/test/task/worktree.test.ts b/packages/coding-agent/test/task/worktree.test.ts index 605ddff18..3b1615501 100644 --- a/packages/coding-agent/test/task/worktree.test.ts +++ b/packages/coding-agent/test/task/worktree.test.ts @@ -2,15 +2,27 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { getGitNoIndexNullPath, isProjfsUnavailableError, mergeTaskBranches } from "../../src/task/worktree"; +import { getGitNoIndexNullPath, mergeTaskBranches } from "../../src/task/worktree"; -const projfsOverlayStartMock = vi.fn(); -const projfsOverlayStopMock = vi.fn(); +const isoStartMock = vi.fn(); +const isoStopMock = vi.fn(); +const isoIsUnavailableErrorMock = vi.fn( + (message: string) => typeof message === "string" && message.startsWith("ISO_UNAVAILABLE:"), +); const tempDirs: string[] = []; +// Numeric mirror of the napi-generated const-enum so the production code's +// `IsoBackendKind.Overlayfs` references resolve without loading the addon. +const IsoBackendKind = { Apfs: 0, Overlayfs: 1, Projfs: 2, Rcopy: 3 } as const; + vi.mock("@oh-my-pi/pi-natives", () => ({ - projfsOverlayStart: projfsOverlayStartMock, - projfsOverlayStop: projfsOverlayStopMock, + IsoBackendKind, + isoBackend: vi.fn(() => IsoBackendKind.Rcopy), + isoDiff: vi.fn(), + isoIsUnavailableError: isoIsUnavailableErrorMock, + isoProbe: vi.fn(() => ({ available: true, reason: null, kind: IsoBackendKind.Rcopy })), + isoStart: isoStartMock, + isoStop: isoStopMock, })); async function runGit(repo: string, args: string[]): Promise { @@ -58,12 +70,6 @@ describe("worktree isolation helpers", () => { expect(getGitNoIndexNullPath()).toBe(expected); }); - it("detects ProjFS prerequisite errors by prefix", () => { - expect(isProjfsUnavailableError(new Error("PROJFS_UNAVAILABLE: missing feature"))).toBe(true); - expect(isProjfsUnavailableError(new Error("fuse-overlay mount failed"))).toBe(false); - expect(isProjfsUnavailableError("PROJFS_UNAVAILABLE: not-an-error-instance")).toBe(false); - }); - it("does not pop an unrelated pre-existing stash when the working tree is clean", async () => { const { repo } = await createGitRepo(); await fs.writeFile(path.join(repo, "preexisting.txt"), "user stash\n"); diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 95b6c5ad1..65dc0006d 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Removed `projfsOverlayProbe`, `projfsOverlayStart`, and `projfsOverlayStop` overlays APIs and `ProjfsOverlayProbeResult` type from the public natives interface + +### Added + +- Added unified isolation APIs `isoBackend`, `isoProbe`, `isoResolve`, `isoStart`, `isoStop`, `isoDiff`, and `isoIsUnavailableError` for selecting, probing, resolving, starting, stopping, and diffing isolated filesystems +- Added `IsoBackendKind`, `IsoChangeKind`, `IsoDiff`, `IsoFileChange`, `IsoProbeResult`, and `IsoResolveResult` type exports to describe isolation backend capabilities and diff outcomes + +### Changed + +- Changed `native` exports to remove the platform-specific ProjFS-only overlay surface in favor of generic isolation controls ## [14.9.5] - 2026-05-12 diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 74ac86dc8..366989f91 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -765,6 +765,108 @@ export declare enum ImageFormat { */ export declare function invalidateFsScanCache(path?: string | undefined | null): void +/** Kind enum of the backend selected by default for this build target. */ +export declare function isoBackend(): IsoBackendKind + +/** + * Isolation backend identifier. Numeric so the JS side can `switch` on + * the enum without string comparisons. + */ +export declare enum IsoBackendKind { + Apfs = 0, + Btrfs = 1, + Zfs = 2, + LinuxReflink = 3, + Overlayfs = 4, + WindowsBlockClone = 5, + Projfs = 6, + Rcopy = 7 +} + +/** How a single file changed between `lower` and `merged`. */ +export declare enum IsoChangeKind { + Added = 0, + Modified = 1, + Removed = 2 +} + +/** + * Capture the changes between `lower` and `merged`. + * + * Uses [`pi_iso::IsolationBackend::diff`]'s default implementation — + * `git diff` when `merged/.git` exists, otherwise a mtime-skipped tree + * walk. The backend selection only affects the lifecycle methods; diff + * behaviour is uniform. + */ +export declare function isoDiff(lower: string, merged: string): Promise + +export interface IsoDiff { + files: Array +} + +/** One entry in an [`IsoDiff`]. */ +export interface IsoFileChange { + /** Path relative to `merged`. */ + path: string + op: IsoChangeKind + /** + * Unified-diff text. `None` (`null` in JS) means the file is binary; + * read it directly from `merged` if you need the bytes. + */ + diff?: string +} + +/** + * True if `message` is an error message produced by [`IsoError::Unavailable`]. + * Use this to distinguish "this backend isn't installed" from a hard + * failure when handling caught errors on the JS side. + */ +export declare function isoIsUnavailableError(message: string): boolean + +/** + * Probe whether the requested backend can start on this host. Pass + * `null`/omit `kind` to probe the platform-native backend. + */ +export declare function isoProbe(kind?: IsoBackendKind | undefined | null): IsoProbeResult + +/** Probe result for a specific isolation backend. */ +export interface IsoProbeResult { + /** True when the backend's prerequisites are satisfied. */ + available: boolean + /** Human-readable explanation when `available` is false. */ + reason?: string + /** Resolved backend kind. */ + kind: IsoBackendKind +} + +/** + * Pick the best backend available right now. `preferred` is treated as + * a hint — see [`pi_iso::resolve`] for the exact priority rules. + */ +export declare function isoResolve(preferred?: IsoBackendKind | undefined | null): IsoResolveResult + +/** Outcome of [`iso_resolve`]. */ +export interface IsoResolveResult { + /** Backend that will actually be used. */ + kind: IsoBackendKind + /** + * True when the resolver fell back from `preferred` (or from the + * platform native) to a different backend. + */ + fellBack: boolean + /** Human-readable reason for the fallback, if any. */ + reason?: string +} + +/** + * Materialise `merged` as a writable view of `lower` using the requested + * backend. `kind` defaults to the native backend. + */ +export declare function isoStart(kind: IsoBackendKind | undefined | null, lower: string, merged: string): Promise + +/** Tear down a previously started backend at `merged`. */ +export declare function isoStop(kind: IsoBackendKind | undefined | null, merged: string): Promise + /** Event types from Kitty keyboard protocol (flag 2). */ export declare enum KeyEventType { /** Key press event. */ @@ -1007,32 +1109,6 @@ export interface ProcessWaitOptions { signal?: unknown } -/** Probe whether `ProjFS` overlay virtualization can be started on this system. */ -export declare function projfsOverlayProbe(): ProjfsOverlayProbeResult - -/** - * Result of probing Windows Projected File System (`ProjFS`) support for - * overlay workflows. - */ -export interface ProjfsOverlayProbeResult { - /** True when `ProjFS` APIs are available and loaded. */ - available: boolean - /** - * Human-readable reason when `available` is false (e.g. wrong OS or missing - * DLL). - */ - reason?: string -} - -/** - * Start a `ProjFS` overlay: `projection_root` shows the merged view; - * `lower_root` is the backing tree. - */ -export declare function projfsOverlayStart(lowerRoot: string, projectionRoot: string): void - -/** Stop `ProjFS` virtualization for an active `projection_root` session. */ -export declare function projfsOverlayStop(projectionRoot: string): void - /** Result of a PTY command run. */ export interface PtyRunResult { /** Exit code when the command completes. */ diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 5edea39c3..0c9e5c732 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -231,15 +231,19 @@ export const hasMatch = nativeBindings.hasMatch; export const highlightCode = nativeBindings.highlightCode; export const htmlToMarkdown = nativeBindings.htmlToMarkdown; export const invalidateFsScanCache = nativeBindings.invalidateFsScanCache; +export const isoBackend = nativeBindings.isoBackend; +export const isoDiff = nativeBindings.isoDiff; +export const isoIsUnavailableError = nativeBindings.isoIsUnavailableError; +export const isoProbe = nativeBindings.isoProbe; +export const isoResolve = nativeBindings.isoResolve; +export const isoStart = nativeBindings.isoStart; +export const isoStop = nativeBindings.isoStop; export const listWorkspace = nativeBindings.listWorkspace; export const matchesKey = nativeBindings.matchesKey; export const matchesKittySequence = nativeBindings.matchesKittySequence; export const matchesLegacySequence = nativeBindings.matchesLegacySequence; export const parseKey = nativeBindings.parseKey; export const parseKittySequence = nativeBindings.parseKittySequence; -export const projfsOverlayProbe = nativeBindings.projfsOverlayProbe; -export const projfsOverlayStart = nativeBindings.projfsOverlayStart; -export const projfsOverlayStop = nativeBindings.projfsOverlayStop; export const readImageFromClipboard = nativeBindings.readImageFromClipboard; export const sanitizeText = nativeBindings.sanitizeText; export const search = nativeBindings.search; @@ -284,6 +288,21 @@ export const ImageFormat = { WEBP: 2, GIF: 3, }; +export const IsoBackendKind = { + Apfs: 0, + Btrfs: 1, + Zfs: 2, + LinuxReflink: 3, + Overlayfs: 4, + WindowsBlockClone: 5, + Projfs: 6, + Rcopy: 7, +}; +export const IsoChangeKind = { + Added: 0, + Modified: 1, + Removed: 2, +}; export const KeyEventType = { Press: 1, Repeat: 2,