diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index e23e439e1a..82cdff6b3a 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -257,6 +257,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } bun test ./packages/coding-agent/test/session/resident-cache-win32-gate.windows.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + bun test ./packages/coding-agent/test/session/managed-lock-lease.windows.test.ts + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } bun test ./packages/coding-agent/test/sdk-session-directory.windows.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -301,13 +303,20 @@ jobs: windows-telegram-daemon-safety: name: Windows Telegram daemon safety needs: [affected-plan] - if: ${{ needs.affected-plan.outputs.relevant == 'true' && (contains(needs.affected-plan.outputs.changed_paths, 'telegram-daemon') || contains(needs.affected-plan.outputs.changed_paths, 'chat-daemon-control.ts') || contains(needs.affected-plan.outputs.changed_paths, 'packages/coding-agent/src/sdk/broker/process-incarnation.ts') || contains(needs.affected-plan.outputs.changed_paths, 'daemon-control.test.ts') || contains(needs.affected-plan.outputs.changed_paths, 'notifications-telegram-daemon.test.ts') || contains(needs.affected-plan.outputs.changed_paths, 'chat-daemon') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-natives/src/path_identity.rs') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-natives/src/ps.rs') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-shell/src/process.rs') || contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/native/index.d.ts')) }} + if: ${{ needs.affected-plan.outputs.relevant == 'true' && (contains(needs.affected-plan.outputs.changed_paths, 'telegram-daemon') || contains(needs.affected-plan.outputs.changed_paths, 'chat-daemon-control.ts') || contains(needs.affected-plan.outputs.changed_paths, 'packages/coding-agent/src/sdk/broker/process-incarnation.ts') || contains(needs.affected-plan.outputs.changed_paths, 'daemon-control.test.ts') || contains(needs.affected-plan.outputs.changed_paths, 'notifications-telegram-daemon.test.ts') || contains(needs.affected-plan.outputs.changed_paths, 'chat-daemon') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-natives/src/path_identity.rs') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-natives/src/ps.rs') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-shell/src/process.rs') || contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/native/index.d.ts') || contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/native/index.js') || contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/test/path-identity-windows.test.ts')) }} runs-on: windows-latest timeout-minutes: 60 + env: + CI_DEV_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify checked-out source head + shell: pwsh + run: | + $head = (git rev-parse HEAD).Trim() + if ($head -ne $env:CI_DEV_SOURCE_SHA) { throw "Checked-out SHA $head does not match $env:CI_DEV_SOURCE_SHA" } - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.14" @@ -345,6 +354,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } bun test ./packages/natives/test/native.test.ts --test-name-pattern 'signals only the pinned root process' if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + bun test ./packages/natives/test/path-identity-windows.test.ts + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Native addon build runs at most once per run and publishes the built `.node` # files as an artifact the runtime-dependent shards download. A content-hash @@ -705,7 +716,7 @@ jobs: CI_DEV_TELEGRAM_GUARD_RESULT: ${{ needs.telegram-daemon-generation.result }} CI_DEV_TELEGRAM_GUARD_REQUIRED: ${{ needs.affected-plan.outputs.relevant }} CI_DEV_TELEGRAM_WINDOWS_RESULT: ${{ needs.windows-telegram-daemon-safety.result }} - CI_DEV_TELEGRAM_WINDOWS_REQUIRED: ${{ contains(needs.affected-plan.outputs.changed_paths, 'telegram-daemon') || contains(needs.affected-plan.outputs.changed_paths, 'chat-daemon-control.ts') || contains(needs.affected-plan.outputs.changed_paths, 'packages/coding-agent/src/sdk/broker/process-incarnation.ts') || contains(needs.affected-plan.outputs.changed_paths, 'daemon-control.test.ts') || contains(needs.affected-plan.outputs.changed_paths, 'notifications-telegram-daemon.test.ts') || contains(needs.affected-plan.outputs.changed_paths, 'chat-daemon') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-natives/src/path_identity.rs') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-natives/src/ps.rs') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-shell/src/process.rs') || contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/native/index.d.ts') }} + CI_DEV_TELEGRAM_WINDOWS_REQUIRED: ${{ contains(needs.affected-plan.outputs.changed_paths, 'telegram-daemon') || contains(needs.affected-plan.outputs.changed_paths, 'chat-daemon-control.ts') || contains(needs.affected-plan.outputs.changed_paths, 'packages/coding-agent/src/sdk/broker/process-incarnation.ts') || contains(needs.affected-plan.outputs.changed_paths, 'daemon-control.test.ts') || contains(needs.affected-plan.outputs.changed_paths, 'notifications-telegram-daemon.test.ts') || contains(needs.affected-plan.outputs.changed_paths, 'chat-daemon') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-natives/src/path_identity.rs') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-natives/src/ps.rs') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-shell/src/process.rs') || contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/native/index.d.ts') || contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/native/index.js') || contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/test/path-identity-windows.test.ts') }} CI_DEV_DARWIN_ARM64_TAB_WORKER_SMOKE_RESULT: ${{ needs.affected-darwin-arm64-tab-worker-smoke.result }} CI_DEV_DARWIN_ARM64_TAB_WORKER_SMOKE_REQUIRED: ${{ needs.affected-plan.outputs.has_darwin_arm64_tab_worker_smoke }} run: bun scripts/ci-dev-affected.ts --write-affected-evidence @@ -752,7 +763,7 @@ jobs: CI_DEV_TELEGRAM_GUARD_RESULT: ${{ needs.telegram-daemon-generation.result }} CI_DEV_TELEGRAM_GUARD_REQUIRED: ${{ needs.affected-plan.outputs.relevant }} CI_DEV_TELEGRAM_WINDOWS_RESULT: ${{ needs.windows-telegram-daemon-safety.result }} - CI_DEV_TELEGRAM_WINDOWS_REQUIRED: ${{ contains(needs.affected-plan.outputs.changed_paths, 'telegram-daemon') || contains(needs.affected-plan.outputs.changed_paths, 'chat-daemon-control.ts') || contains(needs.affected-plan.outputs.changed_paths, 'packages/coding-agent/src/sdk/broker/process-incarnation.ts') || contains(needs.affected-plan.outputs.changed_paths, 'daemon-control.test.ts') || contains(needs.affected-plan.outputs.changed_paths, 'notifications-telegram-daemon.test.ts') || contains(needs.affected-plan.outputs.changed_paths, 'chat-daemon') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-natives/src/path_identity.rs') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-natives/src/ps.rs') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-shell/src/process.rs') || contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/native/index.d.ts') }} + CI_DEV_TELEGRAM_WINDOWS_REQUIRED: ${{ contains(needs.affected-plan.outputs.changed_paths, 'telegram-daemon') || contains(needs.affected-plan.outputs.changed_paths, 'chat-daemon-control.ts') || contains(needs.affected-plan.outputs.changed_paths, 'packages/coding-agent/src/sdk/broker/process-incarnation.ts') || contains(needs.affected-plan.outputs.changed_paths, 'daemon-control.test.ts') || contains(needs.affected-plan.outputs.changed_paths, 'notifications-telegram-daemon.test.ts') || contains(needs.affected-plan.outputs.changed_paths, 'chat-daemon') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-natives/src/path_identity.rs') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-natives/src/ps.rs') || contains(needs.affected-plan.outputs.changed_paths, 'crates/pi-shell/src/process.rs') || contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/native/index.d.ts') || contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/native/index.js') || contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/test/path-identity-windows.test.ts') }} steps: - name: Fail closed on producer and live dependency results env: diff --git a/crates/pi-natives/src/path_identity.rs b/crates/pi-natives/src/path_identity.rs index b13731c0d0..34cf848cea 100644 --- a/crates/pi-natives/src/path_identity.rs +++ b/crates/pi-natives/src/path_identity.rs @@ -167,6 +167,11 @@ struct ExactFileIdentity { pub struct NativeExactUnlinkResult { pub ok: bool, pub code: Option, + /// True only when retained directory payloads were descriptor-scrubbed and + /// every file plus containing directory namespace was fsynced before return. + pub payload_durable: Option, + /// On Windows this is returned in the caller's namespace; retained handle + /// operations continue to use the volume-GUID canonical path internally. pub detached_path: Option, pub retained_successor_path: Option, /// An internal exchange-placeholder cleanup entry retained after cleanup @@ -327,6 +332,7 @@ impl NativeExactUnlinkResult { Self { ok: true, code: None, + payload_durable: None, detached_path: None, retained_successor_path: None, retained_placeholder_path: None, @@ -338,6 +344,7 @@ impl NativeExactUnlinkResult { Self { ok: true, code: None, + payload_durable: None, detached_path: Some(path), retained_successor_path: None, retained_placeholder_path: None, @@ -349,6 +356,7 @@ impl NativeExactUnlinkResult { Self { ok: false, code: Some(code.to_owned()), + payload_durable: None, detached_path: Some(path), retained_successor_path: None, retained_placeholder_path: None, @@ -356,6 +364,54 @@ impl NativeExactUnlinkResult { } } + #[cfg(unix)] + fn detached_failure_with_durable_payload(code: &str, path: String) -> Self { + Self { + ok: false, + code: Some(code.to_owned()), + payload_durable: Some(true), + detached_path: Some(path), + retained_successor_path: None, + retained_placeholder_path: None, + retained_unknown_path: None, + } + } + + #[cfg(unix)] + fn detached_failure_with_durable_payload_and_placeholder( + code: &str, + path: String, + placeholder_path: String, + ) -> Self { + Self { + ok: false, + code: Some(code.to_owned()), + payload_durable: Some(true), + detached_path: Some(path), + retained_successor_path: None, + retained_placeholder_path: Some(placeholder_path), + retained_unknown_path: None, + } + } + + #[cfg(windows)] + fn detached_failure_with_successor_and_placeholder( + code: &str, + detached_path: String, + successor_path: String, + placeholder_path: String, + ) -> Self { + Self { + ok: false, + code: Some(code.to_owned()), + payload_durable: None, + detached_path: Some(detached_path), + retained_successor_path: Some(successor_path), + retained_placeholder_path: Some(placeholder_path), + retained_unknown_path: None, + } + } + #[cfg(unix)] fn detached_failure_with_placeholder( code: &str, @@ -365,6 +421,7 @@ impl NativeExactUnlinkResult { Self { ok: false, code: Some(code.to_owned()), + payload_durable: None, detached_path: Some(path), retained_successor_path: None, retained_placeholder_path: Some(placeholder_path), @@ -377,6 +434,7 @@ impl NativeExactUnlinkResult { Self { ok: false, code: Some(code.to_owned()), + payload_durable: None, detached_path: Some(path), retained_successor_path: None, retained_placeholder_path: None, @@ -384,11 +442,25 @@ impl NativeExactUnlinkResult { } } + #[cfg(unix)] + fn retained_successor_failure(code: &str, successor_path: String) -> Self { + Self { + ok: false, + code: Some(code.to_owned()), + payload_durable: None, + detached_path: None, + retained_successor_path: Some(successor_path), + retained_placeholder_path: None, + retained_unknown_path: None, + } + } + #[cfg(unix)] fn retained_placeholder_failure(code: &str, placeholder_path: String) -> Self { Self { ok: false, code: Some(code.to_owned()), + payload_durable: None, detached_path: None, retained_successor_path: None, retained_placeholder_path: Some(placeholder_path), @@ -401,6 +473,7 @@ impl NativeExactUnlinkResult { Self { ok: false, code: Some(code.to_owned()), + payload_durable: None, detached_path: None, retained_successor_path: None, retained_placeholder_path: None, @@ -412,6 +485,7 @@ impl NativeExactUnlinkResult { Self { ok: false, code: Some(code.to_owned()), + payload_durable: None, detached_path: None, retained_successor_path: None, retained_placeholder_path: None, @@ -798,6 +872,44 @@ pub fn exact_unlink(path: String, identity: NativeExactFileIdentity) -> NativeEx }; platform::exact_unlink(Path::new(&path), &identity) } +/// Replace a staged regular file only after validating the exact staged source +/// and deleting the exact expected destination. +/// +/// Both identities must describe regular files, not directories or detach-only +/// requests. Publication uses the retained verified source handle and a +/// no-replace rename, so source substitution is rejected and a destination +/// successor is preserved. +#[napi] +pub fn exact_replace_path( + source_path: String, + destination_path: String, + expected_source: NativeExactFileIdentity, + expected_destination: NativeExactFileIdentity, +) -> NativeExactUnlinkResult { + if source_path.contains('\0') || destination_path.contains('\0') { + return NativeExactUnlinkResult::failure("invalid_request"); + } + let Some(expected_source) = exact_file_identity(&expected_source) else { + return NativeExactUnlinkResult::failure("identity_mismatch"); + }; + let Some(expected_destination) = exact_file_identity(&expected_destination) else { + return NativeExactUnlinkResult::failure("identity_mismatch"); + }; + #[cfg(windows)] + { + platform::exact_replace_path( + Path::new(&source_path), + Path::new(&destination_path), + &expected_source, + &expected_destination, + ) + } + #[cfg(not(windows))] + { + let _ = (source_path, destination_path, expected_source, expected_destination); + NativeExactUnlinkResult::failure("unsupported_platform") + } +} /// Restore only the detached object that still has the supplied platform #[cfg_attr(clippy, doc = "")] @@ -845,11 +957,12 @@ pub fn snapshot_directory_tree(path: String) -> NativeDirectoryTreeResult { platform::snapshot_directory_tree(Path::new(&path)) } -/// Remove an already durably planned detached directory only when a fresh +/// Remove a directory tree only when a fresh descriptor-relative snapshot #[cfg_attr(clippy, doc = "")] -/// descriptor-relative snapshot exactly equals the persisted snapshot. The -/// caller-planned root remains in place while its opened descriptor is -/// authoritative throughout recursive removal. +/// exactly equals the persisted snapshot. POSIX first no-replace detaches the +/// verified root to its deterministic `.removing` sibling; the reopened +/// detached descriptor remains authoritative throughout payload scrubbing and +/// replay. #[napi] pub fn exact_remove_directory_tree( path: String, @@ -1117,14 +1230,52 @@ pub(crate) mod platform { /// pathological signal storm turning a retry loop into a hang. const EINTR_RETRY_LIMIT: u32 = 8; - /// Test-only fault injection: the next N calls into the no-replace rename - /// primitive report a synthetic EINTR before the real syscall runs, letting - /// tests exercise the restart loop without racing a real signal. + // Test-only fault injection: the next N calls into the no-replace rename + // primitive report a synthetic EINTR before the real syscall runs, letting + // tests exercise the restart loop without racing a real signal. #[cfg(test)] thread_local! { static RENAME_NO_REPLACE_EINTR_INJECT: std::cell::Cell = const { std::cell::Cell::new(0) }; } + #[cfg(test)] + thread_local! { + static ROOT_PARENT_FSYNC_FAIL_ON_CALL: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + #[cfg(test)] + pub(super) fn inject_root_parent_fsync_failure(call: u32) { + ROOT_PARENT_FSYNC_FAIL_ON_CALL.with(|target| target.set(call)); + } + + #[cfg(test)] + fn take_injected_root_parent_fsync_failure() -> bool { + ROOT_PARENT_FSYNC_FAIL_ON_CALL.with(|target| { + let current = target.get(); + if current == 0 { + return false; + } + target.set(current - 1); + current == 1 + }) + } + + #[cfg(not(test))] + const fn take_injected_root_parent_fsync_failure() -> bool { + false + } + + fn fsync_root_parent(fd: libc::c_int) -> Result<(), &'static str> { + if take_injected_root_parent_fsync_failure() { + return Err("io_error"); + } + // SAFETY: `fd` is a live retained parent directory descriptor. + if unsafe { libc::fsync(fd) } != 0 { + return Err(security_code(&std::io::Error::last_os_error())); + } + Ok(()) + } + #[cfg(test)] pub(super) fn inject_rename_no_replace_eintr(count: u32) { RENAME_NO_REPLACE_EINTR_INJECT.with(|remaining| remaining.set(count)); @@ -1163,11 +1314,24 @@ pub(crate) mod platform { static AFTER_TREE_VALIDATION_HOOK: OnceLock< Mutex, mpsc::Receiver<()>)>>, > = OnceLock::new(); - #[cfg(test)] - static AFTER_TREE_RENAME_HOOK: OnceLock, mpsc::Receiver<()>)>>> = + static BEFORE_TREE_ROOT_RENAME_HOOK: OnceLock< + Mutex, mpsc::Receiver<()>)>>, + > = OnceLock::new(); + #[cfg(test)] + static AFTER_TREE_SCRUB_HOOK: OnceLock, mpsc::Receiver<()>)>>> = OnceLock::new(); + #[cfg(test)] + static BEFORE_TREE_CHILD_RENAME_HOOK: OnceLock< + Mutex, mpsc::Receiver<()>)>>, + > = OnceLock::new(); + + #[cfg(test)] + static AFTER_TREE_FILE_LINK_CHECK_HOOK: OnceLock< + Mutex, mpsc::Receiver<()>)>>, + > = OnceLock::new(); + #[cfg(all(test, target_os = "linux"))] pub(super) fn set_after_exchange_hook(hook: Option<(mpsc::Sender<()>, mpsc::Receiver<()>)>) { *AFTER_EXCHANGE_HOOK @@ -1195,8 +1359,48 @@ pub(crate) mod platform { } #[cfg(all(test, target_os = "linux"))] - pub(super) fn set_after_tree_rename_hook(hook: Option<(mpsc::Sender<()>, mpsc::Receiver<()>)>) { - *AFTER_TREE_RENAME_HOOK + pub(super) fn set_after_tree_validation_hook( + hook: Option<(mpsc::Sender<()>, mpsc::Receiver<()>)>, + ) { + *AFTER_TREE_VALIDATION_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = hook; + } + + #[cfg(all(test, target_os = "linux"))] + pub(super) fn set_before_tree_root_rename_hook( + hook: Option<(mpsc::Sender<()>, mpsc::Receiver<()>)>, + ) { + *BEFORE_TREE_ROOT_RENAME_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = hook; + } + + #[cfg(all(test, target_os = "linux"))] + pub(super) fn set_after_tree_scrub_hook(hook: Option<(mpsc::Sender<()>, mpsc::Receiver<()>)>) { + *AFTER_TREE_SCRUB_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = hook; + } + + #[cfg(all(test, target_os = "linux"))] + pub(super) fn set_before_tree_child_rename_hook( + hook: Option<(mpsc::Sender<()>, mpsc::Receiver<()>)>, + ) { + *BEFORE_TREE_CHILD_RENAME_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = hook; + } + + #[cfg(all(test, target_os = "linux"))] + pub(super) fn set_after_tree_file_link_check_hook( + hook: Option<(mpsc::Sender<()>, mpsc::Receiver<()>)>, + ) { + *AFTER_TREE_FILE_LINK_CHECK_HOOK .get_or_init(|| Mutex::new(None)) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) = hook; @@ -1255,15 +1459,54 @@ pub(crate) mod platform { } #[cfg(test)] - fn pause_after_tree_rename_for_test() { - if let Some((entered, resume)) = AFTER_TREE_RENAME_HOOK + fn pause_before_tree_root_rename_for_test() { + if let Some((entered, resume)) = BEFORE_TREE_ROOT_RENAME_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + entered.send(()).expect("tree root rename hook receiver"); + resume.recv().expect("tree root rename hook resume"); + } + } + + #[cfg(test)] + fn pause_after_tree_scrub_for_test() { + if let Some((entered, resume)) = AFTER_TREE_SCRUB_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + entered.send(()).expect("tree scrub hook receiver"); + resume.recv().expect("tree scrub hook resume"); + } + } + + #[cfg(test)] + fn pause_before_tree_child_rename_for_test() { + if let Some((entered, resume)) = BEFORE_TREE_CHILD_RENAME_HOOK + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + entered.send(()).expect("tree child rename hook receiver"); + resume.recv().expect("tree child rename hook resume"); + } + } + + #[cfg(test)] + fn pause_after_tree_file_link_check_for_test() { + if let Some((entered, resume)) = AFTER_TREE_FILE_LINK_CHECK_HOOK .get_or_init(|| Mutex::new(None)) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .take() { - entered.send(()).expect("tree rename hook receiver"); - resume.recv().expect("tree rename hook resume"); + entered.send(()).expect("tree file mutation hook receiver"); + resume.recv().expect("tree file mutation hook resume"); } } @@ -2710,8 +2953,6 @@ pub(crate) mod platform { Err(_) => ExchangePlaceholderRemoval::RetainedMismatch(detached_name), }; } - // POSIX only unlinks by mutable name. The identity proof cannot authorize - // a later unlinkat because a same-kind replacement may win that race. ExchangePlaceholderRemoval::RetainedFailure(detached_name, "cleanup_pending") } @@ -2729,6 +2970,79 @@ pub(crate) mod platform { digest_reader(&mut file).map_err(|_| "io_error") } + fn scrub_regular_file_openat( + parent_fd: libc::c_int, + name: &CString, + identity: &ExactFileIdentity, + ) -> Result<(), &'static str> { + // SAFETY: `parent_fd` and `name` are live; flags request an exact no-follow + // regular-file descriptor. + let fd = unsafe { + libc::openat(parent_fd, name.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC | libc::O_NOFOLLOW) + }; + if fd < 0 { + return Err(security_code(&std::io::Error::last_os_error())); + } + let result = (|| { + let validate = || -> Result<(), &'static str> { + // SAFETY: zero is a valid initialized representation for `fstat` output. + let mut stat: libc::stat = unsafe { std::mem::zeroed() }; + // SAFETY: `fd` is live and `stat` is writable for the duration of the call. + if unsafe { libc::fstat(fd, &mut stat) } != 0 { + return Err(security_code(&std::io::Error::last_os_error())); + } + if stat.st_mode & libc::S_IFMT != libc::S_IFREG + || stat.st_dev as u64 != identity.dev + || stat.st_ino as u64 != identity.ino + || stat.st_size as u64 != identity.size + || stat_mtime_ns(&stat) != i128::from(identity.mtime_ns) + { + return Err("identity_mismatch"); + } + if stat.st_nlink != 1 { + return Err("hard_link_unsupported"); + } + // SAFETY: `fd` is live and seeking only resets its shared read offset before + // digesting. + if unsafe { libc::lseek(fd, 0, libc::SEEK_SET) } < 0 { + return Err(security_code(&std::io::Error::last_os_error())); + } + // SAFETY: `fd` is live; the returned descriptor is checked before ownership + // transfer. + let duplicated = unsafe { libc::dup(fd) }; + if duplicated < 0 { + return Err(security_code(&std::io::Error::last_os_error())); + } + // SAFETY: `duplicated` is a unique checked descriptor transferred to `File` + // exactly once. + let mut file = unsafe { File::from_raw_fd(duplicated) }; + if digest_reader(&mut file).ok().as_ref() != identity.sha256.as_ref() { + return Err("identity_mismatch"); + } + Ok(()) + }; + validate()?; + validate()?; + // SAFETY: `fd` is the live, twice-revalidated, single-link transcript + // descriptor. + if unsafe { libc::ftruncate(fd, 0) } != 0 { + return Err(security_code(&std::io::Error::last_os_error())); + } + // SAFETY: both descriptors remain live and are synchronized before return. + let file_synced = unsafe { libc::fsync(fd) } == 0; + // SAFETY: `parent_fd` remains live and binds the quarantine namespace. + let parent_synced = unsafe { libc::fsync(parent_fd) } == 0; + if !file_synced || !parent_synced { + return Err("durability_failed"); + } + Ok(()) + })(); + // SAFETY: this function owns `fd` and closes it exactly once after the + // operation. + unsafe { libc::close(fd) }; + result + } + pub(super) fn exact_unlink( path: &Path, identity: &ExactFileIdentity, @@ -2806,19 +3120,15 @@ pub(crate) mod platform { } parent_fd = next_fd; } - if let (Some(expected_dev), Some(expected_ino)) = (identity.parent_dev, identity.parent_ino) { - // SAFETY: libc::stat is a plain C output record; zero initialization creates a - // valid writable buffer for fstat to fill before any field is read. + if let Some((expected_dev, expected_ino)) = identity.parent_dev.zip(identity.parent_ino) { + // SAFETY: zero is valid initialized storage for `fstat` output. let mut parent_stat: libc::stat = unsafe { std::mem::zeroed() }; - // SAFETY: parent_fd is the live directory descriptor owned by this branch, and - // parent_stat points to initialized writable storage for the complete fstat - // result. + // SAFETY: `parent_fd` is the retained walked parent descriptor. if unsafe { libc::fstat(parent_fd, &mut parent_stat) } != 0 || parent_stat.st_dev as u64 != expected_dev || parent_stat.st_ino as u64 != expected_ino { - // SAFETY: the mismatch branch still owns parent_fd and returns immediately - // after closing it exactly once. + // SAFETY: this branch owns `parent_fd` exactly once. unsafe { libc::close(parent_fd) }; return NativeExactUnlinkResult::failure("parent_mismatch"); } @@ -2861,9 +3171,6 @@ pub(crate) mod platform { } if named.st_dev as u64 != identity.dev || named.st_ino as u64 != identity.ino - || identity - .nlink - .is_some_and(|nlink| named.st_nlink as u64 != nlink) || named.st_size as u64 != identity.size || stat_mtime_ns(&named) != i128::from(identity.mtime_ns) { @@ -2871,6 +3178,13 @@ pub(crate) mod platform { unsafe { libc::close(parent_fd) }; return NativeExactUnlinkResult::failure("identity_mismatch"); } + if !identity.directory + && (named.st_nlink != 1 || identity.nlink.is_some_and(|nlink| nlink != 1)) + { + // SAFETY: this branch owns the live descriptor and closes it exactly once. + unsafe { libc::close(parent_fd) }; + return NativeExactUnlinkResult::failure("hard_link_unsupported"); + } if !identity.directory && digest_openat(parent_fd, &name).ok().as_ref() != identity.sha256.as_ref() { @@ -2955,9 +3269,6 @@ pub(crate) mod platform { } == 0 && detached.st_mode & libc::S_IFMT == expected_kind && detached.st_dev as u64 == identity.dev && detached.st_ino as u64 == identity.ino - && identity - .nlink - .is_none_or(|nlink| detached.st_nlink as u64 == nlink) && detached.st_size as u64 == identity.size && stat_mtime_ns(&detached) == i128::from(identity.mtime_ns); let digest_matches = identity.directory @@ -3052,11 +3363,18 @@ pub(crate) mod platform { unsafe { libc::close(parent_fd) }; return result; } - // POSIX has no descriptor-bound unlink. Retain the proven detached object - // and exchange placeholder rather than risk unlinking a replacement. + // POSIX cannot descriptor-unlink, but it can descriptor-scrub the exact + // detached regular file. Durable zero-length retained entries are then + // reconciled as internal placeholders without preserving transcript bytes. + if let Err(code) = scrub_regular_file_openat(parent_fd, &quarantine, identity) { + // SAFETY: this error branch owns `parent_fd` and closes it exactly once. + unsafe { libc::close(parent_fd) }; + return NativeExactUnlinkResult::detached_failure(code, detached_path); + } let result = match remove_exchange_placeholder(parent_fd, &name, placeholder) { + ExchangePlaceholderRemoval::Removed => NativeExactUnlinkResult::success(), ExchangePlaceholderRemoval::RetainedFailure(retained_name, code) => { - NativeExactUnlinkResult::detached_failure_with_placeholder( + NativeExactUnlinkResult::detached_failure_with_durable_payload_and_placeholder( code, detached_path, path @@ -3067,7 +3385,25 @@ pub(crate) mod platform { .into_owned(), ) }, - _ => NativeExactUnlinkResult::detached_failure("cleanup_pending", detached_path), + ExchangePlaceholderRemoval::RetainedMismatch(retained_name) => { + NativeExactUnlinkResult::detached_failure_with_durable_payload_and_placeholder( + "cleanup_pending", + detached_path, + path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(retained_name.to_string_lossy().as_ref()) + .to_string_lossy() + .into_owned(), + ) + }, + ExchangePlaceholderRemoval::RestoredMismatch | ExchangePlaceholderRemoval::Failed => { + NativeExactUnlinkResult::detached_failure_with_durable_payload_and_placeholder( + "cleanup_pending", + detached_path, + path.to_string_lossy().into_owned(), + ) + }, }; // SAFETY: this branch owns the live descriptor and closes it exactly once. @@ -3206,6 +3542,21 @@ pub(crate) mod platform { Ok(value) => value, Err(result) => return *result, }; + if let Some((expected_parent_dev, expected_parent_ino)) = + identity.parent_dev.zip(identity.parent_ino) + { + // SAFETY: zero is valid initialized storage for fstat output. + let mut parent_stat: libc::stat = unsafe { std::mem::zeroed() }; + // SAFETY: parent_fd is the live retained parent descriptor. + if unsafe { libc::fstat(parent_fd, &mut parent_stat) } != 0 + || parent_stat.st_dev as u64 != expected_parent_dev + || parent_stat.st_ino as u64 != expected_parent_ino + { + // SAFETY: this branch owns parent_fd exactly once. + unsafe { libc::close(parent_fd) }; + return NativeExactUnlinkResult::failure("parent_mismatch"); + } + } let Some(original_name_bytes) = original_path.file_name().map(|name| name.as_bytes()) else { // SAFETY: this branch owns the live descriptor and closes it exactly once. unsafe { libc::close(parent_fd) }; @@ -3230,9 +3581,6 @@ pub(crate) mod platform { } == 0 && detached.st_mode & libc::S_IFMT == expected_kind && detached.st_dev as u64 == identity.dev && detached.st_ino as u64 == identity.ino - && identity - .nlink - .is_none_or(|nlink| detached.st_nlink as u64 == nlink) && detached.st_size as u64 == identity.size && stat_mtime_ns(&detached) == i128::from(identity.mtime_ns) && (identity.directory @@ -3242,14 +3590,48 @@ pub(crate) mod platform { unsafe { libc::close(parent_fd) }; return NativeExactUnlinkResult::failure("identity_mismatch"); } - if let Err(code) = rename_no_replace(parent_fd, parent_fd, &detached_name, &original_name) { + if !identity.directory && detached.st_nlink != 1 { // SAFETY: this branch owns the live descriptor and closes it exactly once. unsafe { libc::close(parent_fd) }; - return NativeExactUnlinkResult::failure(if code == "quarantine_collision" { - "collision" - } else { - code - }); + return NativeExactUnlinkResult::failure("hard_link_unsupported"); + } + // Revalidate the name immediately before commit; rename_no_replace remains the + // only namespace mutation and any observed substitution fails closed. + // SAFETY: zero is valid initialized storage for fstatat output. + let mut current: libc::stat = unsafe { std::mem::zeroed() }; + // SAFETY: parent_fd and detached_name remain live for this no-follow probe. + let current_matches = unsafe { + libc::fstatat(parent_fd, detached_name.as_ptr(), &mut current, libc::AT_SYMLINK_NOFOLLOW) + } == 0 && current.st_mode & libc::S_IFMT == expected_kind + && current.st_dev as u64 == identity.dev + && current.st_ino as u64 == identity.ino + && current.st_size as u64 == identity.size + && stat_mtime_ns(¤t) == i128::from(identity.mtime_ns) + && (identity.directory + || digest_openat(parent_fd, &detached_name).ok().as_ref() == identity.sha256.as_ref()); + if !current_matches { + // SAFETY: this branch owns the live parent descriptor exactly once. + unsafe { libc::close(parent_fd) }; + return NativeExactUnlinkResult::failure("identity_mismatch"); + } + let placeholder = + match create_exchange_placeholder(parent_fd, &original_name, identity.directory) { + Ok(placeholder) => placeholder, + Err(code) => { + // SAFETY: this branch owns the live descriptor and closes it exactly once. + unsafe { libc::close(parent_fd) }; + return NativeExactUnlinkResult::failure(if code == "quarantine_collision" { + "collision" + } else { + code + }); + }, + }; + if let Err(code) = rename_exchange(parent_fd, parent_fd, &detached_name, &original_name) { + let _ = remove_exchange_placeholder(parent_fd, &original_name, placeholder); + // SAFETY: this branch owns the live descriptor and closes it exactly once. + unsafe { libc::close(parent_fd) }; + return NativeExactUnlinkResult::failure(code); } // SAFETY: zero is a valid initialized representation for this output struct. let mut restored: libc::stat = unsafe { std::mem::zeroed() }; @@ -3260,9 +3642,6 @@ pub(crate) mod platform { } == 0 && restored.st_mode & libc::S_IFMT == expected_kind && restored.st_dev as u64 == identity.dev && restored.st_ino as u64 == identity.ino - && identity - .nlink - .is_none_or(|nlink| restored.st_nlink as u64 == nlink) && restored.st_size as u64 == identity.size && stat_mtime_ns(&restored) == i128::from(identity.mtime_ns) && (identity.directory @@ -3278,6 +3657,26 @@ pub(crate) mod platform { "restore_failed" }); } + match remove_exchange_placeholder(parent_fd, &detached_name, placeholder) { + ExchangePlaceholderRemoval::Removed => {}, + ExchangePlaceholderRemoval::RetainedMismatch(retained_name) + | ExchangePlaceholderRemoval::RetainedFailure(retained_name, _) => { + // SAFETY: this branch owns the live descriptor and closes it exactly once. + unsafe { libc::close(parent_fd) }; + return NativeExactUnlinkResult::retained_placeholder_failure( + "cleanup_pending", + retained_name.to_string_lossy().into_owned(), + ); + }, + ExchangePlaceholderRemoval::RestoredMismatch | ExchangePlaceholderRemoval::Failed => { + // SAFETY: this branch owns the live descriptor and closes it exactly once. + unsafe { libc::close(parent_fd) }; + return NativeExactUnlinkResult::retained_unknown_failure( + "cleanup_pending", + detached_path.to_string_lossy().into_owned(), + ); + }, + } // SAFETY: this branch owns the live descriptor and closes it exactly once. unsafe { libc::close(parent_fd) }; NativeExactUnlinkResult::success() @@ -3337,8 +3736,16 @@ pub(crate) mod platform { } fn directory_names(fd: libc::c_int) -> Result>, &'static str> { - // SAFETY: `fd` is live; this function owns the returned duplicate. - let duplicate = unsafe { libc::dup(fd) }; + let current = c"."; + // SAFETY: `fd` is live and `.` resolves the same directory with an independent + // stream offset for each validation or scrub pass. + let duplicate = unsafe { + libc::openat( + fd, + current.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; if duplicate < 0 { return Err(security_code(&std::io::Error::last_os_error())); } @@ -3403,7 +3810,7 @@ pub(crate) mod platform { match stat.st_mode & libc::S_IFMT { libc::S_IFREG => { if stat.st_nlink != 1 { - return Err("identity_mismatch"); + return Err("hard_link_unsupported"); } entries.push(entry_from_stat( child_relative, @@ -3488,41 +3895,94 @@ pub(crate) mod platform { .find(|entry| entry.relative_path == relative) } - fn detached_entry_matches( + fn digest_fd(fd: libc::c_int) -> Result<[u8; 32], &'static str> { + // SAFETY: `fd` is live; this function owns the returned duplicate. + let duplicate = unsafe { libc::dup(fd) }; + if duplicate < 0 { + return Err(security_code(&std::io::Error::last_os_error())); + } + // SAFETY: ownership of the live duplicate transfers to `File` exactly once. + let mut file = unsafe { File::from_raw_fd(duplicate) }; + digest_reader(&mut file).map_err(|_| "io_error") + } + + fn open_tree_entry( parent_fd: libc::c_int, name: &CString, expected: &NativeDirectoryTreeEntry, - ) -> Result { + allow_scrubbed: bool, + ) -> Result { + let directory = expected.kind == "directory"; + let flags = libc::O_RDONLY + | libc::O_CLOEXEC + | libc::O_NOFOLLOW + | if directory { libc::O_DIRECTORY } else { 0 }; + // SAFETY: the parent descriptor and NUL-terminated component are live. + let fd = unsafe { libc::openat(parent_fd, name.as_ptr(), flags) }; + if fd < 0 { + return Err(security_code(&std::io::Error::last_os_error())); + } // SAFETY: zero is a valid initialized representation for this output struct. let mut stat: libc::stat = unsafe { std::mem::zeroed() }; - // SAFETY: the descriptor and CString are live; the initialized output struct is - // writable. - if unsafe { libc::fstatat(parent_fd, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) } - != 0 - { + // SAFETY: `fd` is live and `stat` is writable. + if unsafe { libc::fstat(fd, &mut stat) } != 0 { + // SAFETY: this branch owns `fd` exactly once. + unsafe { libc::close(fd) }; return Err(security_code(&std::io::Error::last_os_error())); } - let kind = match stat.st_mode & libc::S_IFMT { - libc::S_IFREG => "file", - libc::S_IFDIR => "directory", - libc::S_IFLNK => return Ok(false), - _ => return Ok(false), + if !directory && stat.st_nlink != 1 { + // SAFETY: this branch owns `fd` exactly once. + unsafe { libc::close(fd) }; + return Err("hard_link_unsupported"); + } + let expected_kind = if directory { + libc::S_IFDIR + } else { + libc::S_IFREG }; - if kind != expected.kind.as_str() - || stat.st_dev as u64 != expected.dev.parse().ok().unwrap_or(u64::MAX) - || stat.st_ino as u64 != expected.ino.parse().ok().unwrap_or(u64::MAX) - || stat.st_nlink as u64 != expected.nlink.parse().ok().unwrap_or(u64::MAX) - || (kind == "file" - && (stat.st_size as u64 != expected.size.parse().ok().unwrap_or(u64::MAX) - || stat_mtime_ns(&stat).to_string() != expected.mtime_ns)) - { - return Ok(false); + let identity_matches = stat.st_mode & libc::S_IFMT == expected_kind + && stat.st_dev as u64 == expected.dev.parse().ok().unwrap_or(u64::MAX) + && stat.st_ino as u64 == expected.ino.parse().ok().unwrap_or(u64::MAX); + let content_matches = if directory { + expected.sha256.is_none() + } else { + let digest = match digest_fd(fd) { + Ok(digest) => digest, + Err(code) => { + // SAFETY: this branch owns `fd` exactly once. + unsafe { libc::close(fd) }; + return Err(code); + }, + }; + let original = stat.st_size as u64 == expected.size.parse().ok().unwrap_or(u64::MAX) + && stat_mtime_ns(&stat).to_string() == expected.mtime_ns + && expected.sha256.as_deref() == Some(hex_digest(digest).as_str()); + let scrubbed = allow_scrubbed && stat.st_size == 0 && digest == sha256(b""); + original || scrubbed + }; + if !identity_matches || !content_matches { + // SAFETY: this branch owns `fd` exactly once. + unsafe { libc::close(fd) }; + return Err("identity_mismatch"); } - if kind == "file" { - let digest = hex_digest(digest_openat(parent_fd, name).map_err(|_| "io_error")?); - return Ok(expected.sha256.as_deref() == Some(digest.as_str())); + Ok(fd) + } + + fn open_tree_entry_unverified( + parent_fd: libc::c_int, + name: &CString, + directory: bool, + ) -> Result { + let flags = libc::O_RDONLY + | libc::O_CLOEXEC + | libc::O_NOFOLLOW + | if directory { libc::O_DIRECTORY } else { 0 }; + // SAFETY: the parent descriptor and NUL-terminated component are live. + let fd = unsafe { libc::openat(parent_fd, name.as_ptr(), flags) }; + if fd < 0 { + return Err(security_code(&std::io::Error::last_os_error())); } - Ok(expected.sha256.is_none()) + Ok(fd) } /// Each child quarantine name is a bounded deterministic digest of the @@ -3557,6 +4017,179 @@ pub(crate) mod platform { matching.next().is_none().then_some(entry) } + fn scrub_tree_fd( + fd: libc::c_int, + relative: &str, + expected: &[NativeDirectoryTreeEntry], + ) -> Result<(), &'static str> { + let mut names = directory_names(fd)?; + names.sort(); + for name_bytes in names { + let physical = CString::new(name_bytes.clone()).map_err(|_| "io_error")?; + let direct_name = std::str::from_utf8(&name_bytes).ok(); + let direct_relative = direct_name.map(|name| { + if relative.is_empty() { + name.to_owned() + } else { + format!("{relative}/{name}") + } + }); + let expected_direct = direct_relative + .as_deref() + .and_then(|candidate| expected_tree_entry(expected, candidate)); + let expected_quarantined = + expected_quarantined_tree_entry(expected, relative, &name_bytes); + let (expected_child, already_quarantined) = match (expected_direct, expected_quarantined) { + (Some(entry), None) => (entry, false), + (None, Some(entry)) => (entry, true), + _ => return Err("identity_mismatch"), + }; + let child = open_tree_entry(fd, &physical, expected_child, true)?; + let retained_name = if already_quarantined { + tree_quarantine_name(expected_child) + } else { + physical.clone() + }; + #[cfg(test)] + if !already_quarantined { + pause_before_tree_child_rename_for_test(); + } + // Reopen the current retained name and compare it to the authorized + // descriptor before recursive or writable access. Children stay under + // their direct names inside the already-detached root; no mutable child + // pathname is renamed or unlinked by this scrubber. + let retained = match open_tree_entry_unverified( + fd, + &retained_name, + expected_child.kind == "directory", + ) { + Ok(retained) => retained, + Err(code) => { + // SAFETY: this branch owns `child` exactly once. + unsafe { libc::close(child) }; + return Err(code); + }, + }; + // SAFETY: zero is a valid initialized representation for these output structs. + let mut child_stat: libc::stat = unsafe { std::mem::zeroed() }; + // SAFETY: zero is a valid initialized representation for this output struct. + let mut retained_stat: libc::stat = unsafe { std::mem::zeroed() }; + // SAFETY: both descriptors are live and both output structs are writable. + let same_object = unsafe { libc::fstat(child, &mut child_stat) } == 0 + && unsafe { libc::fstat(retained, &mut retained_stat) } == 0 + && child_stat.st_dev == retained_stat.st_dev + && child_stat.st_ino == retained_stat.st_ino; + // SAFETY: this branch owns `retained` exactly once. + unsafe { libc::close(retained) }; + if !same_object { + // SAFETY: this branch owns `child` exactly once. + unsafe { libc::close(child) }; + return Err("identity_mismatch"); + } + let result = if expected_child.kind == "directory" { + scrub_tree_fd(child, &expected_child.relative_path, expected) + } else if child_stat.st_size == 0 + && digest_fd(child).is_ok_and(|digest| digest == sha256(b"")) + { + // SAFETY: `child` is a live descriptor authorized by the tree snapshot. + if unsafe { libc::fsync(child) } != 0 { + Err(security_code(&std::io::Error::last_os_error())) + } else { + Ok(()) + } + } else { + // Reopen writable, then revalidate identity and link count immediately + // before any permission or payload mutation. A hard link created after + // snapshot/open must preserve every alias unchanged. + // SAFETY: `fd` is a live directory descriptor and `retained_name` is a + // NUL-terminated child name retained beneath it. + let writable = unsafe { + libc::openat( + fd, + retained_name.as_ptr(), + libc::O_RDWR | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if writable < 0 { + Err(security_code(&std::io::Error::last_os_error())) + } else { + // SAFETY: zero is a valid initialized representation for this output struct. + let mut writable_stat: libc::stat = unsafe { std::mem::zeroed() }; + // SAFETY: `writable` is live and `writable_stat` is writable. + let writable_matches = unsafe { libc::fstat(writable, &mut writable_stat) } == 0 + && writable_stat.st_dev == child_stat.st_dev + && writable_stat.st_ino == child_stat.st_ino; + let outcome = if !writable_matches { + Err("identity_mismatch") + } else if writable_stat.st_nlink != 1 { + Err("hard_link_unsupported") + } else { + // SAFETY: zero is a valid initialized representation for this output struct. + let mut truncate_stat: libc::stat = unsafe { std::mem::zeroed() }; + // SAFETY: `writable` is live and `truncate_stat` is writable. + let truncate_matches = unsafe { libc::fstat(writable, &mut truncate_stat) } == 0 + && truncate_stat.st_dev == child_stat.st_dev + && truncate_stat.st_ino == child_stat.st_ino; + if !truncate_matches { + Err("identity_mismatch") + } else if truncate_stat.st_nlink != 1 { + Err("hard_link_unsupported") + } else { + #[cfg(test)] + pause_after_tree_file_link_check_for_test(); + // Recheck after the final test/race seam immediately before mutation. + // SAFETY: zero is a valid initialized representation for this output struct. + let mut commit_stat: libc::stat = unsafe { std::mem::zeroed() }; + // SAFETY: `writable` is live and `commit_stat` is writable. + let commit_matches = unsafe { libc::fstat(writable, &mut commit_stat) } == 0 + && commit_stat.st_dev == child_stat.st_dev + && commit_stat.st_ino == child_stat.st_ino + && commit_stat.st_size as u64 + == expected_child.size.parse().ok().unwrap_or(u64::MAX) + && stat_mtime_ns(&commit_stat) + == expected_child.mtime_ns.parse().ok().unwrap_or(i128::MIN) + && digest_fd(writable).ok().is_some_and(|digest| { + expected_child + .sha256 + .as_deref() + .is_some_and(|expected| hex_digest(digest) == expected) + }); + if !commit_matches { + Err("identity_mismatch") + } else if commit_stat.st_nlink != 1 { + Err("hard_link_unsupported") + } else { + // SAFETY: `writable` is the live, revalidated, single-link file descriptor. + let truncate_result = unsafe { libc::ftruncate(writable, 0) }; + if truncate_result != 0 { + Err(security_code(&std::io::Error::last_os_error())) + } else { + // SAFETY: `writable` remains live after successful truncation. + if unsafe { libc::fsync(writable) } != 0 { + Err(security_code(&std::io::Error::last_os_error())) + } else { + Ok(()) + } + } + } + } + }; + // SAFETY: this branch owns `writable` exactly once. + unsafe { libc::close(writable) }; + outcome + } + }; + // SAFETY: this branch owns `child` exactly once. + unsafe { libc::close(child) }; + result?; + } + // SAFETY: `fd` is a live directory descriptor. + if unsafe { libc::fsync(fd) } != 0 { + return Err(security_code(&std::io::Error::last_os_error())); + } + Ok(()) + } + /// Validate the retained tree before atomically detaching its root. Every /// entry still present must map uniquely to its durable logical identity, /// including deterministic names retained by older attempts. @@ -3583,17 +4216,19 @@ pub(crate) mod platform { .and_then(|candidate| expected_tree_entry(expected, candidate)); let expected_quarantined = expected_quarantined_tree_entry(expected, relative, &name_bytes); - let (logical_bytes, expected_child) = match (expected_direct, expected_quarantined) { - (Some(entry), None) => (name_bytes.clone(), entry), - (None, Some(entry)) => ( - entry.relative_path.rsplit_once('/').map_or_else( - || entry.relative_path.as_bytes().to_vec(), - |(_, name)| name.as_bytes().to_vec(), + let (logical_bytes, expected_child, _quarantined) = + match (expected_direct, expected_quarantined) { + (Some(entry), None) => (name_bytes.clone(), entry, false), + (None, Some(entry)) => ( + entry.relative_path.rsplit_once('/').map_or_else( + || entry.relative_path.as_bytes().to_vec(), + |(_, name)| name.as_bytes().to_vec(), + ), + entry, + true, ), - entry, - ), - _ => return Err("identity_mismatch"), - }; + _ => return Err("identity_mismatch"), + }; let logical_name = std::str::from_utf8(&logical_bytes).map_err(|_| "not_utf8")?; let child_relative = if relative.is_empty() { logical_name.to_owned() @@ -3602,28 +4237,18 @@ pub(crate) mod platform { }; if !seen.insert(child_relative.clone()) || expected_tree_entry(expected, &child_relative) != Some(expected_child) - || !detached_entry_matches(fd, &physical, expected_child)? { return Err("identity_mismatch"); } - if expected_child.kind == "directory" { - // SAFETY: the live descriptor, where used, and NUL-terminated path remain - // valid. - let child = unsafe { - libc::openat( - fd, - physical.as_ptr(), - libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, - ) - }; - if child < 0 { - return Err(security_code(&std::io::Error::last_os_error())); - } - let result = validate_tree_fd(child, &child_relative, expected); - // SAFETY: this branch owns the live descriptor and closes it exactly once. - unsafe { libc::close(child) }; - result?; - } + let child = open_tree_entry(fd, &physical, expected_child, true)?; + let result = if expected_child.kind == "directory" { + validate_tree_fd(child, &child_relative, expected) + } else { + Ok(()) + }; + // SAFETY: this branch owns `child` exactly once. + unsafe { libc::close(child) }; + result?; } Ok(()) } @@ -3640,17 +4265,14 @@ pub(crate) mod platform { Err(result) => return *result, }; if let Some((expected_dev, expected_ino)) = expected_parent { - // SAFETY: libc::stat is a plain C output record; zero initialization creates - // writable storage for fstat before any field is read. + // SAFETY: zero is valid initialized storage for `fstat` output. let mut parent_stat: libc::stat = unsafe { std::mem::zeroed() }; - // SAFETY: parent is the live directory descriptor owned by this branch, and - // parent_stat is valid writable output storage. + // SAFETY: `parent` is the retained no-follow parent descriptor. if unsafe { libc::fstat(parent, &mut parent_stat) } != 0 || parent_stat.st_dev as u64 != expected_dev || parent_stat.st_ino as u64 != expected_ino { - // SAFETY: this mismatch branch owns parent and returns immediately after - // closing it exactly once. + // SAFETY: this branch owns `parent` exactly once. unsafe { libc::close(parent) }; return NativeExactUnlinkResult::failure("parent_mismatch"); } @@ -3738,15 +4360,49 @@ pub(crate) mod platform { } #[cfg(test)] pause_after_tree_validation_for_test(); + if !already_final { + // Reopen the current source name after the race seam. A successor cannot + // become the detached cleanup target merely because it occupies the same path. + // SAFETY: `parent` is live and `root_name` is a NUL-terminated direct child. + let current_fd = unsafe { + libc::openat( + parent, + root_name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if current_fd < 0 { + // SAFETY: this branch owns the live descriptors and closes each exactly once. + unsafe { + libc::close(fd); + libc::close(parent); + } + return NativeExactUnlinkResult::detached_failure("identity_mismatch", planned_path); + } + // SAFETY: zero is a valid initialized representation for this output struct. + let mut current_root: libc::stat = unsafe { std::mem::zeroed() }; + // SAFETY: `current_fd` is live and `current_root` is writable. + let current_valid = unsafe { libc::fstat(current_fd, &mut current_root) } == 0 + && current_root.st_dev == root.st_dev + && current_root.st_ino == root.st_ino; + // SAFETY: this branch owns `current_fd` exactly once. + unsafe { libc::close(current_fd) }; + if !current_valid { + // SAFETY: this branch owns the live descriptors and closes each exactly once. + unsafe { + libc::close(fd); + libc::close(parent); + } + return NativeExactUnlinkResult::detached_failure("identity_mismatch", planned_path); + } + } let detached_retained_path = if already_final { retained_path } else { + #[cfg(test)] + pause_before_tree_root_rename_for_test(); match rename_no_replace(parent, parent, root_name, &final_name) { - Ok(()) => { - #[cfg(test)] - pause_after_tree_rename_for_test(); - final_path - }, + Ok(()) => final_path, Err(code) => { // SAFETY: this branch owns the live descriptors and closes each exactly once. unsafe { @@ -3757,14 +4413,23 @@ pub(crate) mod platform { }, } }; - // The pre-rename descriptor cannot authorize the detached name. Reopen and - // revalidate the no-replace retained root before reporting it as replayable. + if let Err(code) = fsync_root_parent(parent) { + // SAFETY: this branch owns the live descriptors and closes each exactly once. + unsafe { + libc::close(fd); + libc::close(parent); + } + return NativeExactUnlinkResult::detached_failure(code, detached_retained_path); + } let detached_name = if already_final { root_name } else { &final_name }; - // SAFETY: the parent descriptor and detached component are live. + // Reopen and revalidate the detached retained name after the race seam. + // A substituted root fails before any recursive or writable mutation. + // SAFETY: `parent` is live and `detached_name` is the NUL-terminated retained + // tree name validated above. let detached_fd = unsafe { libc::openat( parent, @@ -3772,43 +4437,140 @@ pub(crate) mod platform { libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, ) }; - let detached_valid = if detached_fd < 0 { - Err("cleanup_pending") - } else { - // SAFETY: zero is a valid initialized representation for libc::stat. - - let mut detached_root: libc::stat = unsafe { std::mem::zeroed() }; - // SAFETY: detached_fd is live and detached_root is writable. - let result = if unsafe { libc::fstat(detached_fd, &mut detached_root) } != 0 - || detached_root.st_dev as u64 != expected.root_dev.parse().ok().unwrap_or(u64::MAX) - || detached_root.st_ino as u64 != expected.root_ino.parse().ok().unwrap_or(u64::MAX) - { - Err("identity_mismatch") + if detached_fd < 0 { + // SAFETY: this branch owns the original root descriptor exactly once. + unsafe { libc::close(fd) }; + if !already_final { + let (code, successor_path) = + match rename_no_replace(parent, parent, detached_name, root_name) { + Ok(()) => ( + if fsync_root_parent(parent).is_ok() { + "identity_mismatch" + } else { + "io_error" + }, + planned_path, + ), + Err(_) => ("identity_mismatch", detached_retained_path), + }; + // SAFETY: this branch owns the live parent descriptor exactly once. + unsafe { libc::close(parent) }; + return NativeExactUnlinkResult::retained_successor_failure(code, successor_path); + } + // SAFETY: this branch owns the live parent descriptor exactly once. + unsafe { libc::close(parent) }; + return NativeExactUnlinkResult::detached_failure( + "cleanup_pending", + detached_retained_path, + ); + } + // SAFETY: zero is a valid initialized representation for this output struct. + let mut detached_root: libc::stat = unsafe { std::mem::zeroed() }; + // SAFETY: `detached_fd` is live and `detached_root` is writable. + let detached_valid = unsafe { libc::fstat(detached_fd, &mut detached_root) } == 0 + && detached_root.st_dev as u64 == expected.root_dev.parse().ok().unwrap_or(u64::MAX) + && detached_root.st_ino as u64 == expected.root_ino.parse().ok().unwrap_or(u64::MAX); + if !detached_valid { + // SAFETY: this branch owns the retained root descriptors exactly once. + unsafe { + libc::close(detached_fd); + libc::close(fd); + } + if !already_final { + let (code, successor_path) = + match rename_no_replace(parent, parent, detached_name, root_name) { + Ok(()) => ( + if fsync_root_parent(parent).is_ok() { + "identity_mismatch" + } else { + "io_error" + }, + planned_path, + ), + Err(_) => ("identity_mismatch", detached_retained_path), + }; + // SAFETY: this branch owns the live parent descriptor exactly once. + unsafe { libc::close(parent) }; + return NativeExactUnlinkResult::retained_successor_failure(code, successor_path); + } + // SAFETY: this branch owns the live parent descriptor exactly once. + unsafe { libc::close(parent) }; + return NativeExactUnlinkResult::detached_failure( + "identity_mismatch", + detached_retained_path, + ); + } + if let Err(code) = validate_tree_fd(detached_fd, "", &expected.entries) + .and_then(|()| scrub_tree_fd(detached_fd, "", &expected.entries)) + .and_then(|()| validate_tree_fd(detached_fd, "", &expected.entries)) + { + // SAFETY: this branch owns the live descriptors and closes each exactly once. + unsafe { + libc::close(detached_fd); + libc::close(fd); + libc::close(parent); + } + return NativeExactUnlinkResult::detached_failure(code, detached_retained_path); + } + #[cfg(test)] + pause_after_tree_scrub_for_test(); + // Rebind the durable receipt to the retained namespace after payload scrub. + // SAFETY: zero is a valid initialized representation for this output struct. + let mut retained_namespace: libc::stat = unsafe { std::mem::zeroed() }; + // SAFETY: `parent` is live, `detached_name` is NUL-terminated, and the output + // is writable. + let retained_status = unsafe { + libc::fstatat( + parent, + detached_name.as_ptr(), + &mut retained_namespace, + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + let retained_matches = retained_status == 0 + && retained_namespace.st_mode & libc::S_IFMT == libc::S_IFDIR + && retained_namespace.st_dev as u64 == expected.root_dev.parse().ok().unwrap_or(u64::MAX) + && retained_namespace.st_ino as u64 == expected.root_ino.parse().ok().unwrap_or(u64::MAX); + if !retained_matches { + // SAFETY: this branch owns the live descriptors and closes each exactly once. + unsafe { + libc::close(detached_fd); + libc::close(fd); + libc::close(parent); + } + return if retained_status == 0 { + NativeExactUnlinkResult::retained_successor_failure( + "identity_mismatch", + detached_retained_path, + ) } else { - validate_tree_fd(detached_fd, "", &expected.entries) + NativeExactUnlinkResult::detached_failure("identity_mismatch", detached_retained_path) }; - // SAFETY: this branch owns the detached root descriptor exactly once. - unsafe { libc::close(detached_fd) }; - result - }; - if let Err(code) = detached_valid { + } + if let Err(code) = fsync_root_parent(parent) { // SAFETY: this branch owns the live descriptors and closes each exactly once. unsafe { + libc::close(detached_fd); libc::close(fd); libc::close(parent); } return NativeExactUnlinkResult::detached_failure(code, detached_retained_path); } - // POSIX cannot bind final unlink to the verified root descriptor. The - // no-replace detached root preserves the entire validated snapshot for - // deterministic replay instead of exchanging any child or root with a - // mutable placeholder. + // POSIX cannot bind namespace unlink to a verified descriptor. The fallback + // therefore keeps the caller-authorized retained namespace and destroys every + // authorized file payload only after direct-name descriptor revalidation. + // Replays accept the same identities in original or scrubbed form; publisher + // successors are never renamed, unlinked, or truncated. // SAFETY: this branch owns the live descriptors and closes each exactly once. unsafe { + libc::close(detached_fd); libc::close(fd); libc::close(parent); } - NativeExactUnlinkResult::detached_failure("cleanup_pending", detached_retained_path) + NativeExactUnlinkResult::detached_failure_with_durable_payload( + "cleanup_pending", + detached_retained_path, + ) } } @@ -3825,8 +4587,8 @@ mod platform { use sha2::{Digest, Sha256}; use windows_sys::Win32::{ Foundation::{ - CloseHandle, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, GENERIC_ALL, GetLastError, - HANDLE, INVALID_HANDLE_VALUE, LocalFree, + CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, ERROR_FILE_NOT_FOUND, + ERROR_PATH_NOT_FOUND, GENERIC_ALL, GetLastError, HANDLE, INVALID_HANDLE_VALUE, LocalFree, }, Security::{ ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_REVISION, ACL_SIZE_INFORMATION, @@ -3855,9 +4617,19 @@ mod platform { NativeOwnerOnlySecurityResult, sha256, }; + type UvGetOsfhandle = unsafe extern "C" fn(fd: i32) -> isize; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetModuleHandleW(module_name: *const u16) -> *mut c_void; + fn GetProcAddress(module: *mut c_void, procedure_name: *const u8) -> *mut c_void; + } + const SECURITY_OWNER_DACL: u32 = OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION; const SECURITY_OWNER_DACL_PROTECTED: u32 = SECURITY_OWNER_DACL | PROTECTED_DACL_SECURITY_INFORMATION; + const SECURITY_DACL_PROTECTED: u32 = + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION; const FILE_RENAME_INFORMATION_CLASS: i32 = 10; @@ -4228,12 +5000,11 @@ mod platform { ) } - fn open_exact_with_parent( + fn open_exact_with_share( path: &Path, kind: &str, desired_access: u32, - expected_parent: Option<(u64, u64)>, - final_share_access: Option, + final_share_access: u32, ) -> Result { if !matches!(kind, "directory" | "file") { return Err(NativeOwnerOnlySecurityResult::failure("io_error")); @@ -4266,39 +5037,20 @@ mod platform { for (index, name) in names.iter().enumerate() { let final_component = index + 1 == names.len(); let parent = *ancestors.last().expect("volume root retained"); - if final_component { - if let Some((expected_dev, expected_ino)) = expected_parent { - let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; - if unsafe { GetFileInformationByHandle(parent, &mut information) } == 0 - || !expected_handle_identity_matches(&information, expected_dev, expected_ino) - { - close_retained(&mut ancestors); - return Err(NativeOwnerOnlySecurityResult::failure("parent_mismatch")); - } - } - } - let component_access = if final_component { - desired_access | FILE_READ_ATTRIBUTES - } else { - FILE_READ_ATTRIBUTES | FILE_TRAVERSE - }; - let component_directory = if final_component { - kind == "directory" - } else { - true - }; - let opened = if final_component { + let handle = match if final_component { open_relative_with_share( parent, name, - component_access, - component_directory, - final_share_access.unwrap_or(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), + desired_access | FILE_READ_ATTRIBUTES, + kind == "directory", + final_share_access, ) } else { - open_relative(parent, name, component_access, component_directory) - }; - let handle = match opened { + // This retained directory becomes RootDirectory for the next + // descriptor-relative NtCreateFile, which requires traversal + // authority as well as attribute inspection. + open_relative(parent, name, FILE_READ_ATTRIBUTES | FILE_TRAVERSE, true) + } { Ok(handle) => handle, Err(code) => { close_retained(&mut ancestors); @@ -4344,7 +5096,12 @@ mod platform { kind: &str, desired_access: u32, ) -> Result { - open_exact_with_parent(path, kind, desired_access, None, None) + open_exact_with_share( + path, + kind, + desired_access, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + ) } fn open_directory_exact(path: &Path) -> Result { @@ -4386,27 +5143,32 @@ mod platform { let mtime_ns = i128::from(filetime) * 100 - 11_644_473_600_000_000_000i128; u64::from(information.dwVolumeSerialNumber) == identity.dev && ino == identity.ino - && identity - .nlink - .is_none_or(|nlink| u64::from(information.nNumberOfLinks) == nlink) && size == identity.size && mtime_ns == i128::from(identity.mtime_ns) } - fn handles_same_object(left: HANDLE, right: HANDLE) -> bool { + fn handles_same_object_checked(left: HANDLE, right: HANDLE) -> Result { let mut left_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; let mut right_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; - (unsafe { GetFileInformationByHandle(left, &mut left_information) }) != 0 - && (unsafe { GetFileInformationByHandle(right, &mut right_information) }) != 0 - && left_information.dwVolumeSerialNumber == right_information.dwVolumeSerialNumber + if unsafe { GetFileInformationByHandle(left, &mut left_information) } == 0 + || unsafe { GetFileInformationByHandle(right, &mut right_information) } == 0 + { + return Err(last_error_code()); + } + Ok(left_information.dwVolumeSerialNumber == right_information.dwVolumeSerialNumber && left_information.nFileIndexHigh == right_information.nFileIndexHigh - && left_information.nFileIndexLow == right_information.nFileIndexLow + && left_information.nFileIndexLow == right_information.nFileIndexLow) } - fn rename_handle_no_replace( + fn handles_same_object(left: HANDLE, right: HANDLE) -> bool { + handles_same_object_checked(left, right).unwrap_or(false) + } + + fn rename_handle( handle: HANDLE, parent_handle: HANDLE, name: &[u16], + replace_if_exists: bool, ) -> Result<(), &'static str> { let name_bytes = name.len().checked_mul(size_of::()).ok_or("io_error")?; let file_name_offset = std::mem::offset_of!(HandleRenameInformation, file_name); @@ -4429,7 +5191,7 @@ mod platform { // computed from the field offset rather than from the one-element flexible // array member, so the copy never creates an out-of-bounds array reference. unsafe { - (*rename).replace_if_exists = 0; + (*rename).replace_if_exists = u8::from(replace_if_exists); (*rename).root_directory = parent_handle; (*rename).file_name_length = u32::try_from(name_bytes).map_err(|_| "io_error")?; let file_name = storage @@ -4466,28 +5228,22 @@ mod platform { parent_handle: HANDLE, source_name: &std::ffi::OsStr, quarantine_name: &str, + detached_path: String, identity: &ExactFileIdentity, ) -> NativeExactUnlinkResult { - let detached_parent = match final_path(parent_handle) { - Ok(path) => path, - Err(code) => return NativeExactUnlinkResult::failure(code), - }; let name_wide: Vec = quarantine_name.encode_utf16().collect(); let original_name_wide: Vec = source_name.encode_wide().collect(); - let result = match rename_handle_no_replace(handle, parent_handle, &name_wide) { + let result = match rename_handle(handle, parent_handle, &name_wide, false) { Ok(()) => { - let detached_path = Path::new(&detached_parent) - .join(quarantine_name) - .to_string_lossy() - .into_owned(); let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; let matches = unsafe { GetFileInformationByHandle(handle, &mut information) } != 0 && handle_identity_matches(&information, identity) && (identity.directory - || digest_handle(handle).ok().as_ref() == identity.sha256.as_ref()); + || (information.nNumberOfLinks == 1 + && digest_handle(handle).ok().as_ref() == identity.sha256.as_ref())); if matches { NativeExactUnlinkResult::detached(detached_path) - } else if rename_handle_no_replace(handle, parent_handle, &original_name_wide).is_ok() { + } else if rename_handle(handle, parent_handle, &original_name_wide, false).is_ok() { NativeExactUnlinkResult::failure("identity_mismatch") } else { NativeExactUnlinkResult::detached_failure("restore_failed", detached_path) @@ -4546,11 +5302,26 @@ mod platform { Err("io_error") } } - - pub(super) fn rename_path_no_replace( + pub(super) fn exact_replace_path( source_path: &Path, destination_path: &Path, + expected_source: &ExactFileIdentity, + expected_destination: &ExactFileIdentity, ) -> NativeExactUnlinkResult { + if expected_source.directory + || expected_source.detach_only + || expected_destination.directory + || expected_destination.detach_only + { + return NativeExactUnlinkResult::failure("invalid_request"); + } + if expected_source.parent_dev.is_none() + || expected_source.parent_ino.is_none() + || expected_destination.parent_dev.is_none() + || expected_destination.parent_ino.is_none() + { + return NativeExactUnlinkResult::failure("parent_mismatch"); + } let source_path = match lexical_absolute_path(source_path) { Ok(path) => path, Err(code) => return NativeExactUnlinkResult::failure(code), @@ -4559,35 +5330,230 @@ mod platform { Ok(path) => path, Err(code) => return NativeExactUnlinkResult::failure(code), }; - let source_kind = match std::fs::symlink_metadata(&source_path) { - Ok(metadata) if metadata.file_type().is_dir() => "directory", - Ok(_) => "file", - Err(error) - if error.raw_os_error() == Some(ERROR_FILE_NOT_FOUND as i32) - || error.raw_os_error() == Some(ERROR_PATH_NOT_FOUND as i32) => - { - return NativeExactUnlinkResult::failure("not_found"); - }, - Err(_) => return NativeExactUnlinkResult::failure("io_error"), - }; - let source = match open_exact(&source_path, source_kind, FILE_READ_ATTRIBUTES | 0x0001_0000) { + if source_path.parent() != destination_path.parent() { + return NativeExactUnlinkResult::failure("parent_mismatch"); + } + let source = match open_exact_with_share( + &source_path, + "file", + FILE_READ_ATTRIBUTES | FILE_READ_DATA | 0x0001_0000, + FILE_SHARE_READ, + ) { Ok(handle) => handle, Err(result) => { return NativeExactUnlinkResult::failure(result.code.as_deref().unwrap_or("io_error")); }, }; - let Some(destination_parent_path) = destination_path.parent() else { + let mut source_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(source.target, &mut source_information) } == 0 + || source_information.dwFileAttributes + & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) + != 0 || !handle_identity_matches(&source_information, expected_source) + || digest_handle(source.target).ok().as_ref() != expected_source.sha256.as_ref() + { + return NativeExactUnlinkResult::failure("identity_mismatch"); + } + if source_information.nNumberOfLinks != 1 { + return NativeExactUnlinkResult::failure("hard_link_unsupported"); + } + let Some(parent_handle) = source.parent() else { return NativeExactUnlinkResult::failure("io_error"); }; + if let Some((expected_parent_dev, expected_parent_ino)) = + expected_source.parent_dev.zip(expected_source.parent_ino) + { + let mut parent_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(parent_handle, &mut parent_information) } == 0 + || u64::from(parent_information.dwVolumeSerialNumber) != expected_parent_dev + || ((u64::from(parent_information.nFileIndexHigh) << 32) + | u64::from(parent_information.nFileIndexLow)) + != expected_parent_ino + { + return NativeExactUnlinkResult::failure("parent_mismatch"); + } + } + if expected_source.parent_dev != expected_destination.parent_dev + || expected_source.parent_ino != expected_destination.parent_ino + { + return NativeExactUnlinkResult::failure("parent_mismatch"); + } let Some(destination_name) = destination_path.file_name() else { return NativeExactUnlinkResult::failure("io_error"); }; - let destination_parent = match open_directory_exact(destination_parent_path) { + // The destination is opened relative to the source's retained no-follow parent; + // no destination pathname is reopened after this point. + let destination_handle = match open_relative_with_share( + parent_handle, + destination_name, + FILE_READ_ATTRIBUTES | 0x0001_0000 | FILE_WRITE_ATTRIBUTES | FILE_READ_DATA, + false, + FILE_SHARE_READ | FILE_SHARE_DELETE, + ) { + Ok(handle) => handle, + Err(code) => return NativeExactUnlinkResult::failure(code), + }; + let destination = HeldExact { target: destination_handle, ancestors: Vec::new() }; + + let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(destination.target, &mut information) } == 0 { + return NativeExactUnlinkResult::failure(last_error_code()); + } + if information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return NativeExactUnlinkResult::failure("reparse_point"); + } + if information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0 + || !handle_identity_matches(&information, expected_destination) + || digest_handle(destination.target).ok().as_ref() != expected_destination.sha256.as_ref() + { + return NativeExactUnlinkResult::failure("identity_mismatch"); + } + if information.nNumberOfLinks != 1 { + return NativeExactUnlinkResult::failure("hard_link_unsupported"); + } + let mut revalidated: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(destination.target, &mut revalidated) } == 0 + || !handle_identity_matches(&revalidated, expected_destination) + { + return NativeExactUnlinkResult::failure("identity_mismatch"); + } + if revalidated.nNumberOfLinks != 1 { + return NativeExactUnlinkResult::failure("hard_link_unsupported"); + } + match handles_same_object_checked(source.target, destination.target) { + Ok(true) => return NativeExactUnlinkResult::failure("identity_mismatch"), + Ok(false) => {}, + Err(code) => return NativeExactUnlinkResult::failure(code), + } + let mut source_revalidated: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(source.target, &mut source_revalidated) } == 0 + || !handle_identity_matches(&source_revalidated, expected_source) + || digest_handle(source.target).ok().as_ref() != expected_source.sha256.as_ref() + { + return NativeExactUnlinkResult::failure("identity_mismatch"); + } + if source_revalidated.nNumberOfLinks != 1 { + return NativeExactUnlinkResult::failure("hard_link_unsupported"); + } + let retained_name_string = + format!(".gjc-exact-replace-source-{:x}-{:x}", expected_source.dev, expected_source.ino); + let retained_path = source_path.with_file_name(&retained_name_string); + let retained_name: Vec = retained_name_string.encode_utf16().collect(); + if let Err(code) = rename_handle(source.target, parent_handle, &retained_name, false) { + return NativeExactUnlinkResult::failure(code); + } + let retained_path_string = retained_path.to_string_lossy().into_owned(); + let mut retained_source: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(source.target, &mut retained_source) } == 0 + || !handle_identity_matches(&retained_source, expected_source) + || digest_handle(source.target).ok().as_ref() != expected_source.sha256.as_ref() + { + return NativeExactUnlinkResult::detached_failure( + "identity_mismatch", + retained_path_string, + ); + } + if retained_source.nNumberOfLinks != 1 { + return NativeExactUnlinkResult::detached_failure( + "hard_link_unsupported", + retained_path_string, + ); + } + let mut destination_revalidated: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(destination.target, &mut destination_revalidated) } + == 0 || !handle_identity_matches(&destination_revalidated, expected_destination) + || digest_handle(destination.target).ok().as_ref() != expected_destination.sha256.as_ref() + { + return NativeExactUnlinkResult::detached_failure( + "identity_mismatch", + retained_path_string, + ); + } + if destination_revalidated.nNumberOfLinks != 1 { + return NativeExactUnlinkResult::detached_failure( + "hard_link_unsupported", + retained_path_string, + ); + } + let destination_name: Vec = destination_name.encode_wide().collect(); + let predecessor_name_string = format!( + ".gjc-exact-replace-destination-{:x}-{:x}", + expected_destination.dev, expected_destination.ino + ); + let predecessor_path = destination_path.with_file_name(&predecessor_name_string); + let predecessor_name: Vec = predecessor_name_string.encode_utf16().collect(); + if let Err(code) = rename_handle(destination.target, parent_handle, &predecessor_name, false) + { + return NativeExactUnlinkResult::detached_failure(code, retained_path_string); + } + let predecessor_path_string = predecessor_path.to_string_lossy().into_owned(); + match rename_handle(source.target, parent_handle, &destination_name, false) { + Ok(()) => match delete_handle(destination.target) { + Ok(()) => NativeExactUnlinkResult::success(), + Err(code) => NativeExactUnlinkResult::detached_failure_with_successor_and_placeholder( + code, + predecessor_path_string.clone(), + destination_path.to_string_lossy().into_owned(), + predecessor_path_string, + ), + }, + Err(code) => { + let restored_destination = + rename_handle(destination.target, parent_handle, &destination_name, false).is_ok(); + if restored_destination { + NativeExactUnlinkResult::detached_failure(code, retained_path_string) + } else { + NativeExactUnlinkResult::detached_failure_with_successor_and_placeholder( + code, + retained_path_string, + destination_path.to_string_lossy().into_owned(), + predecessor_path_string, + ) + } + }, + } + } + + pub(super) fn rename_path_no_replace( + source_path: &Path, + destination_path: &Path, + ) -> NativeExactUnlinkResult { + let source_path = match lexical_absolute_path(source_path) { + Ok(path) => path, + Err(code) => return NativeExactUnlinkResult::failure(code), + }; + let destination_path = match lexical_absolute_path(destination_path) { + Ok(path) => path, + Err(code) => return NativeExactUnlinkResult::failure(code), + }; + let source_kind = match std::fs::symlink_metadata(&source_path) { + Ok(metadata) if metadata.file_type().is_dir() => "directory", + Ok(_) => "file", + Err(error) + if error.raw_os_error() == Some(ERROR_FILE_NOT_FOUND as i32) + || error.raw_os_error() == Some(ERROR_PATH_NOT_FOUND as i32) => + { + return NativeExactUnlinkResult::failure("not_found"); + }, + Err(_) => return NativeExactUnlinkResult::failure("io_error"), + }; + let source = match open_exact(&source_path, source_kind, FILE_READ_ATTRIBUTES | 0x0001_0000) { + Ok(handle) => handle, + Err(result) => { + return NativeExactUnlinkResult::failure(result.code.as_deref().unwrap_or("io_error")); + }, + }; + let Some(destination_parent_path) = destination_path.parent() else { + return NativeExactUnlinkResult::failure("io_error"); + }; + let Some(destination_name) = destination_path.file_name() else { + return NativeExactUnlinkResult::failure("io_error"); + }; + let destination_parent = match open_directory_exact(destination_parent_path) { Ok(handle) => handle, Err(code) => return NativeExactUnlinkResult::failure(&code), }; let destination_name: Vec = destination_name.encode_wide().collect(); - match rename_handle_no_replace(source.target, destination_parent.target, &destination_name) { + match rename_handle(source.target, destination_parent.target, &destination_name, false) { Ok(()) => NativeExactUnlinkResult::success(), Err(code) => NativeExactUnlinkResult::failure(code), } @@ -4614,22 +5580,17 @@ mod platform { } else { FILE_READ_DATA }; - let handle = match open_exact_with_parent( - path, - kind, - desired_access, - identity.parent_dev.zip(identity.parent_ino), - if identity.directory || identity.detach_only { - None - } else { - Some(FILE_SHARE_READ) - }, - ) { + let handle = match if identity.directory { + open_exact(path, kind, desired_access) + } else { + open_exact_with_share(path, kind, desired_access, FILE_SHARE_READ) + } { Ok(handle) => handle, Err(result) => { return NativeExactUnlinkResult { ok: false, code: result.code, + payload_durable: None, detached_path: None, retained_successor_path: None, retained_placeholder_path: None, @@ -4644,6 +5605,9 @@ mod platform { if !handle_identity_matches(&information, identity) { return NativeExactUnlinkResult::failure("identity_mismatch"); } + if !identity.directory && information.nNumberOfLinks != 1 { + return NativeExactUnlinkResult::failure("hard_link_unsupported"); + } if !identity.directory && digest_handle(handle.target).ok().as_ref() != identity.sha256.as_ref() { @@ -4656,14 +5620,35 @@ mod platform { let Some(parent_handle) = handle.parent() else { return NativeExactUnlinkResult::failure("io_error"); }; + if let Some((expected_parent_dev, expected_parent_ino)) = + identity.parent_dev.zip(identity.parent_ino) + { + let mut parent_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(parent_handle, &mut parent_information) } == 0 + || u64::from(parent_information.dwVolumeSerialNumber) != expected_parent_dev + || ((u64::from(parent_information.nFileIndexHigh) << 32) + | u64::from(parent_information.nFileIndexLow)) + != expected_parent_ino + { + return NativeExactUnlinkResult::failure("parent_mismatch"); + } + } let Some(original_name) = path.file_name() else { return NativeExactUnlinkResult::failure("io_error"); }; + let Some(parent_path) = path.parent() else { + return NativeExactUnlinkResult::failure("io_error"); + }; + let detached_path = parent_path + .join(quarantine_name) + .to_string_lossy() + .into_owned(); return detach_directory( handle.target, parent_handle, original_name, quarantine_name, + detached_path, identity, ); } @@ -4683,22 +5668,24 @@ mod platform { } else { "file" }; - let handle = match open_exact( - detached_path, - kind, - FILE_READ_ATTRIBUTES - | 0x0001_0000 - | if identity.directory { - 0 - } else { - FILE_READ_DATA - }, - ) { + let desired_access = FILE_READ_ATTRIBUTES + | 0x0001_0000 + | if identity.directory { + 0 + } else { + FILE_READ_DATA + }; + let handle = match if identity.directory { + open_exact(detached_path, kind, desired_access) + } else { + open_exact_with_share(detached_path, kind, desired_access, FILE_SHARE_READ) + } { Ok(handle) => handle, Err(result) => { return NativeExactUnlinkResult { ok: false, code: result.code, + payload_durable: None, detached_path: None, retained_successor_path: None, retained_placeholder_path: None, @@ -4716,6 +5703,12 @@ mod platform { { return NativeExactUnlinkResult::failure("identity_mismatch"); } + if !identity.directory && information.nNumberOfLinks != 1 { + return NativeExactUnlinkResult::failure("hard_link_unsupported"); + } + if identity.parent_dev.is_none() || identity.parent_ino.is_none() { + return NativeExactUnlinkResult::failure("parent_mismatch"); + } let Some(source_name) = detached_path.file_name() else { return NativeExactUnlinkResult::failure("io_error"); }; @@ -4735,11 +5728,25 @@ mod platform { if !handles_same_object(detached_parent_handle, original_parent.target) { return NativeExactUnlinkResult::failure("parent_mismatch"); } + if let Some((expected_parent_dev, expected_parent_ino)) = + identity.parent_dev.zip(identity.parent_ino) + { + let mut parent_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(original_parent.target, &mut parent_information) } + == 0 || u64::from(parent_information.dwVolumeSerialNumber) != expected_parent_dev + || ((u64::from(parent_information.nFileIndexHigh) << 32) + | u64::from(parent_information.nFileIndexLow)) + != expected_parent_ino + { + return NativeExactUnlinkResult::failure("parent_mismatch"); + } + } let result = detach_directory( handle.target, original_parent.target, source_name, quarantine_name, + original_path.to_string_lossy().into_owned(), identity, ); match result { @@ -5097,40 +6104,87 @@ mod platform { } } - pub(super) fn apply_owner_only_path_security( - path: &Path, + fn set_owner_only_acl( + handle: HANDLE, kind: &str, + sid: &[u8], + repair_owner: bool, ) -> NativeOwnerOnlySecurityResult { - let handle = match open_exact(path, kind, WRITE_OWNER | WRITE_DAC | READ_CONTROL) { - Ok(handle) => handle, - Err(result) => return result, - }; - let sid = match current_user_sid() { - Ok(sid) => sid, - Err(()) => return NativeOwnerOnlySecurityResult::failure("acl_unavailable"), - }; - let dacl = match owner_only_dacl(&sid, kind) { + let dacl = match owner_only_dacl(sid, kind) { Ok(dacl) => dacl, Err(()) => return NativeOwnerOnlySecurityResult::failure("acl_apply_failed"), }; - // SAFETY: the retained handle identifies the opened object; `sid` and aligned - // `dacl` contain validated, live Windows security structures for this - // synchronous call. let status = unsafe { SetSecurityInfo( - handle.target, + handle, SE_FILE_OBJECT, - SECURITY_OWNER_DACL_PROTECTED, - sid.as_ptr().cast_mut().cast(), + if repair_owner { + SECURITY_OWNER_DACL_PROTECTED + } else { + SECURITY_DACL_PROTECTED + }, + if repair_owner { + sid.as_ptr().cast_mut().cast() + } else { + null_mut() + }, null_mut(), dacl.as_ptr().cast(), null_mut(), ) }; - if status != 0 { - return NativeOwnerOnlySecurityResult::failure("acl_apply_failed"); + if status == 0 { + NativeOwnerOnlySecurityResult::success() + } else { + NativeOwnerOnlySecurityResult::failure("acl_apply_failed") + } + } + pub(super) fn apply_owner_only_path_security( + path: &Path, + kind: &str, + ) -> NativeOwnerOnlySecurityResult { + let mut handle = match open_exact(path, kind, WRITE_DAC | READ_CONTROL) { + Ok(handle) => handle, + Err(result) => return result, + }; + let sid = match current_user_sid() { + Ok(sid) => sid, + Err(()) => return NativeOwnerOnlySecurityResult::failure("acl_unavailable"), + }; + let repair_owner = match inspect_owner_only_acl(handle.target, kind, &sid) { + Ok(OwnerOnlyAclState::Clean) => false, + Ok(OwnerOnlyAclState::OwnerMismatch) => true, + Ok(OwnerOnlyAclState::RepairableMismatch) => false, + Ok(OwnerOnlyAclState::UnsafeMismatch) => { + return NativeOwnerOnlySecurityResult::failure("acl_verify_failed"); + }, + Err(code) => return NativeOwnerOnlySecurityResult::failure(code), + }; + if repair_owner { + let owner_handle = match open_exact(path, kind, WRITE_OWNER | WRITE_DAC | READ_CONTROL) { + Ok(handle) => handle, + Err(result) => return result, + }; + match same_file_identity(handle.target, owner_handle.target) { + Ok(true) => handle = owner_handle, + Ok(false) => return NativeOwnerOnlySecurityResult::failure("identity_mismatch"), + Err(result) => return result, + } + } + let applied = set_owner_only_acl(handle.target, kind, &sid, repair_owner); + if !applied.ok { + return applied; + } + let verified = verify_owner_only_handle(handle.target, kind); + let reopened = match open_exact(path, kind, READ_CONTROL) { + Ok(handle) => handle, + Err(result) => return result, + }; + match same_file_identity(handle.target, reopened.target) { + Ok(true) => verified, + Ok(false) => NativeOwnerOnlySecurityResult::failure("identity_mismatch"), + Err(result) => result, } - verify_owner_only_path_security(path, kind) } pub(super) fn verify_owner_only_path_security( @@ -5170,6 +6224,17 @@ mod platform { if !expected_handle_identity_matches(&final_information, expected_dev, expected_ino) { return NativeOwnerOnlySecurityResult::failure("identity_mismatch"); } + let reopened = match open_exact(path, kind, READ_CONTROL) { + Ok(handle) => handle, + Err(result) => return result, + }; + let mut rebound_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(reopened.target, &mut rebound_information) } == 0 { + return NativeOwnerOnlySecurityResult::failure(last_error_code()); + } + if !expected_handle_identity_matches(&rebound_information, expected_dev, expected_ino) { + return NativeOwnerOnlySecurityResult::failure("identity_mismatch"); + } verified } @@ -5189,11 +6254,10 @@ mod platform { expected_dev: u64, expected_ino: u64, ) -> NativeOwnerOnlySecurityResult { - let handle = match open_exact(path, kind, WRITE_DAC | READ_CONTROL) { + let mut handle = match open_exact(path, kind, WRITE_DAC | READ_CONTROL) { Ok(handle) => handle, Err(result) => return result, }; - // SAFETY: zero is a valid initialized representation for this output struct. let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; if unsafe { GetFileInformationByHandle(handle.target, &mut information) } == 0 { return NativeOwnerOnlySecurityResult::failure(last_error_code()); @@ -5205,38 +6269,36 @@ mod platform { Ok(sid) => sid, Err(()) => return NativeOwnerOnlySecurityResult::failure("acl_unavailable"), }; - match inspect_owner_only_acl(handle.target, kind, &sid) { - Ok(OwnerOnlyAclState::Clean) => return NativeOwnerOnlySecurityResult::success(), - Ok(OwnerOnlyAclState::OwnerMismatch) => { - return NativeOwnerOnlySecurityResult::failure("owner_mismatch"); - }, + let (requires_apply, repair_owner) = match inspect_owner_only_acl(handle.target, kind, &sid) { + Ok(OwnerOnlyAclState::Clean) => (false, false), + Ok(OwnerOnlyAclState::OwnerMismatch) => (true, true), + Ok(OwnerOnlyAclState::RepairableMismatch) => (true, false), Ok(OwnerOnlyAclState::UnsafeMismatch) => { return NativeOwnerOnlySecurityResult::failure("acl_verify_failed"); }, - Ok(OwnerOnlyAclState::RepairableMismatch) => {}, Err(code) => return NativeOwnerOnlySecurityResult::failure(code), - } - let dacl = match owner_only_dacl(&sid, kind) { - Ok(dacl) => dacl, - Err(()) => return NativeOwnerOnlySecurityResult::failure("acl_apply_failed"), - }; - // SAFETY: the retained handle identifies the prechecked object; `dacl` contains - // a validated, live Windows security structure for this synchronous call. - let status = unsafe { - SetSecurityInfo( - handle.target, - SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, - null_mut(), - null_mut(), - dacl.as_ptr().cast(), - null_mut(), - ) }; - if status != 0 { - return NativeOwnerOnlySecurityResult::failure("acl_apply_failed"); + if repair_owner { + let owner_handle = match open_exact(path, kind, WRITE_OWNER | WRITE_DAC | READ_CONTROL) { + Ok(handle) => handle, + Err(result) => return result, + }; + let mut owner_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(owner_handle.target, &mut owner_information) } == 0 + { + return NativeOwnerOnlySecurityResult::failure(last_error_code()); + } + if !expected_handle_identity_matches(&owner_information, expected_dev, expected_ino) { + return NativeOwnerOnlySecurityResult::failure("identity_mismatch"); + } + handle = owner_handle; + } + if requires_apply { + let applied = set_owner_only_acl(handle.target, kind, &sid, repair_owner); + if !applied.ok { + return applied; + } } - // SAFETY: zero is a valid initialized representation for this output struct. let mut final_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; if unsafe { GetFileInformationByHandle(handle.target, &mut final_information) } == 0 { return NativeOwnerOnlySecurityResult::failure(last_error_code()); @@ -5244,55 +6306,196 @@ mod platform { if !expected_handle_identity_matches(&final_information, expected_dev, expected_ino) { return NativeOwnerOnlySecurityResult::failure("identity_mismatch"); } - match inspect_owner_only_acl(handle.target, kind, &sid) { - Ok(OwnerOnlyAclState::Clean) => NativeOwnerOnlySecurityResult::success(), - Ok(OwnerOnlyAclState::OwnerMismatch) => { - NativeOwnerOnlySecurityResult::failure("owner_mismatch") - }, - Ok(OwnerOnlyAclState::RepairableMismatch | OwnerOnlyAclState::UnsafeMismatch) => { - NativeOwnerOnlySecurityResult::failure("acl_verify_failed") - }, - Err(code) => NativeOwnerOnlySecurityResult::failure(code), + let verified = verify_owner_only_handle(handle.target, kind); + let reopened = match open_exact(path, kind, READ_CONTROL) { + Ok(handle) => handle, + Err(result) => return result, + }; + let mut rebound_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(reopened.target, &mut rebound_information) } == 0 { + return NativeOwnerOnlySecurityResult::failure(last_error_code()); } + if !expected_handle_identity_matches(&rebound_information, expected_dev, expected_ino) { + return NativeOwnerOnlySecurityResult::failure("identity_mismatch"); + } + verified } - pub(super) fn apply_owner_only_fd_security( - _: &Path, - _: &str, - _: i32, - ) -> NativeOwnerOnlySecurityResult { - NativeOwnerOnlySecurityResult::failure("acl_unavailable") - } - - pub(super) fn verify_owner_only_fd_security( - _: &Path, - _: &str, - _: i32, - ) -> NativeOwnerOnlySecurityResult { - NativeOwnerOnlySecurityResult::failure("acl_unavailable") + fn uv_osfhandle(caller_fd: i32) -> Option { + let module = unsafe { GetModuleHandleW(null()) }; + if module.is_null() { + return None; + } + let procedure = unsafe { GetProcAddress(module, b"uv_get_osfhandle\0".as_ptr()) }; + if procedure.is_null() { + return None; + } + // SAFETY: `uv_get_osfhandle` is libuv's C ABI descriptor conversion exported + // by Node-compatible hosts. Its descriptor table belongs to the host that + // supplied `caller_fd`, unlike this addon's CRT table. + let conversion: UvGetOsfhandle = unsafe { std::mem::transmute(procedure) }; + Some(unsafe { conversion(caller_fd) }) } - #[cfg(test)] - mod tests { - use super::{FILE_ALL_ACCESS, FILE_READ_DATA, GENERIC_ALL, owner_only_ace_mask_is_safe}; - #[test] - fn owner_only_ace_mask_accepts_legacy_and_current_full_access_masks() { - assert!(owner_only_ace_mask_is_safe(GENERIC_ALL)); - assert!(owner_only_ace_mask_is_safe(FILE_ALL_ACCESS)); + fn retained_caller_handle(caller_fd: i32) -> Result { + if caller_fd < 0 { + return Err(NativeOwnerOnlySecurityResult::failure("identity_unavailable")); } - - #[test] - fn owner_only_ace_mask_rejects_partial_and_combined_masks() { - assert!(!owner_only_ace_mask_is_safe(FILE_ALL_ACCESS & !FILE_READ_DATA)); - assert!(!owner_only_ace_mask_is_safe(GENERIC_ALL | FILE_READ_DATA)); + let Some(raw_handle) = uv_osfhandle(caller_fd) else { + return Err(NativeOwnerOnlySecurityResult::failure("identity_unavailable")); + }; + if raw_handle == -1 { + return Err(NativeOwnerOnlySecurityResult::failure("identity_unavailable")); } - } - fn hex_digest(digest: [u8; 32]) -> String { - digest.iter().map(|byte| format!("{byte:02x}")).collect() + let handle = raw_handle as HANDLE; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return Err(NativeOwnerOnlySecurityResult::failure("identity_unavailable")); + } + let process = unsafe { GetCurrentProcess() }; + let mut retained = INVALID_HANDLE_VALUE; + if unsafe { + DuplicateHandle(process, handle, process, &mut retained, 0, 0, DUPLICATE_SAME_ACCESS) + } == 0 + { + return Err(NativeOwnerOnlySecurityResult::failure("identity_unavailable")); + } + Ok(HeldExact { target: retained, ancestors: Vec::new() }) } - fn directory_names(handle: HANDLE) -> Result, &'static str> { - let mut names = Vec::new(); + fn same_file_identity( + left: HANDLE, + right: HANDLE, + ) -> Result { + let mut left_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + let mut right_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(left, &mut left_information) } == 0 + || unsafe { GetFileInformationByHandle(right, &mut right_information) } == 0 + { + return Err(NativeOwnerOnlySecurityResult::failure("identity_unavailable")); + } + Ok(left_information.dwVolumeSerialNumber == right_information.dwVolumeSerialNumber + && left_information.nFileIndexHigh == right_information.nFileIndexHigh + && left_information.nFileIndexLow == right_information.nFileIndexLow) + } + + fn checked_caller_handle( + path: &Path, + kind: &str, + caller_fd: i32, + desired_access: u32, + ) -> Result<(HeldExact, HeldExact), NativeOwnerOnlySecurityResult> { + let caller = retained_caller_handle(caller_fd)?; + let path_handle = open_exact(path, kind, desired_access)?; + if !same_file_identity(path_handle.target, caller.target)? { + return Err(NativeOwnerOnlySecurityResult::failure("identity_mismatch")); + } + Ok((path_handle, caller)) + } + + pub(super) fn apply_owner_only_fd_security( + path: &Path, + kind: &str, + caller_fd: i32, + ) -> NativeOwnerOnlySecurityResult { + let (mut path_handle, caller) = + match checked_caller_handle(path, kind, caller_fd, READ_CONTROL | WRITE_DAC) { + Ok(handles) => handles, + Err(result) => return result, + }; + let sid = match current_user_sid() { + Ok(sid) => sid, + Err(()) => return NativeOwnerOnlySecurityResult::failure("acl_unavailable"), + }; + let (requires_apply, repair_owner) = + match inspect_owner_only_acl(path_handle.target, kind, &sid) { + Ok(OwnerOnlyAclState::Clean) => (false, false), + Ok(OwnerOnlyAclState::OwnerMismatch) => (true, true), + Ok(OwnerOnlyAclState::RepairableMismatch) => (true, false), + Ok(OwnerOnlyAclState::UnsafeMismatch) => { + return NativeOwnerOnlySecurityResult::failure("acl_verify_failed"); + }, + Err(code) => return NativeOwnerOnlySecurityResult::failure(code), + }; + if repair_owner { + let owner_handle = match open_exact(path, kind, READ_CONTROL | WRITE_DAC | WRITE_OWNER) { + Ok(handle) => handle, + Err(result) => return result, + }; + match same_file_identity(owner_handle.target, caller.target) { + Ok(true) => path_handle = owner_handle, + Ok(false) => return NativeOwnerOnlySecurityResult::failure("identity_mismatch"), + Err(result) => return result, + } + } + if requires_apply { + let applied = set_owner_only_acl(path_handle.target, kind, &sid, repair_owner); + if !applied.ok { + return applied; + } + } + match same_file_identity(path_handle.target, caller.target) { + Ok(true) => {}, + Ok(false) => return NativeOwnerOnlySecurityResult::failure("identity_mismatch"), + Err(result) => return result, + } + let reopened = match open_exact(path, kind, READ_CONTROL) { + Ok(handle) => handle, + Err(result) => return result, + }; + match same_file_identity(reopened.target, caller.target) { + Ok(true) => verify_owner_only_handle(path_handle.target, kind), + Ok(false) => NativeOwnerOnlySecurityResult::failure("identity_mismatch"), + Err(result) => result, + } + } + + pub(super) fn verify_owner_only_fd_security( + path: &Path, + kind: &str, + caller_fd: i32, + ) -> NativeOwnerOnlySecurityResult { + let (path_handle, caller) = match checked_caller_handle(path, kind, caller_fd, READ_CONTROL) { + Ok(handles) => handles, + Err(result) => return result, + }; + let verified = verify_owner_only_handle(path_handle.target, kind); + match same_file_identity(path_handle.target, caller.target) { + Ok(true) => {}, + Ok(false) => return NativeOwnerOnlySecurityResult::failure("identity_mismatch"), + Err(result) => return result, + } + let reopened = match open_exact(path, kind, READ_CONTROL) { + Ok(handle) => handle, + Err(result) => return result, + }; + match same_file_identity(reopened.target, caller.target) { + Ok(true) => verified, + Ok(false) => NativeOwnerOnlySecurityResult::failure("identity_mismatch"), + Err(result) => result, + } + } + #[cfg(test)] + mod tests { + use super::{FILE_ALL_ACCESS, FILE_READ_DATA, GENERIC_ALL, owner_only_ace_mask_is_safe}; + + #[test] + fn owner_only_ace_mask_accepts_legacy_and_current_full_access_masks() { + assert!(owner_only_ace_mask_is_safe(GENERIC_ALL)); + assert!(owner_only_ace_mask_is_safe(FILE_ALL_ACCESS)); + } + + #[test] + fn owner_only_ace_mask_rejects_partial_and_combined_masks() { + assert!(!owner_only_ace_mask_is_safe(FILE_ALL_ACCESS & !FILE_READ_DATA)); + assert!(!owner_only_ace_mask_is_safe(GENERIC_ALL | FILE_READ_DATA)); + } + } + fn hex_digest(digest: [u8; 32]) -> String { + digest.iter().map(|byte| format!("{byte:02x}")).collect() + } + + fn directory_names(handle: HANDLE) -> Result, &'static str> { + let mut names = Vec::new(); let mut restart_scan = 1u8; loop { let mut buffer = vec![0u8; 64 * 1024]; @@ -5406,6 +6609,9 @@ mod platform { if (kind == "directory") != is_directory { return Err("unsupported_entry"); } + if !is_directory && information.nNumberOfLinks != 1 { + return Err("hard_link_unsupported"); + } let ino = (u64::from(information.nFileIndexHigh) << 32) | u64::from(information.nFileIndexLow); let size = (u64::from(information.nFileSizeHigh) << 32) | u64::from(information.nFileSizeLow); @@ -5417,7 +6623,7 @@ mod platform { kind: kind.to_owned(), dev: u64::from(information.dwVolumeSerialNumber).to_string(), ino: ino.to_string(), - nlink: u64::from(information.nNumberOfLinks).to_string(), + nlink: information.nNumberOfLinks.to_string(), size: size.to_string(), mtime_ns: mtime_ns.to_string(), ctime_ns: mtime_ns.to_string(), @@ -5463,14 +6669,8 @@ mod platform { } else if attributes & FILE_ATTRIBUTE_DIRECTORY != 0 { snapshot_tree_handle(child, &child_relative, entries) } else { - match tree_entry(child, child_relative, kind) { - Ok(entry) if entry.nlink == "1" => { - entries.push(entry); - Ok(()) - }, - Ok(_) => Err("identity_mismatch"), - Err(code) => Err(code), - } + entries.push(tree_entry(child, child_relative, kind)?); + Ok(()) }; unsafe { CloseHandle(child) }; result?; @@ -5495,7 +6695,6 @@ mod platform { Ok(actual.kind == expected.kind && actual.dev == expected.dev && actual.ino == expected.ino - && actual.nlink == expected.nlink && (kind == "directory" || (actual.size == expected.size && actual.mtime_ns == expected.mtime_ns @@ -5542,7 +6741,7 @@ mod platform { expected: &NativeDirectoryTreeEntry, ) -> Result<(), &'static str> { let name: Vec = tree_quarantine_name(expected).encode_utf16().collect(); - rename_handle_no_replace(handle, parent, &name) + rename_handle(handle, parent, &name, false) } fn set_handle_attributes(handle: HANDLE, attributes: u32) -> Result<(), &'static str> { @@ -5657,32 +6856,30 @@ mod platform { return Err("identity_mismatch"); } let directory = expected_child.kind == "directory"; - let child = open_relative( - handle, - &name_os, - FILE_READ_ATTRIBUTES | FILE_READ_DATA | FILE_WRITE_ATTRIBUTES | 0x0001_0000, - directory, - )?; + let child = if directory { + open_relative( + handle, + &name_os, + FILE_READ_ATTRIBUTES | FILE_READ_DATA | FILE_WRITE_ATTRIBUTES | 0x0001_0000, + true, + )? + } else { + open_relative_with_share( + handle, + &name_os, + FILE_READ_ATTRIBUTES | FILE_READ_DATA | FILE_WRITE_ATTRIBUTES | 0x0001_0000, + false, + FILE_SHARE_READ, + )? + }; if !tree_entry_matches(child, expected_child)? { unsafe { CloseHandle(child) }; return Err("identity_mismatch"); } - let quarantine_name = tree_quarantine_name(expected_child); - let already_quarantined = name == quarantine_name; + let already_quarantined = name == tree_quarantine_name(expected_child); if !already_quarantined { - if let Err(error) = quarantine_tree_child(child, handle, expected_child) { - unsafe { CloseHandle(child) }; - return Err(error); - } + quarantine_tree_child(child, handle, expected_child)?; } - unsafe { CloseHandle(child) }; - let child = open_relative_with_share( - handle, - std::ffi::OsStr::new(&quarantine_name), - FILE_READ_ATTRIBUTES | FILE_READ_DATA | FILE_WRITE_ATTRIBUTES | 0x0001_0000, - directory, - FILE_SHARE_READ, - )?; if !tree_entry_matches(child, expected_child)? { unsafe { CloseHandle(child) }; return Err("identity_mismatch"); @@ -5740,27 +6937,24 @@ mod platform { let mut final_candidate = PathBuf::from(path); final_candidate.set_file_name(OsString::from_wide(&final_name)); let input_is_final = planned_path.ends_with(".removing"); - let (root, retained_path, already_final) = match open_exact_with_parent( + let (root, retained_path, already_final) = match open_exact( path, "directory", FILE_READ_ATTRIBUTES | FILE_READ_DATA | FILE_WRITE_ATTRIBUTES | 0x0001_0000, - expected_parent, - None, ) { Ok(root) => (root, planned_path.clone(), input_is_final), Err(result) if !input_is_final && result.code.as_deref() == Some("not_found") => { - match open_exact_with_parent( + match open_exact( &final_candidate, "directory", FILE_READ_ATTRIBUTES | FILE_READ_DATA | FILE_WRITE_ATTRIBUTES | 0x0001_0000, - expected_parent, - None, ) { Ok(root) => (root, final_path.clone(), true), Err(result) => { return NativeExactUnlinkResult { ok: false, code: result.code, + payload_durable: None, detached_path: None, retained_successor_path: None, retained_placeholder_path: None, @@ -5773,6 +6967,7 @@ mod platform { return NativeExactUnlinkResult { ok: false, code: result.code, + payload_durable: None, detached_path: None, retained_successor_path: None, retained_placeholder_path: None, @@ -5791,23 +6986,30 @@ mod platform { return NativeExactUnlinkResult::detached_failure(code, retained_path); } let parent = *root.ancestors.last().expect("directory parent retained"); + if let Some((expected_parent_dev, expected_parent_ino)) = expected_parent { + let mut parent_information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(parent, &mut parent_information) } == 0 + || u64::from(parent_information.dwVolumeSerialNumber) != expected_parent_dev + || ((u64::from(parent_information.nFileIndexHigh) << 32) + | u64::from(parent_information.nFileIndexLow)) + != expected_parent_ino + { + return NativeExactUnlinkResult::detached_failure("parent_mismatch", retained_path); + } + } match remove_tree_handle(root.target, "", &expected.entries) { - Ok(()) if !already_final => { - match rename_handle_no_replace(root.target, parent, &final_name) { - Ok(()) => match tree_entry(root.target, String::new(), "directory") { - Ok(entry) if entry.dev == expected.root_dev && entry.ino == expected.root_ino => { - match delete_handle(root.target) { - Ok(()) => NativeExactUnlinkResult::success(), - Err(code) => NativeExactUnlinkResult::detached_failure(code, final_path), - } - }, - Ok(_) => { - NativeExactUnlinkResult::detached_failure("identity_mismatch", final_path) - }, - Err(code) => NativeExactUnlinkResult::detached_failure(code, final_path), + Ok(()) if !already_final => match rename_handle(root.target, parent, &final_name, false) { + Ok(()) => match tree_entry(root.target, String::new(), "directory") { + Ok(entry) if entry.dev == expected.root_dev && entry.ino == expected.root_ino => { + match delete_handle(root.target) { + Ok(()) => NativeExactUnlinkResult::success(), + Err(code) => NativeExactUnlinkResult::detached_failure(code, final_path), + } }, - Err(code) => NativeExactUnlinkResult::detached_failure(code, planned_path), - } + Ok(_) => NativeExactUnlinkResult::detached_failure("identity_mismatch", final_path), + Err(code) => NativeExactUnlinkResult::detached_failure(code, final_path), + }, + Err(code) => NativeExactUnlinkResult::detached_failure(code, planned_path), }, Ok(()) => match delete_handle(root.target) { Ok(()) => NativeExactUnlinkResult::success(), @@ -6233,7 +7435,11 @@ mod exact_unlink_placeholder_tests { platform::set_after_exchange_hook(None); platform::set_before_exchange_hook(None); platform::set_after_placeholder_detach_hook(None); - platform::set_after_tree_rename_hook(None); + platform::set_after_tree_validation_hook(None); + platform::set_before_tree_root_rename_hook(None); + platform::set_after_tree_scrub_hook(None); + platform::set_before_tree_child_rename_hook(None); + platform::set_after_tree_file_link_check_hook(None); } } @@ -6267,10 +7473,10 @@ mod exact_unlink_placeholder_tests { let metadata = fs::metadata(&target).expect("stat target"); let identity = ExactFileIdentity { dev: metadata.dev(), + ino: metadata.ino(), nlink: Some(metadata.nlink()), parent_dev: None, parent_ino: None, - ino: metadata.ino(), size: metadata.size(), mtime_ns: metadata.mtime_nsec() + metadata.mtime() * 1_000_000_000, directory: false, @@ -6297,8 +7503,13 @@ mod exact_unlink_placeholder_tests { assert!(!result.ok); assert_eq!(result.code.as_deref(), Some("cleanup_pending")); assert_eq!(result.detached_path.as_deref(), Some(stale.to_string_lossy().as_ref())); + assert_eq!(result.payload_durable, Some(true)); assert_eq!(fs::read(&target).expect("successor preserved"), b"live successor"); - assert_eq!(fs::read(&stale).expect("stale quarantine retained"), b"stale"); + assert!( + fs::read(&stale) + .expect("stale quarantine scrubbed") + .is_empty() + ); fs::remove_dir_all(root).expect("remove temporary directory"); } @@ -6326,10 +7537,10 @@ mod exact_unlink_placeholder_tests { let metadata = fs::metadata(&target).expect("stat target"); let identity = ExactFileIdentity { dev: metadata.dev(), + ino: metadata.ino(), nlink: Some(metadata.nlink()), parent_dev: None, parent_ino: None, - ino: metadata.ino(), size: metadata.size(), mtime_ns: metadata.mtime_nsec() + metadata.mtime() * 1_000_000_000, directory: target_is_directory, @@ -6392,10 +7603,10 @@ mod exact_unlink_placeholder_tests { let metadata = fs::metadata(&target).expect("stat target"); let identity = ExactFileIdentity { dev: metadata.dev(), + ino: metadata.ino(), nlink: Some(metadata.nlink()), parent_dev: None, parent_ino: None, - ino: metadata.ino(), size: metadata.size(), mtime_ns: metadata.mtime_nsec() + metadata.mtime() * 1_000_000_000, directory: target_is_directory, @@ -6463,10 +7674,10 @@ mod exact_unlink_placeholder_tests { let metadata = fs::metadata(&target).expect("stat target"); let identity = ExactFileIdentity { dev: metadata.dev(), + ino: metadata.ino(), nlink: Some(metadata.nlink()), parent_dev: None, parent_ino: None, - ino: metadata.ino(), size: metadata.size(), mtime_ns: metadata.mtime_nsec() + metadata.mtime() * 1_000_000_000, directory: target_is_directory, @@ -6536,10 +7747,10 @@ mod exact_unlink_placeholder_tests { let metadata = fs::metadata(&target).expect("stat target"); let identity = ExactFileIdentity { dev: metadata.dev(), + ino: metadata.ino(), nlink: Some(metadata.nlink()), parent_dev: None, parent_ino: None, - ino: metadata.ino(), size: metadata.size(), mtime_ns: metadata.mtime_nsec() + metadata.mtime() * 1_000_000_000, directory: false, @@ -6574,10 +7785,26 @@ mod exact_unlink_placeholder_tests { platform::set_after_placeholder_detach_hook(None); assert!(!result.ok); - assert!(matches!(result.code.as_deref(), Some("cleanup_pending" | "identity_mismatch"))); + assert_eq!( + result.code.as_deref(), + Some(if detach_only { + "identity_mismatch" + } else { + "cleanup_pending" + }), + ); assert_eq!(result.detached_path.as_deref(), Some(stale.to_string_lossy().as_ref())); + assert_eq!(result.payload_durable, if detach_only { None } else { Some(true) }); assert_eq!(fs::read(&target).expect("read second successor"), b"second"); - assert_eq!(fs::read(&stale).expect("read detached stale object"), b"stale"); + if detach_only { + assert_eq!(fs::read(&stale).expect("read retained stale object"), b"stale"); + } else { + assert!( + fs::read(&stale) + .expect("read scrubbed stale object") + .is_empty() + ); + } let retained = fs::read_dir(&root) .expect("read temporary directory") .map(|entry| entry.expect("read temporary entry").path()) @@ -6619,10 +7846,10 @@ mod exact_unlink_placeholder_tests { let metadata = fs::metadata(&target).expect("stat target"); let identity = ExactFileIdentity { dev: metadata.dev(), + ino: metadata.ino(), nlink: Some(metadata.nlink()), parent_dev: None, parent_ino: None, - ino: metadata.ino(), size: metadata.size(), mtime_ns: metadata.mtime_nsec() + metadata.mtime() * 1_000_000_000, directory: false, @@ -6696,31 +7923,38 @@ mod exact_unlink_placeholder_tests { assert!(!result.ok); assert_eq!(result.code.as_deref(), Some("cleanup_pending")); assert_eq!(result.detached_path.as_deref(), Some(detached.to_string_lossy().as_ref())); + assert_eq!(result.payload_durable, Some(true)); assert!(result.retained_successor_path.is_none()); assert!(result.retained_placeholder_path.is_none()); assert!(result.retained_unknown_path.is_none()); } - fn same_tree_after_authorized_rename( - left: &NativeDirectoryTreeSnapshot, - right: &NativeDirectoryTreeSnapshot, + fn tree_is_descriptor_scrubbed( + observed: &NativeDirectoryTreeSnapshot, + expected: &NativeDirectoryTreeSnapshot, ) -> bool { - left.root_dev == right.root_dev - && left.root_ino == right.root_ino - && left.entries.len() == right.entries.len() - && left - .entries - .iter() - .zip(&right.entries) - .all(|(left, right)| { - left.relative_path == right.relative_path - && left.kind == right.kind - && left.dev == right.dev - && left.ino == right.ino - && left.size == right.size - && left.mtime_ns == right.mtime_ns - && left.sha256 == right.sha256 - }) + let mut observed_identities = observed + .entries + .iter() + .map(|entry| (&entry.kind, &entry.dev, &entry.ino)) + .collect::>(); + let mut expected_identities = expected + .entries + .iter() + .map(|entry| (&entry.kind, &entry.dev, &entry.ino)) + .collect::>(); + observed_identities.sort(); + expected_identities.sort(); + observed.root_dev == expected.root_dev + && observed.root_ino == expected.root_ino + && observed_identities == expected_identities + && observed.entries.iter().all(|entry| { + (entry.relative_path.is_empty() && entry.kind == "directory") + || entry.kind == "directory" + || (entry.size == "0" + && entry.sha256.as_deref() + == Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")) + }) } fn replay_retains_verified_tree(nested: bool) { @@ -6744,32 +7978,24 @@ mod exact_unlink_placeholder_tests { let snapshot = platform::snapshot_directory_tree(&target) .snapshot .expect("snapshot target"); - let detached = root.join("target.removing"); + let detached = std::path::PathBuf::from(format!("{}.removing", target.to_string_lossy())); let first = platform::exact_remove_directory_tree(&target, &snapshot, None); assert_tree_replay_result(&first, &detached); - assert!(target.symlink_metadata().is_err()); + let first_snapshot = platform::snapshot_directory_tree(&detached) + .snapshot + .expect("snapshot detached"); assert!( - same_tree_after_authorized_rename( - &platform::snapshot_directory_tree(&detached) - .snapshot - .expect("snapshot detached"), - &snapshot, - ), - "first retained tree is replayable from the original snapshot" + tree_is_descriptor_scrubbed(&first_snapshot, &snapshot), + "first retained tree contains no authorized payload", ); let second = platform::exact_remove_directory_tree(&target, &snapshot, None); assert_tree_replay_result(&second, &detached); - assert!( - same_tree_after_authorized_rename( - &platform::snapshot_directory_tree(&detached) - .snapshot - .expect("snapshot detached"), - &snapshot, - ), - "second call retains the same replayable tree" - ); + let second_snapshot = platform::snapshot_directory_tree(&detached) + .snapshot + .expect("snapshot detached"); + assert_eq!(second_snapshot, first_snapshot, "replay does not mutate the scrubbed tree"); fs::remove_dir_all(root).expect("remove temporary directory"); } @@ -6782,6 +8008,413 @@ mod exact_unlink_placeholder_tests { fn nested_tree_retention_replays_on_second_call_with_exact_evidence() { replay_retains_verified_tree(true); } + + #[test] + fn root_parent_fsync_failures_withhold_durable_marker_and_replay() { + let _guard = exchange_hook_test_guard(); + for fail_on_call in [1, 2] { + let root = std::env::temp_dir().join(format!( + "gjc-tree-root-fsync-{fail_on_call}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(), + )); + fs::create_dir(&root).expect("create temporary directory"); + let target = root.join("target"); + fs::create_dir(&target).expect("create target"); + fs::write(target.join("payload.bin"), b"authorized payload").expect("write payload"); + let snapshot = platform::snapshot_directory_tree(&target) + .snapshot + .expect("snapshot target"); + let detached = std::path::PathBuf::from(format!("{}.removing", target.to_string_lossy())); + + platform::inject_root_parent_fsync_failure(fail_on_call); + let interrupted = platform::exact_remove_directory_tree(&target, &snapshot, None); + assert!(!interrupted.ok); + assert_eq!(interrupted.code.as_deref(), Some("io_error")); + assert_eq!( + interrupted.detached_path.as_deref(), + Some(detached.to_string_lossy().as_ref()) + ); + assert_eq!(interrupted.payload_durable, None); + + platform::inject_root_parent_fsync_failure(0); + let replayed = platform::exact_remove_directory_tree(&target, &snapshot, None); + assert_tree_replay_result(&replayed, &detached); + let replayed_snapshot = platform::snapshot_directory_tree(&detached) + .snapshot + .expect("snapshot replayed tree"); + assert!(tree_is_descriptor_scrubbed(&replayed_snapshot, &snapshot)); + fs::remove_dir_all(root).expect("remove temporary directory"); + } + } + + #[test] + fn tree_scrub_preserves_a_substituted_root_successor_after_validation() { + let _guard = exchange_hook_test_guard(); + let root = std::env::temp_dir().join(format!( + "gjc-tree-successor-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(), + )); + fs::create_dir(&root).expect("create temporary directory"); + let target = root.join("target"); + fs::create_dir(&target).expect("create target"); + fs::write(target.join("state.json"), b"authorized stale payload") + .expect("write stale payload"); + let snapshot = platform::snapshot_directory_tree(&target) + .snapshot + .expect("snapshot target"); + let detached = target.clone(); + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + platform::set_before_tree_root_rename_hook(Some((entered_tx, resume_rx))); + let target_for_remove = target.clone(); + let removal = thread::spawn(move || { + platform::exact_remove_directory_tree(&target_for_remove, &snapshot, None) + }); + entered_rx.recv().expect("wait for root validation"); + let retained_stale = root.join("retained-stale-root"); + fs::rename(&target, &retained_stale).expect("retain stale root"); + fs::create_dir(&target).expect("publish successor root"); + fs::write(target.join("state.json"), b"substituted successor") + .expect("write successor payload"); + resume_tx.send(()).expect("resume tree scrub"); + let result = removal.join().expect("tree scrub thread"); + platform::set_before_tree_root_rename_hook(None); + assert!(!result.ok); + assert_eq!(result.code.as_deref(), Some("identity_mismatch")); + assert!(result.detached_path.is_none()); + assert_eq!( + result.retained_successor_path.as_deref(), + Some(detached.to_string_lossy().as_ref()) + ); + assert_eq!( + fs::read(detached.join("state.json")).expect("read successor"), + b"substituted successor" + ); + assert_eq!( + fs::read(retained_stale.join("state.json")).expect("read stale object"), + b"authorized stale payload" + ); + fs::remove_dir_all(root).expect("remove temporary directory"); + } + + #[test] + fn tree_scrub_restores_a_regular_file_root_successor() { + let _guard = exchange_hook_test_guard(); + let root = std::env::temp_dir().join(format!( + "gjc-tree-file-successor-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(), + )); + fs::create_dir(&root).expect("create temporary directory"); + let target = root.join("target"); + fs::create_dir(&target).expect("create target"); + fs::write(target.join("state.json"), b"authorized stale payload") + .expect("write stale payload"); + let snapshot = platform::snapshot_directory_tree(&target) + .snapshot + .expect("snapshot target"); + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + platform::set_before_tree_root_rename_hook(Some((entered_tx, resume_rx))); + let target_for_remove = target.clone(); + let removal = thread::spawn(move || { + platform::exact_remove_directory_tree(&target_for_remove, &snapshot, None) + }); + entered_rx.recv().expect("wait for root validation"); + let retained_stale = root.join("retained-stale-root"); + fs::rename(&target, &retained_stale).expect("retain stale root"); + fs::write(&target, b"regular-file successor").expect("publish file successor"); + resume_tx.send(()).expect("resume tree scrub"); + let result = removal.join().expect("tree scrub thread"); + platform::set_before_tree_root_rename_hook(None); + assert!(!result.ok); + assert_eq!(result.code.as_deref(), Some("identity_mismatch")); + assert_eq!( + result.retained_successor_path.as_deref(), + Some(target.to_string_lossy().as_ref()) + ); + assert_eq!(fs::read(&target).expect("read successor"), b"regular-file successor"); + assert_eq!( + fs::read(retained_stale.join("state.json")).expect("read stale object"), + b"authorized stale payload" + ); + fs::remove_dir_all(root).expect("remove temporary directory"); + } + + #[test] + fn tree_scrub_rejects_a_post_scrub_retained_root_successor() { + let _guard = exchange_hook_test_guard(); + let root = std::env::temp_dir().join(format!( + "gjc-tree-post-scrub-successor-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(), + )); + fs::create_dir(&root).expect("create temporary directory"); + let target = root.join("target"); + let detached = std::path::PathBuf::from(format!("{}.removing", target.to_string_lossy())); + fs::create_dir(&target).expect("create target"); + fs::write(target.join("state.json"), b"authorized stale payload") + .expect("write stale payload"); + let snapshot = platform::snapshot_directory_tree(&target) + .snapshot + .expect("snapshot target"); + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + platform::set_after_tree_scrub_hook(Some((entered_tx, resume_rx))); + let target_for_remove = target.clone(); + let removal = thread::spawn(move || { + platform::exact_remove_directory_tree(&target_for_remove, &snapshot, None) + }); + entered_rx + .recv() + .expect("wait for post-scrub receipt boundary"); + let retained_scrubbed = root.join("retained-scrubbed-root"); + fs::rename(&detached, &retained_scrubbed).expect("retain scrubbed root"); + fs::create_dir(&detached).expect("publish retained-name successor"); + fs::write(detached.join("state.json"), b"successor payload") + .expect("write successor payload"); + resume_tx.send(()).expect("resume durable receipt"); + let result = removal.join().expect("tree scrub thread"); + platform::set_after_tree_scrub_hook(None); + assert!(!result.ok); + assert_eq!(result.code.as_deref(), Some("identity_mismatch")); + assert_eq!(result.payload_durable, None); + assert_eq!( + result.retained_successor_path.as_deref(), + Some(detached.to_string_lossy().as_ref()) + ); + assert_eq!( + fs::read(detached.join("state.json")).expect("read successor"), + b"successor payload" + ); + assert_eq!( + fs::read(retained_scrubbed.join("state.json")).expect("read scrubbed original"), + b"" + ); + fs::remove_dir_all(root).expect("remove temporary directory"); + } + + #[test] + fn tree_scrub_rejects_external_hard_links_without_truncation() { + let _guard = exchange_hook_test_guard(); + let root = std::env::temp_dir().join(format!( + "gjc-tree-hard-link-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(), + )); + fs::create_dir(&root).expect("create temporary directory"); + + let rejected = root.join("rejected"); + fs::create_dir(&rejected).expect("create rejected tree"); + fs::write(rejected.join("payload.bin"), b"shared payload").expect("write rejected payload"); + fs::hard_link(rejected.join("payload.bin"), root.join("rejected-alias.bin")) + .expect("link rejected alias"); + let rejected_snapshot = platform::snapshot_directory_tree(&rejected); + assert!(!rejected_snapshot.ok); + assert_eq!(rejected_snapshot.code.as_deref(), Some("hard_link_unsupported")); + assert_eq!( + fs::read(root.join("rejected-alias.bin")).expect("read rejected alias"), + b"shared payload" + ); + + let raced = root.join("raced"); + fs::create_dir(&raced).expect("create raced tree"); + fs::write(raced.join("payload.bin"), b"raced shared payload").expect("write raced payload"); + let raced_snapshot = platform::snapshot_directory_tree(&raced) + .snapshot + .expect("snapshot unlinked tree"); + let alias = root.join("raced-alias.bin"); + fs::hard_link(raced.join("payload.bin"), &alias).expect("link raced alias"); + let result = platform::exact_remove_directory_tree(&raced, &raced_snapshot, None); + assert!(!result.ok); + assert_eq!(result.code.as_deref(), Some("hard_link_unsupported")); + assert_eq!(result.payload_durable, None); + let detached = std::path::PathBuf::from( + result + .detached_path + .as_deref() + .expect("retained detached root"), + ); + assert_eq!( + fs::read(detached.join("payload.bin")).expect("read retained payload"), + b"raced shared payload" + ); + assert_eq!(fs::read(alias).expect("read external alias"), b"raced shared payload"); + fs::remove_dir_all(root).expect("remove temporary directory"); + } + + #[test] + fn tree_scrub_rechecks_hard_links_at_truncate_boundary() { + let _guard = exchange_hook_test_guard(); + let root = std::env::temp_dir().join(format!( + "gjc-tree-late-hard-link-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(), + )); + fs::create_dir(&root).expect("create temporary directory"); + let target = root.join("target"); + fs::create_dir(&target).expect("create target"); + fs::write(target.join("payload.bin"), b"late shared payload").expect("write payload"); + let snapshot = platform::snapshot_directory_tree(&target) + .snapshot + .expect("snapshot target"); + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + platform::set_after_tree_file_link_check_hook(Some((entered_tx, resume_rx))); + let target_for_remove = target.clone(); + let removal = thread::spawn(move || { + platform::exact_remove_directory_tree(&target_for_remove, &snapshot, None) + }); + entered_rx + .recv() + .expect("wait for final hard-link check boundary"); + let detached = fs::read_dir(&root) + .expect("list root") + .map(|entry| entry.expect("read entry").path()) + .find(|entry| entry.is_dir() && entry.join("payload.bin").exists()) + .expect("find detached root"); + let alias = root.join("late-alias.bin"); + fs::hard_link(detached.join("payload.bin"), &alias).expect("link late alias"); + resume_tx.send(()).expect("resume final hard-link check"); + let result = removal.join().expect("tree scrub thread"); + platform::set_after_tree_file_link_check_hook(None); + assert!(!result.ok); + assert_eq!(result.code.as_deref(), Some("hard_link_unsupported")); + assert_eq!(result.payload_durable, None); + assert_eq!(fs::read(&alias).expect("read external alias"), b"late shared payload"); + let retained = detached.join("payload.bin"); + assert_eq!(fs::read(retained).expect("read retained artifact"), b"late shared payload"); + fs::remove_dir_all(root).expect("remove temporary directory"); + } + + #[test] + fn tree_scrub_rechecks_payload_digest_at_truncate_boundary() { + let _guard = exchange_hook_test_guard(); + let root = std::env::temp_dir().join(format!( + "gjc-tree-late-payload-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(), + )); + fs::create_dir(&root).expect("create temporary directory"); + let target = root.join("target"); + fs::create_dir(&target).expect("create target"); + fs::write(target.join("payload.bin"), b"authorized payload").expect("write payload"); + let snapshot = platform::snapshot_directory_tree(&target) + .snapshot + .expect("snapshot target"); + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + platform::set_after_tree_file_link_check_hook(Some((entered_tx, resume_rx))); + let target_for_remove = target.clone(); + let removal = thread::spawn(move || { + platform::exact_remove_directory_tree(&target_for_remove, &snapshot, None) + }); + entered_rx + .recv() + .expect("wait for final payload check boundary"); + let detached = fs::read_dir(&root) + .expect("list root") + .map(|entry| entry.expect("read entry").path()) + .find(|entry| entry.is_dir() && entry.join("payload.bin").exists()) + .expect("find detached root"); + fs::write(detached.join("payload.bin"), b"substituted payload") + .expect("replace payload bytes"); + resume_tx.send(()).expect("resume final payload check"); + let result = removal.join().expect("tree scrub thread"); + platform::set_after_tree_file_link_check_hook(None); + assert!(!result.ok); + assert_eq!(result.code.as_deref(), Some("identity_mismatch")); + assert_eq!( + fs::read(detached.join("payload.bin")).expect("read retained artifact"), + b"substituted payload" + ); + fs::remove_dir_all(root).expect("remove temporary directory"); + } + + #[test] + fn tree_child_revalidation_preserves_same_name_successor() { + let _guard = exchange_hook_test_guard(); + let root = std::env::temp_dir().join(format!( + "gjc-tree-child-successor-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(), + )); + fs::create_dir(&root).expect("create temporary directory"); + let target = root.join("target"); + fs::create_dir(&target).expect("create target"); + fs::write(target.join("state.json"), b"authorized stale payload") + .expect("write stale payload"); + let snapshot = platform::snapshot_directory_tree(&target) + .snapshot + .expect("snapshot target"); + let detached = std::path::PathBuf::from(format!("{}.removing", target.to_string_lossy())); + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + platform::set_before_tree_child_rename_hook(Some((entered_tx, resume_rx))); + let target_for_remove = target.clone(); + let removal = thread::spawn(move || { + platform::exact_remove_directory_tree(&target_for_remove, &snapshot, None) + }); + entered_rx.recv().expect("wait for child rename boundary"); + let retained_stale = detached.join("retained-stale"); + fs::rename(detached.join("state.json"), &retained_stale).expect("retain authorized object"); + fs::write(detached.join("state.json"), b"same-name successor").expect("publish successor"); + let successor_identity = fs::metadata(detached.join("state.json")).expect("stat successor"); + resume_tx.send(()).expect("resume child rename"); + let result = removal.join().expect("tree scrub thread"); + platform::set_before_tree_child_rename_hook(None); + assert!(!result.ok); + assert_eq!(result.code.as_deref(), Some("identity_mismatch")); + assert_eq!(result.payload_durable, None); + assert_eq!( + fs::read(detached.join("state.json")).expect("read successor"), + b"same-name successor" + ); + let restored_identity = + fs::metadata(detached.join("state.json")).expect("stat restored successor"); + assert_eq!(restored_identity.dev(), successor_identity.dev()); + assert_eq!(restored_identity.ino(), successor_identity.ino()); + assert_eq!( + fs::read(retained_stale).expect("read authorized object"), + b"authorized stale payload" + ); + assert!( + fs::read_dir(&detached) + .expect("list detached root") + .all(|entry| !entry + .expect("read entry") + .file_name() + .to_string_lossy() + .starts_with(".pi-tree-detached-")) + ); + fs::remove_dir_all(root).expect("remove temporary directory"); + } #[test] fn aborted_tree_hook_does_not_block_the_next_hook() { let _guard = exchange_hook_test_guard(); @@ -6802,7 +8435,7 @@ mod exact_unlink_placeholder_tests { let (entered_tx, entered_rx) = mpsc::channel(); let (resume_tx, resume_rx) = mpsc::channel(); drop(resume_tx); - platform::set_after_tree_rename_hook(Some((entered_tx, resume_rx))); + platform::set_after_tree_validation_hook(Some((entered_tx, resume_rx))); let target_for_remove = target.clone(); let aborted = thread::spawn(move || { platform::exact_remove_directory_tree(&target_for_remove, &snapshot, None) @@ -6817,7 +8450,7 @@ mod exact_unlink_placeholder_tests { .expect("snapshot next target"); let (entered_tx, entered_rx) = mpsc::channel(); let (resume_tx, resume_rx) = mpsc::channel(); - platform::set_after_tree_rename_hook(Some((entered_tx, resume_rx))); + platform::set_after_tree_validation_hook(Some((entered_tx, resume_rx))); let next_for_remove = next.clone(); let removal = thread::spawn(move || { platform::exact_remove_directory_tree(&next_for_remove, &snapshot, None) diff --git a/crates/pi-natives/src/recovery_fs.rs b/crates/pi-natives/src/recovery_fs.rs index 1fbcea63bc..2bc502bdc0 100644 --- a/crates/pi-natives/src/recovery_fs.rs +++ b/crates/pi-natives/src/recovery_fs.rs @@ -210,6 +210,7 @@ fn sync_parent(parent: &File) -> std::io::Result<()> { pub struct RecoveryFsIdentity { pub dev: String, pub ino: String, + pub nlink: String, pub size: String, pub mtime_ns: String, pub ctime_ns: String, @@ -1287,6 +1288,7 @@ fn identity(file: &File) -> Result { Ok(RecoveryFsIdentity { dev: stat.st_dev.to_string(), ino: stat.st_ino.to_string(), + nlink: stat.st_nlink.to_string(), size: (stat.st_size as u64).to_string(), mtime_ns: stat_mtime_ns(&stat).to_string(), ctime_ns: stat_ctime_ns(&stat).to_string(), @@ -1314,6 +1316,7 @@ fn regular_identity(file: &File) -> Result { Ok(RecoveryFsIdentity { dev: stat.st_dev.to_string(), ino: stat.st_ino.to_string(), + nlink: stat.st_nlink.to_string(), size: (stat.st_size as u64).to_string(), mtime_ns: stat_mtime_ns(&stat).to_string(), ctime_ns: stat_ctime_ns(&stat).to_string(), diff --git a/issues/21-session-resume-model-behavior-not-configurable.md b/issues/21-session-resume-model-behavior-not-configurable.md index 959dad988f..274210687a 100644 --- a/issues/21-session-resume-model-behavior-not-configurable.md +++ b/issues/21-session-resume-model-behavior-not-configurable.md @@ -8,10 +8,10 @@ When a session is resumed — either at CLI startup (`-c`/`-r`) or via `/resume` already-running TUI session — the model in use is restored from the session file's last `model_change` entry, not from the currently configured default model: -- `packages/coding-agent/src/sdk.ts:1019-1033` — CLI-level resume. Restores `existingSession.models.default` - unless an explicit `--model`/`options.model` was passed (`hasExplicitModel` gate at `sdk.ts:1005`, - `sdk.ts:1024`). -- `packages/coding-agent/src/session/agent-session.ts:9710-9734` (`switchSession`) — in-process +- `packages/coding-agent/src/sdk/session.ts:1281-1319` — CLI-level resume. Restores + `existingSession.models.default` unless an explicit `--model`/`options.model` was passed + (`hasExplicitModel` gate at `session.ts:1281`, restore gate at `session.ts:1309`). +- `packages/coding-agent/src/session/agent-session.ts:15997-16115` (`switchSession`) — in-process `/resume` session switch. Same restore-from-`sessionContext.models.default` behavior, with no override path other than the CLI flag (which doesn't apply to a mid-run `/resume`). @@ -40,11 +40,12 @@ Add a settings key, e.g. `session.resumeModelBehavior` (`packages/coding-agent/s enum: `"keepSessionModel"` (default, current behavior) | `"useCurrentDefault"`), and branch on it at both restore sites: -- `sdk.ts:1024` — when `useCurrentDefault`, skip the `existingSession.models.default` restore and - resolve the model the same way a brand-new session would (`resolveModelRoleValue(settings.getModelRole("default"), …)`), - with the same `hasModelApiKey` fallback guard already used for the session-restore path. -- `agent-session.ts:9711` (`switchSession`) — same branch, applied after `sessionContext` is loaded, - before `#setModelWithProviderSessionReset`/`agent.setModel`. +- `sdk/session.ts:1297-1309` — when `useCurrentDefault`, skip the + `existingSession.models.default` restore and resolve the model the same way a brand-new session + would (`resolveModelRoleValue(settings.getModelRole("default"), …)`), with the same + `hasModelApiKey` fallback guard already used for the session-restore path. +- `agent-session.ts:16087-16115` (`switchSession`) — same branch, applied after `sessionContext` is + loaded, before the authoritative model restore. Stage 2 (done): a third `"ask"` mode prompts in the TUI resume picker (`selector-controller.ts` `handleResumeSession` → `#maybePromptResumeModelChoice`) only when the diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e960a962fe..6d52a70771 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -31,6 +31,10 @@ - Canonical wrapped first-event timeouts now continue the same clean turn through bounded retries and configured fallback rotation, while preserving replay-safety, cancellation, provider-terminal policies, exact attempt diagnostics, and task/subagent retry-status truth (#3553). - Runtime skill discovery now preserves a candidate when its exact skill name appears as a query token, so additional task-specific terms no longer discard an explicitly named skill. +### Fixed + +- Managed-session deletion now immediately continues a descriptor-authorized POSIX artifact detach through exact payload scrubbing before retiring the transcript. Durable direct and replay cleanup preserve substituted successors, while fork regressions use an explicit snapshot barrier instead of scheduler timing. +- Managed-session migration keeps a definitely live holder exclusive beyond the 60-second lease without timer-dependent self-fencing, permits immediate successor acquisition only after explicit release or proven process death, and surfaces capacity/busy startup failures through fixed path/content-redacted guidance (#3508). ## [0.12.5] - 2026-07-30 ### Fixed diff --git a/packages/coding-agent/src/config/file-lock.ts b/packages/coding-agent/src/config/file-lock.ts index 9ab6a0ddd2..7330d248c0 100644 --- a/packages/coding-agent/src/config/file-lock.ts +++ b/packages/coding-agent/src/config/file-lock.ts @@ -17,6 +17,10 @@ const DEFAULT_OPTIONS: Required = { type LockInfo = FileLockOwnerToken; +export const FileLockTestHooks: { + afterParentMkdir?: (lockPath: string) => void | Promise; +} = {}; + /** * Returns the OS-provided process start timestamp for PID-reuse detection. * `ps` is available on the supported Unix hosts (macOS and Linux), unlike @@ -237,6 +241,8 @@ async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSn async function tryAcquireLock(lockPath: string): Promise { await fs.mkdir(path.dirname(lockPath), { recursive: true }); + const afterParentMkdir = FileLockTestHooks.afterParentMkdir; + if (afterParentMkdir) await afterParentMkdir(lockPath); try { await fs.mkdir(lockPath); return await writeLockInfo(lockPath); diff --git a/packages/coding-agent/src/gjc-runtime/session-state-sidecar.ts b/packages/coding-agent/src/gjc-runtime/session-state-sidecar.ts index d4fd3ef939..6130f60df7 100644 --- a/packages/coding-agent/src/gjc-runtime/session-state-sidecar.ts +++ b/packages/coding-agent/src/gjc-runtime/session-state-sidecar.ts @@ -5,7 +5,7 @@ import * as path from "node:path"; import type { AssistantMessage } from "@gajae-code/ai"; import { normalizePathForComparison, postmortem } from "@gajae-code/utils"; import { withFileLock } from "../config/file-lock"; -import { sessionRuntimeDir } from "./session-layout"; +import { sessionRoot, sessionRuntimeDir } from "./session-layout"; import { isValidOwnerIntent, lifecyclePaths, @@ -1022,6 +1022,7 @@ export async function persistCoordinatorRuntimeStateFromPostmortem( const stateFile = runtimeStateFileForContext(context); if (!stateFile) return; const identity = normalizedIdentity(context); + const ownerSessionRoot = sessionRoot(context.cwd, identity.sessionId); const ownerTerminalVerdict = context.ownerTerminal ? await observeOwnerTerminalPostmortem(reason, context.ownerTerminal, identity.sessionId) : null; @@ -1091,7 +1092,16 @@ export async function persistCoordinatorRuntimeStateFromPostmortem( await writeStateFileSync(stateFile, payload); }), ), - ); + ).catch(error => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + try { + fsSync.lstatSync(ownerSessionRoot); + } catch (rootError) { + if ((rootError as NodeJS.ErrnoException).code === "ENOENT") return; + } + } + throw error; + }); } export function registerCoordinatorRuntimeStateFinalizer(context: RuntimeStateContext): () => void { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index d0a6016ce5..c28f694002 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -57,10 +57,11 @@ import { discoverAuthStorage, } from "./sdk"; import type { AgentSession } from "./session/agent-session"; - +import { SessionMigrationBusyError } from "./session/internal/session-open-errors"; import { type ResumeSessionIdentity, resolveResumableSession, + SessionArtifactCapacityError, type SessionDestination, type SessionDirectoryMigrationPolicy, type SessionInfo, @@ -562,6 +563,22 @@ export function resolveManagedAgentDirForScope(_cwd: string): string { return getAgentDir(); } export const BARE_RESUME_OPEN_ERROR = "Could not open the selected session. Use --resume ."; +const SESSION_ARTIFACT_CAPACITY_RECOVERY_MESSAGE = + "The selected legacy session's artifacts exceed the supported migration capacity. Archive or remove only that legacy session's artifacts after confirming they are no longer needed, then retry."; + +function operatorFacingSessionOpenMessage(value: unknown): string | undefined { + const code = + value instanceof SessionArtifactCapacityError + ? value.code + : value instanceof SessionMigrationBusyError + ? value.code + : typeof value === "string" + ? value + : undefined; + if (code === "artifact_capacity_exceeded") return SESSION_ARTIFACT_CAPACITY_RECOVERY_MESSAGE; + if (code === "migration_busy") return new SessionMigrationBusyError().message; + return undefined; +} function isBareResume(parsed: Args): boolean { return ( @@ -1072,6 +1089,7 @@ type RunPrintMode = (session: AgentSession, options: PrintModeOptions) => Promis export interface RunRootCommandDependencies { createAgentSession?: typeof createAgentSession; + createSessionManager?: typeof createSessionManager; discoverAuthStorage?: typeof discoverAuthStorage; runAcpMode?: (options?: { agentDir?: string }) => Promise; settings?: Settings; @@ -1208,13 +1226,13 @@ export async function runRootCommand( undefined, resumeMigrationPolicy, ); - } catch { - process.stderr.write(`${BARE_RESUME_OPEN_ERROR}\n`); + } catch (error) { + process.stderr.write(`${operatorFacingSessionOpenMessage(error) ?? BARE_RESUME_OPEN_ERROR}\n`); if (!deps.suppressProcessExit) process.exitCode = 1; return; } if (opened.kind === "error") { - process.stderr.write(`${BARE_RESUME_OPEN_ERROR}\n`); + process.stderr.write(`${operatorFacingSessionOpenMessage(opened.reason) ?? BARE_RESUME_OPEN_ERROR}\n`); if (!deps.suppressProcessExit) process.exitCode = 1; return; } @@ -1381,9 +1399,27 @@ export async function runRootCommand( // Create session manager based on CLI flags. A bare resume was strictly opened // before startup discovery, so it never reaches create-or-open behavior here. - const sessionManager = - bareResumeSessionManager ?? - (await logger.time("createSessionManager", createSessionManager, parsedArgs, cwd, settingsInstance)); + let sessionManager: SessionManager | undefined = bareResumeSessionManager; + if (!sessionManager) { + try { + sessionManager = await logger.time( + "createSessionManager", + deps.createSessionManager ?? createSessionManager, + parsedArgs, + cwd, + settingsInstance, + ); + } catch (error) { + const message = operatorFacingSessionOpenMessage(error); + if (!message) throw error; + process.stderr.write(`${message}\n`); + if (!deps.suppressProcessExit) process.exitCode = 1; + authStorage.close(); + stopThemeWatcher(); + await postmortem.cleanup(); + return; + } + } // Restore the resumed session's working directory so the HUD branch, the // project path, and the agent's tools all match where the session was diff --git a/packages/coding-agent/src/sdk/broker/broker.ts b/packages/coding-agent/src/sdk/broker/broker.ts index 315b42c18a..613d767756 100644 --- a/packages/coding-agent/src/sdk/broker/broker.ts +++ b/packages/coding-agent/src/sdk/broker/broker.ts @@ -938,9 +938,8 @@ export class Broker { }); if (isCleanupPending(response)) return response; const persisted = await this.ledger.readTerminal(identity, requestHash); - const expectedResponseDigest = createHash("sha256").update(canonicalJson(response)).digest("hex"); const persistenceVerified = - persisted?.responseDigest === expectedResponseDigest && + persisted !== undefined && canonicalJson(persisted.response) === canonicalJson(response) && canonicalJson(persisted.durableEffects) === canonicalJson(outcome.durableEffects) && canonicalJson(persisted.startupFailure) === canonicalJson(outcome.startupFailure); diff --git a/packages/coding-agent/src/sdk/broker/lifecycle-ledger.ts b/packages/coding-agent/src/sdk/broker/lifecycle-ledger.ts index a01039632d..4c39e75608 100644 --- a/packages/coding-agent/src/sdk/broker/lifecycle-ledger.ts +++ b/packages/coding-agent/src/sdk/broker/lifecycle-ledger.ts @@ -666,7 +666,13 @@ export class LifecycleLedger { return this.#mutate(async () => { const previous = this.#byIdentity.get(identity); if (!previous) throw new Error("Unknown lifecycle identity"); - const next = { ...previous, ...fields, state, ts: Date.now() }; + const next = { + ...previous, + ...fields, + state, + ts: Date.now(), + ...(fields.response !== undefined ? { response: fields.response } : {}), + }; if (this.#isCleanupPending(next)) { next.unresolvedCleanupResponse = undefined; next.unresolvedCleanupResponseDigest = undefined; @@ -796,4 +802,31 @@ export class LifecycleLedger { get(identity: string): LifecycleLedgerEntry | undefined { return this.#byIdentity.get(identity); } + + findPendingCleanupByTarget( + sessionId: string, + cwd: string, + transcriptPath: string, + ): LifecycleLedgerEntry | undefined { + const expectedCwd = path.resolve(cwd); + const expectedTranscript = path.resolve(transcriptPath); + let latest: LifecycleLedgerEntry | undefined; + for (const entry of this.#byIdentity.values()) { + if (entry.state !== "effect_started" || !this.#isCleanupPending(entry)) continue; + const response = entry.response as { + error?: { cleanup?: { sessionId?: unknown; cwd?: unknown; transcriptPath?: unknown } }; + }; + const cleanup = response.error?.cleanup; + if ( + cleanup?.sessionId !== sessionId || + typeof cleanup.cwd !== "string" || + typeof cleanup.transcriptPath !== "string" || + path.resolve(cleanup.cwd) !== expectedCwd || + path.resolve(cleanup.transcriptPath) !== expectedTranscript + ) + continue; + if (!latest || entry.ts > latest.ts) latest = entry; + } + return latest; + } } diff --git a/packages/coding-agent/src/sdk/broker/lifecycle.ts b/packages/coding-agent/src/sdk/broker/lifecycle.ts index cad1bec3d6..0d2e3fa08b 100644 --- a/packages/coding-agent/src/sdk/broker/lifecycle.ts +++ b/packages/coding-agent/src/sdk/broker/lifecycle.ts @@ -865,7 +865,7 @@ export async function writeSessionLifecycleReady(root: string, id: string, effec const incarnation = processIncarnation(process.pid); if (!incarnation) throw new Error("Lifecycle child has no readable OS incarnation."); await fs.mkdir(path.join(root, "sdk"), { recursive: true, mode: 0o700 }); - await fs.writeFile(lifecycleReadyPath(root, id), JSON.stringify({ pid: process.pid, effectMarker, incarnation }), { + await fs.writeFile(lifecycleReadyPath(root, id), canonicalJson({ pid: process.pid, effectMarker, incarnation }), { mode: 0o600, }); } @@ -1365,15 +1365,6 @@ function lifecycleMetadataReplayFiles(cleanup: CleanupEvidence): LifecycleCleanu return cleanup.lifecycleFiles?.length ? cleanup.lifecycleFiles : undefined; } -function absentLifecyclePath(file: string): boolean { - try { - fsSync.lstatSync(file); - return false; - } catch (error) { - return (error as NodeJS.ErrnoException).code === "ENOENT"; - } -} - function isLifecycleCleanupResponse(value: LifecycleFileCapture | BrokerResponse | undefined): value is BrokerResponse { return typeof value === "object" && value !== null && "ok" in value; } @@ -1423,6 +1414,19 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon try { current = captureLifecycleFile(candidate, true, true); } catch { + if ( + file.completed && + [file.detachedPath, file.plannedPath].some( + bound => bound && path.resolve(candidate) === path.resolve(bound), + ) + ) { + try { + const stat = fsSync.lstatSync(candidate); + if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && stat.size === 0) continue; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + } + } return fail("terminal_uncertain", "Lifecycle metadata candidate could not be safely inspected."); } if (!current) continue; @@ -1457,7 +1461,7 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon if (!markerEntry) return fail("terminal_uncertain", "Lifecycle readiness metadata lacks canonical marker authority."); if (!marker) { - if (createHash("sha256").update(canonicalJson(readyMarker)).digest("hex") !== markerEntry.identity.sha256) + if (createHash("sha256").update(ready.bytes).digest("hex") !== markerEntry.identity.sha256) return fail( "terminal_uncertain", "Lifecycle readiness metadata is not bound to the completed marker authority.", @@ -1473,30 +1477,6 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon return undefined; } -function lifecycleMetadataReplayAbsent(cleanup: CleanupEvidence): boolean { - const files = lifecycleMetadataReplayFiles(cleanup); - if (!files) return true; - const root = path.resolve(cleanup.metadataRoot!); - const id = cleanup.sessionId!; - const required = new Set([lifecycleMarkerPath(root, id), lifecycleReadyPath(root, id)]); - for (const file of files) for (const candidate of lifecycleCleanupCandidates(file)) required.add(candidate); - for (const candidate of required) { - if (absentLifecyclePath(candidate)) continue; - // A completed file's recorded retained quarantine is durable evidence, not a - // survivor — accept it only at its receipt-bound path and identity. - const owner = files.find( - file => - file.completed === true && - file.detachedPath !== undefined && - path.resolve(file.detachedPath) === path.resolve(candidate), - ); - if (!owner) return false; - const current = captureLifecycleFile(candidate, true, true); - if (!current || !sameLifecycleCleanupIdentity(current.identity, owner.identity)) return false; - } - return true; -} - /** * Base dev persisted metadata cleanup one file at a time. Accept only its * identity-bound marker receipt and translate it into the current replay plan. @@ -1612,7 +1592,7 @@ function legacyMetadataCleanupPlan(cleanup: CleanupEvidence): CleanupEvidence | return undefined; } if (marker && !sameEffectMarker(marker, readyMarker)) return undefined; - if (!marker && createHash("sha256").update(canonicalJson(readyMarker)).digest("hex") !== persistedIdentity.sha256) + if (!marker && createHash("sha256").update(ready.capture.bytes).digest("hex") !== persistedIdentity.sha256) return undefined; } @@ -1785,11 +1765,11 @@ async function reconcileLifecycleCleanup( file.detachedPath && path.resolve(candidate) === path.resolve(file.detachedPath) && stat.isFile() && - !stat.isSymbolicLink() - ) { - const current = captureLifecycleFile(candidate, true, true); - if (current && sameLifecycleCleanupIdentity(current.identity, file.identity)) continue; - } + !stat.isSymbolicLink() && + stat.nlink === 1 && + stat.size === 0 + ) + continue; return fail( "terminal_uncertain", "Lifecycle cleanup receipt marks a target complete while an authorized candidate remains.", @@ -1892,7 +1872,7 @@ async function reconcileLifecycleCleanup( }); } const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) => - candidateIndex === index ? { ...candidate, completed: true as const } : candidate, + candidateIndex === index ? { ...candidate, detachedPath: undefined, completed: true as const } : candidate, ); activeCleanup = { ...activeCleanup, lifecycleFiles }; await broker.ledger.transition(identity, "effect_started", { @@ -1900,8 +1880,6 @@ async function reconcileLifecycleCleanup( }); lifecycleCleanupHooksForTest.get(broker)?.(); } - if (!lifecycleMetadataReplayAbsent(activeCleanup)) - return fail("terminal_uncertain", "Lifecycle metadata replay left an authorized sibling behind."); await syncDirectory(path.join(activeCleanup.metadataRoot!, "sdk")); return completion; } @@ -3463,6 +3441,21 @@ async function executeLifecycleResponse( return (error as NodeJS.ErrnoException).code === "ENOENT"; } }; + try { + const currentTranscript = fsSync.lstatSync(cleanupTarget.transcriptPath, { bigint: true }); + if ( + currentTranscript.isFile() && + !currentTranscript.isSymbolicLink() && + currentTranscript.dev === cleanupTarget.transcriptIdentity.dev && + currentTranscript.ino === cleanupTarget.transcriptIdentity.ino + ) + cleanupTarget.transcriptIdentity = { + ...cleanupTarget.transcriptIdentity, + nlink: currentTranscript.nlink, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } const transcriptParentIdentity = cleanup?.transcriptParentIdentity ?? validated.transcriptParentIdentity; const durableArtifactsPlan = cleanup?.artifactTree?.plannedPath ?? cleanup?.plannedArtifactsPath ?? cleanupTarget.plannedArtifactsPath; @@ -3687,11 +3680,17 @@ async function executeLifecycleResponse( const retainedTranscriptReplayHasNoSideAuthority = retainedTranscriptSidePaths.every( candidate => candidate === undefined, ); - const retainedTranscriptSideAuthorityIsProvenAbsent = - retainedTranscriptSidePaths.every(pathIsAbsent) && - (cleanupTarget.detachedTranscriptPath ? true : retainedTranscriptIdentityIsAbsentFromParent()); if (cleanup && !retainedTranscriptReplayHasNoSideAuthority) { - if (!retainedTranscriptSideAuthorityIsProvenAbsent) return await publishRetainedTranscriptSideAuthority(); + const successorOrUnknownRemains = [ + cleanupTarget.retainedTranscriptSuccessorPath, + cleanupTarget.retainedTranscriptUnknownPath, + ].some(candidate => candidate !== undefined && !pathIsAbsent(candidate)); + if ( + successorOrUnknownRemains || + !pathIsAbsent(cleanupTarget.retainedTranscriptPlaceholderPath) || + (!cleanupTarget.detachedTranscriptPath && !retainedTranscriptIdentityIsAbsentFromParent()) + ) + return await publishRetainedTranscriptSideAuthority(); cleanupTarget.retainedTranscriptSuccessorPath = undefined; cleanupTarget.retainedTranscriptPlaceholderPath = undefined; cleanupTarget.retainedTranscriptUnknownPath = undefined; @@ -3861,8 +3860,6 @@ async function executeLifecycleResponse( preauthorizedCleanup, "Saved session cleanup is pending in artifacts: a planned quarantine alias remains before terminal completion.", ); - if (deleted.kind === "deleted" && !retainedTranscriptIdentityIsAbsentFromParent()) - return await publishRetainedTranscriptSideAuthority(); const retainedRootArtifactsPlan = durableArtifactsPlan; if (deleted.kind === "cleanup_pending") return fail( diff --git a/packages/coding-agent/src/sdk/bus/chat-daemon-control.ts b/packages/coding-agent/src/sdk/bus/chat-daemon-control.ts index 378364cb7f..5cffa9f083 100644 --- a/packages/coding-agent/src/sdk/bus/chat-daemon-control.ts +++ b/packages/coding-agent/src/sdk/bus/chat-daemon-control.ts @@ -42,11 +42,14 @@ export type ChatDaemonAction = "stop" | "reload"; * replacement to exact native filesystem authority; generation 18 retired that * binding, and generation 19 binds exact cleanup to parent/link-count authority. * Discord generation 21 applies rustfmt and clippy-equivalent cleanup to the - * pi-shell process-tree authority (#3682). + * pi-shell process-tree authority (#3682). Discord generation 22 / slack + * generation 21 refreshes retained cleanup semantics; discord generation 23 / + * slack generation 22 hardens exact Bash process-tree ownership shared by chat + * daemon cleanup. */ export const CHAT_DAEMON_GENERATIONS: Readonly> = { - discord: 21, - slack: 20, + discord: 23, + slack: 22, }; export function chatDaemonGeneration(kind: ChatDaemonKind): number { diff --git a/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts b/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts index 7b79db3439..987d5388e1 100644 --- a/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts +++ b/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts @@ -51,9 +51,10 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3; * 37 retires that binding (revert of #3489, which stalled POSIX artifact * cleanup); generation 38 binds exact cleanup to parent and link-count authority. * Generation 39 applies rustfmt and clippy-equivalent cleanup to the pi-shell - * process-tree authority (#3682). + * process-tree authority (#3682); generation 40 hardens exact Bash process-tree + * ownership, settlement, and descendant cleanup authority. */ -export const DAEMON_GENERATION = 39; +export const DAEMON_GENERATION = 40; /** * Serving-compatibility boundary for daemon lifecycle requests. Epoch 1 covers diff --git a/packages/coding-agent/src/sdk/session-directory.ts b/packages/coding-agent/src/sdk/session-directory.ts index d096ef415c..6f5a89980f 100644 --- a/packages/coding-agent/src/sdk/session-directory.ts +++ b/packages/coding-agent/src/sdk/session-directory.ts @@ -39,6 +39,7 @@ export type ResolveManagedSessionScopeResult = | "sessions_root_unavailable" | "binding_conflict" | "binding_invalid" + | "migration_busy" | "atomic_unavailable" | "durability_not_provable" | "durability_failed" diff --git a/packages/coding-agent/src/session/internal/managed-session-scope.ts b/packages/coding-agent/src/session/internal/managed-session-scope.ts index 1f14b064bb..84036a9005 100644 --- a/packages/coding-agent/src/session/internal/managed-session-scope.ts +++ b/packages/coding-agent/src/session/internal/managed-session-scope.ts @@ -8,13 +8,14 @@ import { verifyOwnerOnlyPathSecurity, verifyOwnerOnlyPathSecurityExpected, } from "@gajae-code/natives"; -import { logger, pathIsWithin } from "@gajae-code/utils"; +import { hasFsCode, logger, pathIsWithin } from "@gajae-code/utils"; import type { ResumeSessionIdentity } from "../session-manager"; import { FileSessionStorage, type NativeDirectoryTreeSnapshot, type SessionStorageFileIdentity, type VerifiedSessionDeleteResult, + type VerifiedSessionDeleteTarget, } from "../session-storage"; import { acquireManagedLock, @@ -127,7 +128,7 @@ function configuredRootPath(scope: ManagedScope): string { const canonical = fs.realpathSync.native(candidate); return suffix.length === 0 ? canonical : path.join(canonical, ...suffix); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (!hasFsCode(error, "ENOENT")) throw error; const parent = path.dirname(candidate); if (parent === candidate) throw new Error("Configured managed root is unavailable."); suffix.unshift(path.basename(candidate)); @@ -163,6 +164,7 @@ export type ManagedScopeErrorCode = | "sessions_root_unavailable" | "binding_conflict" | "binding_invalid" + | "migration_busy" | "atomic_unavailable" | "invalid_request" | "durability_failed" @@ -177,6 +179,24 @@ export type ManagedScopeResolution = cause?: { readonly classification: string; readonly diagnostic?: string }; }; +function managedScopeFailureCause(error: unknown): { readonly classification: string } { + return { classification: managedSecurityFailureClassification(error) ?? "binding_invalid" }; +} + +const managedScopeFailureCodes = new Set([ + "atomic_unavailable", + "invalid_request", + "durability_failed", + "durability_not_provable", + "migration_busy", +]); + +function managedScopeFailureMessage(error: unknown, fallback: string): string { + const classification = managedSecurityFailureClassification(error); + if (classification) return classification; + return error instanceof Error && managedScopeFailureCodes.has(error.message) ? error.message : fallback; +} + export interface ManagedCandidate { sessionId: string; path: string; @@ -222,6 +242,38 @@ export type ManagedDeleteCandidateResult = | { kind: "cleanup_pending"; tombstonePath: string; phase: "artifacts" | "transcript"; message: string } | { kind: "error"; code: ManagedOpenFailure; message: string }; +export interface ManagedVerifiedDeleteTestEvent { + readonly flow: "direct" | "reconcile"; + readonly stage: "initial" | "artifact-finalization" | "transcript-after-artifacts-removed"; +} + +export interface ManagedLockReleaseTestEvent { + readonly path: string; + readonly attemptId: string; +} + +/** Test-only ordering seams for verified deletion and managed-lock release. */ +export const ManagedSessionScopeTestHooks: { + beforeVerifiedDelete?: (event: ManagedVerifiedDeleteTestEvent) => void | Promise; + beforeManagedLockRelease?: (event: ManagedLockReleaseTestEvent) => void | Promise; +} = {}; + +async function deleteSessionVerifiedWithFence( + flow: ManagedVerifiedDeleteTestEvent["flow"], + stage: ManagedVerifiedDeleteTestEvent["stage"], + lock: ManagedStorageLock, + target: VerifiedSessionDeleteTarget, + verifyAuthority?: () => void, +): Promise { + lock.assertOwned(); + verifyAuthority?.(); + const hook = ManagedSessionScopeTestHooks.beforeVerifiedDelete; + if (hook) await hook({ flow, stage }); + lock.assertOwned(); + verifyAuthority?.(); + return new FileSessionStorage().deleteSessionVerified(target); +} + type NativeIdentity = | { ok: true; platform: "posix" | "win32"; canonicalPath: string } | { ok: false; code: NativeIdentityFailureCode }; @@ -277,7 +329,7 @@ function identityFor(cwd: string): NativeIdentity { function verifyExistingManagedScopeDirectory(pathname: string) { if (process.platform !== "win32") return verifyOwnerOnlyPathSecurity(pathname, "directory"); const expected = fs.lstatSync(pathname, { bigint: true }); - if (!expected.isDirectory() || expected.isSymbolicLink()) throw new Error("Unsafe managed directory"); + if (!expected.isDirectory() || expected.isSymbolicLink()) throw new Error("reparse_point"); const verified = verifyOwnerOnlyPathSecurityExpected(pathname, "directory", expected.dev, expected.ino); const current = fs.lstatSync(pathname, { bigint: true }); if ( @@ -286,7 +338,7 @@ function verifyExistingManagedScopeDirectory(pathname: string) { current.dev !== expected.dev || current.ino !== expected.ino ) - throw new Error("Managed session directory changed"); + throw new Error("identity_mismatch"); return verified; } @@ -405,8 +457,18 @@ function validateExistingBinding(scope: ManagedScope): ManagedScopeResolution | try { raw = captureManagedFileNoFollow(bindingPath).bytes.toString("utf8"); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; - return { kind: "error", code: "binding_invalid", message: "The managed scope binding is invalid JSON." }; + if (hasFsCode(error, "ENOENT")) return undefined; + const classification = hasFsCode(error, "EACCES") + ? "EACCES" + : hasFsCode(error, "EPERM") + ? "EPERM" + : "binding_invalid"; + return { + kind: "error", + code: "binding_invalid", + message: "The managed scope binding is invalid JSON.", + cause: { classification }, + }; } return validateBindingRaw(scope, raw); } @@ -429,10 +491,11 @@ function resolveManagedScopeInternal( kind: "error", code: "sessions_root_unavailable", message: "The sessions root is not a safe directory.", + cause: { classification: "reparse_point" }, }; } } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + if (!hasFsCode(error, "ENOENT")) { return { kind: "error", code: "sessions_root_unavailable", @@ -462,10 +525,11 @@ function resolveManagedScopeInternal( kind: "error", code: "sessions_root_unavailable", message: "The sessions root is not a safe directory.", + cause: { classification: "reparse_point" }, }; } } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + if (!hasFsCode(error, "ENOENT")) { return { kind: "error", code: "sessions_root_unavailable", @@ -476,7 +540,12 @@ function resolveManagedScopeInternal( try { const directory = fs.lstatSync(scope.directoryPath); if (!directory.isDirectory() || directory.isSymbolicLink()) { - return { kind: "error", code: "binding_invalid", message: "The managed scope path is not a safe directory." }; + return { + kind: "error", + code: "binding_invalid", + message: "The managed scope path is not a safe directory.", + cause: { classification: "reparse_point" }, + }; } const security = validateNativeSecurityResult( verifyExistingManagedScopeDirectory(scope.directoryPath), @@ -491,8 +560,12 @@ function resolveManagedScopeInternal( }; } } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - return { kind: "error", code: "binding_invalid", message: "The managed scope path could not be inspected." }; + if (!hasFsCode(error, "ENOENT")) { + return { + kind: "error", + code: "binding_invalid", + message: "The managed scope path could not be inspected.", + }; } } return validateExistingBinding(scope) ?? { kind: "resolved", scope }; @@ -557,7 +630,7 @@ function fsyncManagedParent(pathname: string): void { try { descriptor = fs.openSync(parent, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT" && path.dirname(parent) !== parent) { + if (hasFsCode(error, "ENOENT") && path.dirname(parent) !== parent) { parent = path.dirname(parent); continue; } @@ -692,7 +765,6 @@ function inspectCandidate(filePath: string, provenance: "v2" | "legacy"): Manage named.isSymbolicLink() || named.dev !== snapshot.identity.dev || named.ino !== snapshot.identity.ino || - named.nlink !== 1n || Number(named.size) !== snapshot.identity.size || named.mtimeNs !== snapshot.identity.mtimeNs ) @@ -707,7 +779,7 @@ function inspectCandidate(filePath: string, provenance: "v2" | "legacy"): Manage canonicalPath: path.resolve(filePath), sessionId: header.id, ...snapshot.identity, - nlink: named.nlink, + nlink: snapshot.identity.nlink, mtimeMs: Number(named.mtimeMs), sha256: createHash("sha256").update(snapshot.bytes).digest("hex"), }, @@ -781,7 +853,7 @@ export async function ensureManagedScope( try { await publishManagedFileNoReplace(bindingPath, new TextEncoder().encode(binding), undefined, root, policy); } catch (error) { - if ((error as Error).message !== "destination_conflict") throw error; + if (!(error instanceof Error) || error.message !== "destination_conflict") throw error; bindingCollision = true; } const validated = validateExistingBinding(scope); @@ -789,15 +861,14 @@ export async function ensureManagedScope( if (bindingCollision) fsyncCanonicalBinding(bindingPath, binding); const preparedDirectory = fs.lstatSync(scope.directoryPath, { bigint: true }); - if (!preparedDirectory.isDirectory() || preparedDirectory.isSymbolicLink()) - throw new Error("Managed session directory changed"); + if (!preparedDirectory.isDirectory() || preparedDirectory.isSymbolicLink()) throw new Error("reparse_point"); managedDirectoryIdentities.set(scope, { dev: preparedDirectory.dev, ino: preparedDirectory.ino }); return { kind: "resolved", scope }; } catch (error) { const publication = error instanceof ManagedPublishError ? error : undefined; const message = publication?.classification ?? - (error instanceof Error ? error.message : "The managed scope could not be initialized."); + managedScopeFailureMessage(error, "The managed scope could not be initialized."); const code = message === "atomic_unavailable" || message === "invalid_request" || @@ -805,14 +876,13 @@ export async function ensureManagedScope( message === "durability_not_provable" ? message : "binding_invalid"; - return { kind: "error", code, message, - ...(publication - ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } } - : { cause: { classification: code } }), + cause: publication + ? { classification: publication.classification, diagnostic: publication.diagnostic } + : managedScopeFailureCause(error), }; } } @@ -948,7 +1018,7 @@ export function prepareManagedSessionScopeForWriteSync( try { store.publishNoReplaceSync(MANAGED_SESSION_BINDING_FILE, binding); } catch (error) { - if ((error as Error).message !== "destination_conflict") throw error; + if (!(error instanceof Error) || error.message !== "destination_conflict") throw error; } stage = "binding_read"; const capturedBinding = store.readExpected(MANAGED_SESSION_BINDING_FILE); @@ -986,8 +1056,7 @@ export function prepareManagedSessionScopeForWriteSync( } catch (error) { const publication = error instanceof ManagedPublishError ? error : undefined; const message = - publication?.classification ?? - (error instanceof Error ? error.message : "Managed write protocol setup failed."); + publication?.classification ?? managedScopeFailureMessage(error, "Managed write protocol setup failed."); const code = message === "atomic_unavailable" || message === "invalid_request" || @@ -995,14 +1064,14 @@ export function prepareManagedSessionScopeForWriteSync( message === "durability_not_provable" ? message : "binding_invalid"; - const securityClassification = managedSecurityFailureClassification(error); + return { kind: "error", code, message, cause: publication ? { classification: publication.classification, diagnostic: publication.diagnostic } - : { classification: securityClassification ?? code, diagnostic: `prepare:${stage}` }, + : { ...managedScopeFailureCause(error), diagnostic: `prepare:${stage}` }, }; } } @@ -1140,7 +1209,6 @@ function sameCandidate(left: ManagedCandidate, right: ManagedCandidate): boolean left.path === right.path && left.identity.dev === right.identity.dev && left.identity.ino === right.identity.ino && - left.identity.nlink === right.identity.nlink && left.identity.size === right.identity.size && left.identity.mtimeNs === right.identity.mtimeNs && left.identity.sha256 === right.identity.sha256 @@ -1153,7 +1221,6 @@ function matchesExpectedResumeIdentity(candidate: ManagedCandidate, expected: Re candidate.identity.sessionId === expected.sessionId && candidate.identity.dev === expected.dev && candidate.identity.ino === expected.ino && - candidate.identity.nlink === expected.nlink && candidate.identity.size === expected.size && candidate.identity.mtimeMs === expected.mtimeMs && candidate.identity.mtimeNs === expected.mtimeNs && @@ -1218,7 +1285,14 @@ function receiptMatches( state?: unknown; role?: unknown; retainedPath?: unknown; - identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown }; + identity?: { + dev?: unknown; + ino?: unknown; + size?: unknown; + mtimeNs?: unknown; + parentDev?: unknown; + parentIno?: unknown; + }; tree?: unknown; }; }; @@ -1275,6 +1349,8 @@ function receiptMatches( cleanupIdentity.ino !== undefined && cleanupIdentity.size !== undefined && cleanupIdentity.mtimeNs !== undefined && + cleanupIdentity.parentDev !== undefined && + cleanupIdentity.parentIno !== undefined && !!cleanupTree && cleanupAuthorityMatches( { @@ -1286,6 +1362,8 @@ function receiptMatches( ino: BigInt(String(cleanupIdentity.ino)), size: BigInt(String(cleanupIdentity.size)), mtimeNs: BigInt(String(cleanupIdentity.mtimeNs)), + parentDev: BigInt(String(cleanupIdentity.parentDev)), + parentIno: BigInt(String(cleanupIdentity.parentIno)), }, tree: cleanupTree, }, @@ -1368,27 +1446,7 @@ function preparedReceiptMatches( } } -type RetiredTarget = ManagedCandidate & { transcriptParentIdentity: { dev: bigint; ino: bigint } }; - -function authorizeRetiredTarget(scope: ManagedScope, candidate: ManagedCandidate): RetiredTarget | undefined { - try { - const before = fs.lstatSync(path.dirname(candidate.path), { bigint: true }); - if (!before.isDirectory()) return undefined; - const verified = validateCandidateForScope(scope, candidate); - const after = fs.lstatSync(path.dirname(candidate.path), { bigint: true }); - if ( - !verified || - !sameCandidate(verified, candidate) || - !after.isDirectory() || - after.dev !== before.dev || - after.ino !== before.ino - ) - return undefined; - return { ...candidate, transcriptParentIdentity: { dev: before.dev, ino: before.ino } }; - } catch { - return undefined; - } -} +type RetiredTarget = ManagedCandidate; function retiredTargets(scope: ManagedScope, pathname: string): readonly RetiredTarget[] | undefined { try { @@ -1409,14 +1467,6 @@ function retiredTargets(scope: ManagedScope, pathname: string): readonly Retired const identity = item.identity; if (!identity || typeof identity !== "object" || Array.isArray(identity)) return undefined; const fields = identity as Record; - const transcriptParentIdentity = item.transcriptParentIdentity; - if ( - !transcriptParentIdentity || - typeof transcriptParentIdentity !== "object" || - Array.isArray(transcriptParentIdentity) - ) - return undefined; - const parentFields = transcriptParentIdentity as Record; if ( typeof item.path !== "string" || typeof item.sessionId !== "string" || @@ -1426,13 +1476,10 @@ function retiredTargets(scope: ManagedScope, pathname: string): readonly Retired typeof fields.canonicalPath !== "string" || typeof fields.dev !== "string" || typeof fields.ino !== "string" || - typeof fields.nlink !== "string" || typeof fields.size !== "number" || typeof fields.mtimeMs !== "number" || typeof fields.mtimeNs !== "string" || - typeof fields.sha256 !== "string" || - typeof parentFields.dev !== "string" || - typeof parentFields.ino !== "string" + typeof fields.sha256 !== "string" ) return undefined; const provenance = @@ -1451,17 +1498,12 @@ function retiredTargets(scope: ManagedScope, pathname: string): readonly Retired canonicalPath: fields.canonicalPath, dev: BigInt(fields.dev), ino: BigInt(fields.ino), - nlink: BigInt(fields.nlink as string), size: fields.size, mtimeMs: fields.mtimeMs, mtimeNs: BigInt(fields.mtimeNs), sha256: fields.sha256, sessionId: item.sessionId, }, - transcriptParentIdentity: { - dev: BigInt(parentFields.dev as string), - ino: BigInt(parentFields.ino as string), - }, }); } return targets; @@ -1483,13 +1525,19 @@ type RetainedArtifactsRootReceipt = { }; function retainedArtifactsRootReceipt(receipt: CleanupReceipt): RetainedArtifactsRootReceipt | undefined { - const pathname = deterministicRemovalRoot(receipt.plannedArtifactsPath); + const pathname = receipt.detachedArtifactsPath ?? deterministicRemovalRoot(receipt.plannedArtifactsPath); if (!fs.existsSync(pathname)) return undefined; - if (!receipt.expectedArtifactsIdentity) throw new Error("durability_failed"); + if (!isQuarantinePath(receipt.target, pathname) || !receipt.expectedArtifactsIdentity) + throw new Error("durability_failed"); const identity = artifactIdentityAt(pathname); - if (!identity || !sameArtifactRootIdentity(identity, receipt.expectedArtifactsIdentity)) + if ( + !identity || + identity.dev !== receipt.expectedArtifactsIdentity.dev || + identity.ino !== receipt.expectedArtifactsIdentity.ino + ) throw new Error("durability_failed"); const tree = snapshotArtifactTree(pathname); + if (!artifactTreePayloadAbsent(tree)) throw new Error("durability_failed"); return { path: pathname, identity, tree }; } @@ -1500,13 +1548,21 @@ function retainedArtifactsRootMatches(record: Record): boolean const retained = record.retainedArtifactsRoot as Record; const identity = retained.identity; const tree = artifactTreeSnapshot(retained.tree); + const expectedRetainedPath = + typeof record.detachedArtifactsPath === "string" + ? record.detachedArtifactsPath + : typeof record.plannedArtifactsPath === "string" + ? deterministicRemovalRoot(record.plannedArtifactsPath) + : undefined; if ( - typeof record.plannedArtifactsPath !== "string" || - retained.path !== deterministicRemovalRoot(record.plannedArtifactsPath) || + !expectedRetainedPath || + retained.path !== expectedRetainedPath || + record.artifactsPayloadDurable !== true || !identity || typeof identity !== "object" || Array.isArray(identity) || - !tree + !tree || + !artifactTreePayloadAbsent(tree) ) throw new Error("durability_failed"); const artifactIdentity = identity as Record; @@ -1522,14 +1578,9 @@ function retainedArtifactsRootMatches(record: Record): boolean const observed = artifactIdentityAt(retained.path); if ( !observed || - !sameArtifactRootIdentity(observed, { - dev: BigInt(artifactIdentity.dev), - ino: BigInt(artifactIdentity.ino), - size: artifactIdentity.size, - mtimeNs: BigInt(artifactIdentity.mtimeNs), - sha256: artifactIdentity.sha256, - }) || - JSON.stringify(snapshotArtifactTree(retained.path)) !== JSON.stringify(tree) + observed.dev !== BigInt(artifactIdentity.dev) || + observed.ino !== BigInt(artifactIdentity.ino) || + !artifactTreePayloadAbsent(snapshotArtifactTree(retained.path)) ) throw new Error("durability_failed"); return true; @@ -1538,12 +1589,13 @@ function retainedArtifactsRootMatches(record: Record): boolean type CleanupReceipt = { attempt: number; target: RetiredTarget; - transcriptParentIdentity: { dev: bigint; ino: bigint }; expectedArtifactsIdentity?: SessionStorageFileIdentity; - artifactsAbsentAtAuthorization?: true; expectedArtifactsTree?: NativeDirectoryTreeSnapshot; + artifactsPayloadDurable?: true; + artifactsRemovedAttempt?: number; detachedArtifactsPath?: string; detachedTranscriptPath?: string; + transcriptPayloadDurable?: true; retainedArtifactsSuccessorPath?: string; retainedArtifactsPlaceholderPath?: string; retainedArtifactsUnknownPath?: string; @@ -1580,15 +1632,15 @@ function cleanupReceipt(scope: ManagedScope, tombstone: string, receipt: Cleanup cwd: receipt.target.cwd, identity: receipt.target.identity, }, - transcriptParentIdentity: { - dev: receipt.transcriptParentIdentity.dev.toString(), - ino: receipt.transcriptParentIdentity.ino.toString(), - }, ...(receipt.expectedArtifactsIdentity ? { expectedArtifactsIdentity: receipt.expectedArtifactsIdentity } : {}), - ...(receipt.artifactsAbsentAtAuthorization ? { artifactsAbsentAtAuthorization: true as const } : {}), ...(receipt.expectedArtifactsTree ? { expectedArtifactsTree: receipt.expectedArtifactsTree } : {}), + ...(receipt.artifactsPayloadDurable === true ? { artifactsPayloadDurable: true } : {}), + ...(receipt.artifactsRemovedAttempt !== undefined + ? { artifactsRemovedAttempt: receipt.artifactsRemovedAttempt } + : {}), ...(receipt.detachedArtifactsPath ? { detachedArtifactsPath: receipt.detachedArtifactsPath } : {}), ...(receipt.detachedTranscriptPath ? { detachedTranscriptPath: receipt.detachedTranscriptPath } : {}), + ...(receipt.transcriptPayloadDurable === true ? { transcriptPayloadDurable: true } : {}), ...(receipt.retainedArtifactsSuccessorPath ? { retainedArtifactsSuccessorPath: receipt.retainedArtifactsSuccessorPath } : {}), @@ -1612,45 +1664,102 @@ function cleanupReceipt(scope: ManagedScope, tombstone: string, receipt: Cleanup }; } -function cleanupArtifactsRemoved( +type CleanupArtifactsRemovedEvidence = { retainedArtifactsRootPath?: string }; + +function cleanupArtifactsRemovedEvidence( scope: ManagedScope, tombstone: string, target: RetiredTarget, attempt: number, -): boolean { +): CleanupArtifactsRemovedEvidence | undefined { try { const value: unknown = JSON.parse( captureManagedFileNoFollow(cleanupReceiptPath(tombstone, target, "artifacts_removed", attempt)).bytes.toString( "utf8", ), ); - if (!value || typeof value !== "object" || Array.isArray(value)) return false; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record = value as Record; const recorded = record.target as Record | undefined; const identity = recorded?.identity as Record | undefined; - return ( - record.schemaVersion === 2 && - record.state === "artifacts_removed" && - record.scope === scopeDigest(scope.platform, scope.canonicalCwd) && - record.tombstone === tombstone && - record.attempt === attempt && - recorded?.path === target.path && - recorded.sessionId === target.sessionId && - recorded.cwd === target.cwd && - identity?.canonicalPath === target.identity.canonicalPath && - identity.dev === String(target.identity.dev) && - identity.ino === String(target.identity.ino) && - identity.size === target.identity.size && - identity.mtimeNs === String(target.identity.mtimeNs) && - identity.sha256 === target.identity.sha256 && - retainedArtifactsRootMatches(record) - ); + if ( + record.schemaVersion !== 2 || + record.state !== "artifacts_removed" || + record.scope !== scopeDigest(scope.platform, scope.canonicalCwd) || + record.tombstone !== tombstone || + record.attempt !== attempt || + recorded?.path !== target.path || + recorded.sessionId !== target.sessionId || + recorded.cwd !== target.cwd || + identity?.canonicalPath !== target.identity.canonicalPath || + identity.dev !== String(target.identity.dev) || + identity.ino !== String(target.identity.ino) || + identity.size !== target.identity.size || + identity.mtimeNs !== String(target.identity.mtimeNs) || + identity.sha256 !== target.identity.sha256 || + !retainedArtifactsRootMatches(record) + ) + return undefined; + const retained = record.retainedArtifactsRoot as Record | undefined; + return { retainedArtifactsRootPath: typeof retained?.path === "string" ? retained.path : undefined }; } catch (error) { if ((error as Error).message === "durability_failed") throw error; - return false; + return undefined; } } +function cleanupArtifactsRemovedReceipt( + tombstone: string, + target: RetiredTarget, + attempt: number, +): RetainedArtifactsRootReceipt | undefined { + const receiptPath = cleanupReceiptPath(tombstone, target, "artifacts_removed", attempt); + if (!fs.existsSync(receiptPath)) return undefined; + const record = JSON.parse(captureManagedFileNoFollow(receiptPath).bytes.toString("utf8")) as Record; + if (!retainedArtifactsRootMatches(record)) return undefined; + const retained = record.retainedArtifactsRoot as Record | undefined; + const identity = retained?.identity; + const tree = artifactTreeSnapshot(retained?.tree); + if ( + !retained || + typeof retained.path !== "string" || + !identity || + typeof identity !== "object" || + Array.isArray(identity) || + !tree + ) + return undefined; + const typed = identity as Record; + if ( + typeof typed.dev !== "string" || + typeof typed.ino !== "string" || + typeof typed.size !== "number" || + typeof typed.mtimeNs !== "string" || + typeof typed.sha256 !== "string" + ) + return undefined; + return { + path: retained.path, + identity: { + dev: BigInt(typed.dev), + ino: BigInt(typed.ino), + size: typed.size, + mtimeNs: BigInt(typed.mtimeNs), + sha256: typed.sha256, + }, + tree, + }; +} + +function cleanupArtifactsRemoved( + scope: ManagedScope, + tombstone: string, + target: RetiredTarget, + attempt: number, +): boolean { + return cleanupArtifactsRemovedEvidence(scope, tombstone, target, attempt) !== undefined; +} + async function publishCleanupArtifactsRemoved( scope: ManagedScope, tombstone: string, @@ -1700,6 +1809,23 @@ function isAuthorizedArtifactRoot(target: RetiredTarget, plannedRoot: string, pa ); } +function assertAuthorizedCleanupPending( + target: RetiredTarget, + active: CleanupReceipt, + deletion: Extract, +): void { + if ( + (deletion.phase === "artifacts" && + !isAuthorizedArtifactRoot( + target, + active.detachedArtifactsPath ?? active.plannedArtifactsPath, + deletion.detachedArtifactsPath, + )) || + (deletion.phase === "transcript" && deletion.detachedTranscriptPath !== active.plannedTranscriptPath) + ) + throw new Error("durability_failed"); +} + function transcriptRootMatchesTarget(pathname: string, target: RetiredTarget): boolean { try { const observed = captureManagedFileNoFollow(pathname); @@ -1717,16 +1843,36 @@ function transcriptRootMatchesTarget(pathname: string, target: RetiredTarget): b } } -function cleanupTranscriptParentMatches(target: RetiredTarget, pending: CleanupReceipt): boolean { +function reconcileScrubbedTranscriptPlaceholder(pathname: string): boolean { try { - const stat = fs.lstatSync(path.dirname(target.path), { bigint: true }); + const stat = fs.lstatSync(pathname); + return !stat.isSymbolicLink() && stat.isFile() && stat.size === 0 && stat.nlink === 1; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT"; + } +} + +function managedPathPresentNoFollow(pathname: string): boolean { + try { + fs.lstatSync(pathname); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw new Error("durability_failed"); + } +} + +function isScrubbedTranscriptPlaceholder(pathname: string): boolean { + try { + const stat = fs.lstatSync(pathname); + if (stat.isSymbolicLink()) return false; return ( - stat.isDirectory() && - stat.dev === pending.transcriptParentIdentity.dev && - stat.ino === pending.transcriptParentIdentity.ino + (stat.isFile() && stat.size === 0 && stat.nlink === 1) || + (stat.isDirectory() && fs.readdirSync(pathname).length === 0) ); - } catch { - return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw new Error("durability_failed"); } } @@ -1734,32 +1880,16 @@ function cleanupRootsAbsent( tombstone: string, target: RetiredTarget, pending: CleanupReceipt, - includeTranscriptAuthority = true, + allowedRetainedArtifactsRoot?: string, ): boolean { - const transcriptParent = path.dirname(target.path); - const transcriptParentMatches = (): boolean => { - try { - const stat = fs.lstatSync(transcriptParent, { bigint: true }); - return ( - stat.isDirectory() && - stat.dev === pending.transcriptParentIdentity.dev && - stat.ino === pending.transcriptParentIdentity.ino - ); - } catch { - return false; - } - }; - if (!transcriptParentMatches()) return false; const prefix = `${path.basename(tombstone, ".json")}.${stableOperationName(target)}.cleanup-pending-`; - const activeTranscriptRoots = includeTranscriptAuthority - ? new Set( - [pending.plannedTranscriptPath, pending.detachedTranscriptPath].filter((pathname): pathname is string => - isQuarantinePath(target, pathname), - ), - ) - : new Set(); + const activeTranscriptRoots = new Set( + [pending.plannedTranscriptPath, pending.detachedTranscriptPath].filter((pathname): pathname is string => + isQuarantinePath(target, pathname), + ), + ); const roots = new Set([ - ...(includeTranscriptAuthority ? [target.path] : []), + target.path, target.path.slice(0, -6), pending.plannedArtifactsPath, deterministicRemovalRoot(pending.plannedArtifactsPath), @@ -1788,25 +1918,80 @@ function cleanupRootsAbsent( retainedTranscriptSuccessorPath?: unknown; retainedTranscriptPlaceholderPath?: unknown; retainedTranscriptUnknownPath?: unknown; + transcriptPayloadDurable?: unknown; }; if (isQuarantinePath(target, record.plannedArtifactsPath)) { roots.add(record.plannedArtifactsPath); roots.add(deterministicRemovalRoot(record.plannedArtifactsPath)); } if (isQuarantinePath(target, record.detachedArtifactsPath)) roots.add(record.detachedArtifactsPath); + const historicalTranscriptDurable = record.transcriptPayloadDurable === true; for (const pathname of [record.plannedTranscriptPath, record.detachedTranscriptPath]) { - if (isQuarantinePath(target, pathname) && !activeTranscriptRoots.has(pathname)) roots.add(pathname); + if (!isQuarantinePath(target, pathname) || activeTranscriptRoots.has(pathname)) continue; + if (historicalTranscriptDurable || pending.transcriptPayloadDurable === true) { + if (!reconcileScrubbedTranscriptPlaceholder(pathname)) return false; + roots.delete(pathname); + } else roots.add(pathname); } - for (const pathname of [ - record.retainedArtifactsSuccessorPath, - record.retainedArtifactsPlaceholderPath, - record.retainedArtifactsUnknownPath, - record.retainedTranscriptSuccessorPath, - record.retainedTranscriptPlaceholderPath, - record.retainedTranscriptUnknownPath, - ]) { + for (const pathname of [record.retainedArtifactsSuccessorPath, record.retainedArtifactsUnknownPath]) { if (isRetainedNativePath(target, pathname)) roots.add(pathname); } + if (isRetainedNativePath(target, record.retainedArtifactsPlaceholderPath)) { + if (!reconcileScrubbedTranscriptPlaceholder(record.retainedArtifactsPlaceholderPath)) + roots.add(record.retainedArtifactsPlaceholderPath); + } + for (const pathname of [record.retainedTranscriptSuccessorPath, record.retainedTranscriptUnknownPath]) { + if (isRetainedNativePath(target, pathname)) roots.add(pathname); + } + if (isRetainedNativePath(target, record.retainedTranscriptPlaceholderPath)) { + if (historicalTranscriptDurable) { + if (!reconcileScrubbedTranscriptPlaceholder(record.retainedTranscriptPlaceholderPath)) return false; + roots.delete(record.retainedTranscriptPlaceholderPath); + } else roots.add(record.retainedTranscriptPlaceholderPath); + } + } + for (const blocker of [pending.retainedArtifactsSuccessorPath, pending.retainedArtifactsUnknownPath]) { + if (!blocker) continue; + try { + fs.lstatSync(blocker); + return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + if (pending.retainedArtifactsPlaceholderPath) { + if (!reconcileScrubbedTranscriptPlaceholder(pending.retainedArtifactsPlaceholderPath)) return false; + roots.delete(pending.retainedArtifactsPlaceholderPath); + } + if (pending.transcriptPayloadDurable === true) { + for (const blocker of [pending.retainedTranscriptSuccessorPath, pending.retainedTranscriptUnknownPath]) { + if (!blocker) continue; + try { + fs.lstatSync(blocker); + return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + for (const placeholder of [ + target.path, + pending.plannedTranscriptPath, + pending.detachedTranscriptPath, + pending.retainedTranscriptPlaceholderPath, + ].filter((pathname): pathname is string => typeof pathname === "string")) { + if (!reconcileScrubbedTranscriptPlaceholder(placeholder)) return false; + roots.delete(placeholder); + activeTranscriptRoots.delete(placeholder); + } + } + if (allowedRetainedArtifactsRoot) { + if ( + pending.detachedArtifactsPath !== allowedRetainedArtifactsRoot || + !retainedArtifactPayloadAbsent(allowedRetainedArtifactsRoot) + ) + return false; + assertRetainedArtifactsAuthority(pending); + roots.delete(allowedRetainedArtifactsRoot); } for (const pathname of roots) { try { @@ -1827,75 +2012,85 @@ function cleanupRootsAbsent( if (!transcriptRootMatchesTarget(pathname, target)) throw new Error("durability_failed"); return false; } - if (!includeTranscriptAuthority) return true; - const pendingDirectories = [transcriptParent]; - const snapshots: Array<{ path: string; stat: fs.BigIntStats }> = []; - let entryCount = 0; - try { - while (pendingDirectories.length > 0) { - const directory = pendingDirectories.pop(); - if (!directory) return false; - const before = fs.lstatSync(directory, { bigint: true }); - if (!before.isDirectory()) return false; - if ( - directory === transcriptParent && - (before.dev !== pending.transcriptParentIdentity.dev || before.ino !== pending.transcriptParentIdentity.ino) - ) - return false; - snapshots.push({ path: directory, stat: before }); - const entries = fs.readdirSync(directory); - entryCount += entries.length; - if (entryCount > MANAGED_ARTIFACT_MAX_FILES) return false; - for (const name of entries) { - const pathname = path.join(directory, name); - let stat: fs.BigIntStats; - try { - stat = fs.lstatSync(pathname, { bigint: true }); - } catch { - return false; - } - if (stat.dev === target.identity.dev && stat.ino === target.identity.ino) return false; - if (stat.isDirectory()) pendingDirectories.push(pathname); - } - } - for (const snapshot of snapshots) { - const after = fs.lstatSync(snapshot.path, { bigint: true }); - if ( - !after.isDirectory() || - after.dev !== snapshot.stat.dev || - after.ino !== snapshot.stat.ino || - after.mtimeNs !== snapshot.stat.mtimeNs || - after.ctimeNs !== snapshot.stat.ctimeNs - ) - return false; - } - return true; - } catch { - return false; + if (allowedRetainedArtifactsRoot) { + assertRetainedArtifactsAuthority(pending); + if (!retainedArtifactPayloadAbsent(allowedRetainedArtifactsRoot)) return false; } + return true; } function sameArtifactRootIdentity(left: SessionStorageFileIdentity, right: SessionStorageFileIdentity): boolean { return left.dev === right.dev && left.ino === right.ino; } -function sameArtifactTreeSnapshot(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean { - if (left.rootDev !== right.rootDev || left.rootIno !== right.rootIno || left.entries.length !== right.entries.length) - return false; - const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string => - JSON.stringify([ - entry.relativePath, - entry.kind, - entry.dev, - entry.ino, - entry.nlink, - entry.size, - entry.mtimeNs, - entry.sha256, - ]); - const leftEntries = left.entries.map(entryKey).sort(); - const rightEntries = right.entries.map(entryKey).sort(); - return leftEntries.every((entry, index) => entry === rightEntries[index]); +type ManagedArtifactTreeEntry = NativeDirectoryTreeSnapshot["entries"][number]; + +function artifactTreeQuarantineName(entry: ManagedArtifactTreeEntry): string { + const material = Buffer.concat([ + Buffer.from(entry.relativePath), + Buffer.from([0]), + Buffer.from(entry.dev), + Buffer.from([0]), + Buffer.from(entry.ino), + ]); + return `.pi-tree-detached-${createHash("sha256").update(material).digest("hex")}`; +} + +function artifactTreeReplayPathCompatible( + observedPath: string, + expectedPath: string, + expectedByPath: ReadonlyMap, +): boolean { + if (expectedPath === "") return observedPath === ""; + const observedParts = observedPath.split("/"); + const expectedParts = expectedPath.split("/"); + if (observedParts.length !== expectedParts.length) return false; + for (let index = 0; index < expectedParts.length; index += 1) { + const logicalPath = expectedParts.slice(0, index + 1).join("/"); + const logicalEntry = expectedByPath.get(logicalPath); + if (!logicalEntry) return false; + const observedPart = observedParts[index]; + if (observedPart !== expectedParts[index] && observedPart !== artifactTreeQuarantineName(logicalEntry)) + return false; + } + return true; +} + +export function artifactTreeReplayCompatible( + observed: NativeDirectoryTreeSnapshot, + expected: NativeDirectoryTreeSnapshot, +): boolean { + if (observed.rootDev !== expected.rootDev || observed.rootIno !== expected.rootIno) return false; + const emptyDigest = createHash("sha256").update("").digest("hex"); + const expectedByIdentity = new Map( + expected.entries.map(entry => [JSON.stringify([entry.kind, entry.dev, entry.ino]), entry] as const), + ); + const expectedByPath = new Map(expected.entries.map(entry => [entry.relativePath, entry] as const)); + const seen = new Set(); + let rootObserved = false; + const compatible = observed.entries.every(entry => { + const key = JSON.stringify([entry.kind, entry.dev, entry.ino]); + if (seen.has(key)) return false; + seen.add(key); + const original = expectedByIdentity.get(key); + if (!original || !artifactTreeReplayPathCompatible(entry.relativePath, original.relativePath, expectedByPath)) + return false; + if (entry.relativePath === "" && entry.kind === "directory") rootObserved = true; + if (entry.kind === "directory") return true; + return ( + (entry.size === original.size && entry.mtimeNs === original.mtimeNs && entry.sha256 === original.sha256) || + (entry.size === "0" && entry.sha256 === emptyDigest) + ); + }); + return compatible && rootObserved; +} + +function artifactTreePayloadAbsent(snapshot: NativeDirectoryTreeSnapshot): boolean { + const emptyDigest = createHash("sha256").update("").digest("hex"); + return snapshot.entries.every( + entry => + entry.kind === "directory" || (entry.kind === "file" && entry.size === "0" && entry.sha256 === emptyDigest), + ); } function assertRetainedArtifactsAuthority(pending: CleanupReceipt): void { @@ -1908,10 +2103,15 @@ function assertRetainedArtifactsAuthority(pending: CleanupReceipt): void { throw new Error("durability_failed"); } const observed = artifactIdentityAt(pending.detachedArtifactsPath); + const observedTree = snapshotArtifactTree(pending.detachedArtifactsPath); if ( !observed || - !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity) || - !sameArtifactTreeSnapshot(snapshotArtifactTree(pending.detachedArtifactsPath), pending.expectedArtifactsTree) + (pending.artifactsPayloadDurable === true + ? observed.dev !== pending.expectedArtifactsIdentity.dev || + observed.ino !== pending.expectedArtifactsIdentity.ino || + !artifactTreePayloadAbsent(observedTree) + : !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity) || + !artifactTreeReplayCompatible(observedTree, pending.expectedArtifactsTree)) ) throw new Error("binding_invalid"); } @@ -1921,27 +2121,77 @@ function probePlannedCleanupDetach(target: RetiredTarget, pending: CleanupReceip let detachedArtifactsPath = pending.detachedArtifactsPath; let detachedTranscriptPath = pending.detachedTranscriptPath; - for (const pathname of [pending.plannedArtifactsPath, deterministicRemovalRoot(pending.plannedArtifactsPath)]) { + const detachedCandidates = new Set([ + pending.plannedArtifactsPath, + deterministicRemovalRoot(pending.plannedArtifactsPath), + ...(pending.detachedArtifactsPath ? [deterministicRemovalRoot(pending.detachedArtifactsPath)] : []), + ]); + for (const pathname of detachedCandidates) { if (!fs.existsSync(pathname)) continue; - if (!pending.expectedArtifactsIdentity) throw new Error("durability_failed"); + if (!pending.expectedArtifactsIdentity || !pending.expectedArtifactsTree) throw new Error("durability_failed"); const observed = artifactIdentityAt(pathname); - if (!observed || !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity)) - throw new Error("durability_failed"); - if (detachedArtifactsPath && detachedArtifactsPath !== pathname) throw new Error("durability_failed"); - detachedArtifactsPath = pathname; - } - if (fs.existsSync(pending.plannedTranscriptPath)) { - const observed = captureManagedFileNoFollow(pending.plannedTranscriptPath); - const digest = createHash("sha256").update(observed.bytes).digest("hex"); + const observedTree = snapshotArtifactTree(pathname); if ( - observed.identity.dev !== target.identity.dev || - observed.identity.ino !== target.identity.ino || - observed.identity.size !== target.identity.size || - observed.identity.mtimeNs !== target.identity.mtimeNs || - digest !== target.identity.sha256 + !observed || + (pending.artifactsPayloadDurable === true + ? observed.dev !== pending.expectedArtifactsIdentity.dev || + observed.ino !== pending.expectedArtifactsIdentity.ino || + !artifactTreePayloadAbsent(observedTree) + : !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity) || + !artifactTreeReplayCompatible(observedTree, pending.expectedArtifactsTree)) ) throw new Error("durability_failed"); - detachedTranscriptPath = pending.plannedTranscriptPath; + if (detachedArtifactsPath && detachedArtifactsPath !== pathname && fs.existsSync(detachedArtifactsPath)) + throw new Error("durability_failed"); + detachedArtifactsPath = pathname; + } + if (pending.transcriptPayloadDurable === true) { + for (const blocker of [pending.retainedTranscriptSuccessorPath, pending.retainedTranscriptUnknownPath]) { + if (!blocker) continue; + try { + fs.lstatSync(blocker); + throw new Error("durability_failed"); + } catch (error) { + if ((error as Error).message === "durability_failed") throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("durability_failed"); + } + } + for (const placeholder of [ + target.path, + pending.plannedTranscriptPath, + pending.detachedTranscriptPath, + pending.retainedTranscriptPlaceholderPath, + ].filter((pathname): pathname is string => typeof pathname === "string")) + if (!reconcileScrubbedTranscriptPlaceholder(placeholder)) throw new Error("durability_failed"); + return { ...pending, detachedArtifactsPath, detachedTranscriptPath: undefined }; + } + const retainedBlockers = [pending.retainedTranscriptSuccessorPath, pending.retainedTranscriptUnknownPath].filter( + (pathname): pathname is string => typeof pathname === "string" && managedPathPresentNoFollow(pathname), + ); + if (retainedBlockers.length > 0) throw new Error("durability_failed"); + const transcriptCandidates = [ + target.path, + pending.plannedTranscriptPath, + pending.detachedTranscriptPath, + pending.retainedTranscriptPlaceholderPath, + ].filter( + (pathname, index, values): pathname is string => + typeof pathname === "string" && managedPathPresentNoFollow(pathname) && values.indexOf(pathname) === index, + ); + const boundCandidates = transcriptCandidates.filter(pathname => transcriptRootMatchesTarget(pathname, target)); + if (boundCandidates.length > 1) throw new Error("durability_failed"); + if (boundCandidates.length === 1) { + detachedTranscriptPath = boundCandidates[0] === target.path ? undefined : boundCandidates[0]; + } else if (transcriptCandidates.length > 0) { + if (!transcriptCandidates.every(isScrubbedTranscriptPlaceholder)) throw new Error("durability_failed"); + for (const pathname of transcriptCandidates) + if (!reconcileScrubbedTranscriptPlaceholder(pathname)) throw new Error("durability_failed"); + return { + ...pending, + detachedArtifactsPath, + detachedTranscriptPath: undefined, + transcriptPayloadDurable: true, + }; } return { ...pending, detachedArtifactsPath, detachedTranscriptPath }; } @@ -1974,7 +2224,6 @@ function artifactTreeSnapshot(value: unknown): NativeDirectoryTreeSnapshot | und (item.kind === "file" || item.kind === "directory") && typeof item.dev === "string" && typeof item.ino === "string" && - typeof item.nlink === "string" && typeof item.size === "string" && typeof item.mtimeNs === "string" && typeof item.ctimeNs === "string" && @@ -2022,14 +2271,6 @@ function pendingCleanupReceipt( const attempt = record.attempt; const recorded = record.target as Record | undefined; const identity = recorded?.identity as Record | undefined; - const transcriptParentIdentity = record.transcriptParentIdentity as Record | undefined; - if ( - typeof transcriptParentIdentity?.dev === "string" && - typeof transcriptParentIdentity?.ino === "string" && - (transcriptParentIdentity.dev !== target.transcriptParentIdentity.dev.toString() || - transcriptParentIdentity.ino !== target.transcriptParentIdentity.ino.toString()) - ) - throw new Error("durability_failed"); if ( record.schemaVersion !== 2 || record.state !== "cleanup_pending" || @@ -2043,20 +2284,9 @@ function pendingCleanupReceipt( recorded.cwd !== target.cwd || identity?.dev !== String(target.identity.dev) || identity.ino !== String(target.identity.ino) || - identity.nlink !== String(target.identity.nlink) || identity.size !== target.identity.size || identity.mtimeNs !== String(target.identity.mtimeNs) || identity.sha256 !== target.identity.sha256 || - !transcriptParentIdentity || - typeof transcriptParentIdentity.dev !== "string" || - typeof transcriptParentIdentity.ino !== "string" || - !/^[0-9]+$/.test(transcriptParentIdentity.dev) || - !/^[0-9]+$/.test(transcriptParentIdentity.ino) || - transcriptParentIdentity.dev !== target.transcriptParentIdentity.dev.toString() || - transcriptParentIdentity.ino !== target.transcriptParentIdentity.ino.toString() || - (latest !== undefined && - (transcriptParentIdentity.dev !== latest.transcriptParentIdentity.dev.toString() || - transcriptParentIdentity.ino !== latest.transcriptParentIdentity.ino.toString())) || !isQuarantinePath(target, record.plannedArtifactsPath) || !isQuarantinePath(target, record.plannedTranscriptPath) || record.plannedArtifactsPath === record.plannedTranscriptPath || @@ -2075,11 +2305,16 @@ function pendingCleanupReceipt( (record.retainedTranscriptPlaceholderPath !== undefined && !isRetainedNativePath(target, record.retainedTranscriptPlaceholderPath)) || (record.retainedTranscriptUnknownPath !== undefined && - !isRetainedNativePath(target, record.retainedTranscriptUnknownPath)) + !isRetainedNativePath(target, record.retainedTranscriptUnknownPath)) || + (record.artifactsPayloadDurable !== undefined && record.artifactsPayloadDurable !== true) || + (record.artifactsRemovedAttempt !== undefined && + (typeof record.artifactsRemovedAttempt !== "number" || + !Number.isSafeInteger(record.artifactsRemovedAttempt) || + record.artifactsRemovedAttempt < 1 || + record.artifactsRemovedAttempt > (attempt as number))) || + (record.transcriptPayloadDurable !== undefined && record.transcriptPayloadDurable !== true) ) throw new Error("durability_failed"); - if (record.artifactsAbsentAtAuthorization !== undefined && record.artifactsAbsentAtAuthorization !== true) - throw new Error("durability_failed"); const artifact = record.expectedArtifactsIdentity as Record | undefined; const expectedArtifactsIdentity = artifact ? typeof artifact.dev === "string" && @@ -2097,8 +2332,6 @@ function pendingCleanupReceipt( : undefined : undefined; if (artifact && !expectedArtifactsIdentity) throw new Error("durability_failed"); - if (record.artifactsAbsentAtAuthorization === true && expectedArtifactsIdentity) - throw new Error("durability_failed"); const expectedArtifactsTree = record.expectedArtifactsTree === undefined ? undefined : artifactTreeSnapshot(record.expectedArtifactsTree); if (record.expectedArtifactsTree !== undefined && !expectedArtifactsTree) throw new Error("durability_failed"); @@ -2117,15 +2350,13 @@ function pendingCleanupReceipt( latest = { attempt, target, - transcriptParentIdentity: { - dev: BigInt(transcriptParentIdentity.dev as string), - ino: BigInt(transcriptParentIdentity.ino as string), - }, expectedArtifactsIdentity, - artifactsAbsentAtAuthorization: record.artifactsAbsentAtAuthorization === true ? true : undefined, expectedArtifactsTree, + artifactsPayloadDurable: record.artifactsPayloadDurable === true ? true : undefined, + artifactsRemovedAttempt: record.artifactsRemovedAttempt as number | undefined, detachedArtifactsPath: record.detachedArtifactsPath as string | undefined, detachedTranscriptPath: record.detachedTranscriptPath as string | undefined, + transcriptPayloadDurable: record.transcriptPayloadDurable === true ? true : undefined, retainedArtifactsSuccessorPath: record.retainedArtifactsSuccessorPath as string | undefined, retainedArtifactsPlaceholderPath: record.retainedArtifactsPlaceholderPath as string | undefined, retainedArtifactsUnknownPath: record.retainedArtifactsUnknownPath as string | undefined, @@ -2147,13 +2378,7 @@ function artifactIdentityForCleanup(target: RetiredTarget): SessionStorageFileId try { const stat = fs.lstatSync(target.path.slice(0, -6), { bigint: true }); if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("unsafe_artifacts"); - return { - dev: stat.dev, - ino: stat.ino, - size: Number(stat.size), - mtimeNs: stat.mtimeNs, - sha256: "", - }; + return { dev: stat.dev, ino: stat.ino, size: Number(stat.size), mtimeNs: stat.mtimeNs, sha256: "" }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; @@ -2172,10 +2397,18 @@ function snapshotArtifactTree(pathname: string): NativeDirectoryTreeSnapshot { return result.snapshot; } +function retainedArtifactPayloadAbsent(pathname: string): boolean { + try { + return artifactTreePayloadAbsent(snapshotArtifactTree(pathname)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + throw error; + } +} + function nextCleanupReceipt(target: RetiredTarget, pending: CleanupReceipt | undefined): CleanupReceipt { const attempt = (pending?.attempt ?? 0) + 1; const directory = path.dirname(target.path); - const transcriptParentIdentity = pending?.transcriptParentIdentity ?? target.transcriptParentIdentity; const operation = stableOperationName(target); const expectedArtifactsIdentity = pending?.expectedArtifactsIdentity ?? artifactIdentityForCleanup(target); const expectedArtifactsTree = @@ -2185,16 +2418,16 @@ function nextCleanupReceipt(target: RetiredTarget, pending: CleanupReceipt | und return { attempt, target, - transcriptParentIdentity, expectedArtifactsIdentity, - artifactsAbsentAtAuthorization: - pending?.artifactsAbsentAtAuthorization ?? (expectedArtifactsIdentity ? undefined : true), expectedArtifactsTree, + artifactsPayloadDurable: pending?.artifactsPayloadDurable, + artifactsRemovedAttempt: pending?.artifactsRemovedAttempt, detachedArtifactsPath: pending?.detachedArtifactsPath, retainedArtifactsSuccessorPath: pending?.retainedArtifactsSuccessorPath, retainedArtifactsPlaceholderPath: pending?.retainedArtifactsPlaceholderPath, retainedArtifactsUnknownPath: pending?.retainedArtifactsUnknownPath, detachedTranscriptPath: pending?.detachedTranscriptPath, + transcriptPayloadDurable: pending?.transcriptPayloadDurable, retainedTranscriptSuccessorPath: pending?.retainedTranscriptSuccessorPath, retainedTranscriptPlaceholderPath: pending?.retainedTranscriptPlaceholderPath, retainedTranscriptUnknownPath: pending?.retainedTranscriptUnknownPath, @@ -2221,10 +2454,22 @@ function cleanupPendingEvidence( expectedArtifactsIdentity: deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity, expectedArtifactsTree: deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree, + artifactsPayloadDurable: + deletion.phase === "artifacts" + ? deletion.artifactsPayloadDurable + ? true + : undefined + : active.artifactsPayloadDurable, detachedArtifactsPath: deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath, detachedTranscriptPath: deletion.phase === "transcript" ? deletion.detachedTranscriptPath : active.detachedTranscriptPath, + transcriptPayloadDurable: + deletion.phase === "transcript" + ? deletion.transcriptPayloadDurable + ? true + : undefined + : active.transcriptPayloadDurable, retainedArtifactsSuccessorPath: deletion.phase === "artifacts" ? deletion.retainedSuccessorPath : active.retainedArtifactsSuccessorPath, retainedArtifactsPlaceholderPath: @@ -2240,6 +2485,57 @@ function cleanupPendingEvidence( }; } +async function continueDetachedArtifactCleanup( + scope: ManagedScope, + tombstone: string, + target: RetiredTarget, + pendingEvidence: CleanupReceipt, + fallbackDetachedTranscriptPath: string | undefined, + lock: ManagedStorageLock, + flow: ManagedVerifiedDeleteTestEvent["flow"], +): Promise<{ deletion: VerifiedSessionDeleteResult; pendingEvidence: CleanupReceipt }> { + const deletion = await deleteSessionVerifiedWithFence(flow, "artifact-finalization", lock, { + sessionsRoot: scope.sessionsRoot, + transcriptPath: target.path, + sessionId: target.sessionId, + cwd: target.cwd, + transcriptIdentity: target.identity, + transcriptParentIdentity: (() => { + const parent = fs.lstatSync(path.dirname(target.path), { bigint: true }); + return { dev: parent.dev, ino: parent.ino }; + })(), + expectedArtifactsIdentity: pendingEvidence.expectedArtifactsIdentity, + expectedArtifactsTree: pendingEvidence.expectedArtifactsTree, + detachedArtifactsPath: pendingEvidence.detachedArtifactsPath, + retainedArtifactsSuccessorPath: pendingEvidence.retainedArtifactsSuccessorPath, + retainedArtifactsPlaceholderPath: pendingEvidence.retainedArtifactsPlaceholderPath, + retainedArtifactsUnknownPath: pendingEvidence.retainedArtifactsUnknownPath, + detachedTranscriptPath: pendingEvidence.detachedTranscriptPath ?? fallbackDetachedTranscriptPath, + retainedTranscriptSuccessorPath: pendingEvidence.retainedTranscriptSuccessorPath, + retainedTranscriptPlaceholderPath: pendingEvidence.retainedTranscriptPlaceholderPath, + retainedTranscriptUnknownPath: pendingEvidence.retainedTranscriptUnknownPath, + plannedArtifactsPath: pendingEvidence.plannedArtifactsPath, + plannedTranscriptPath: pendingEvidence.plannedTranscriptPath, + }); + if (deletion.kind === "cleanup_pending") { + if ( + deletion.phase !== "artifacts" || + !isAuthorizedArtifactRoot( + target, + pendingEvidence.detachedArtifactsPath ?? pendingEvidence.plannedArtifactsPath, + deletion.detachedArtifactsPath, + ) + ) + throw new Error("durability_failed"); + const followup = nextCleanupReceipt(target, pendingEvidence); + pendingEvidence = cleanupPendingEvidence(followup, pendingEvidence, deletion); + await publishCleanupPending(scope, tombstone, pendingEvidence, lock); + } else if (deletion.kind !== "artifacts_removed") { + throw new Error("durability_failed"); + } + return { deletion, pendingEvidence }; +} + async function publishCleanupPending( scope: ManagedScope, tombstone: string, @@ -2477,7 +2773,7 @@ function manifestContains(transcriptPath: string, manifest: readonly ArtifactMan type DetachedArtifactRoot = { originalPath: string; detachedPath: string; - identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint }; + identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; parentDev: bigint; parentIno: bigint }; tree: NativeDirectoryTreeSnapshot; }; @@ -2494,6 +2790,7 @@ function planArtifactRootForMigration(sourceTranscript: string, operation: strin const tree = snapshotArtifactTree(originalPath); const root = tree.entries.find(entry => entry.relativePath === "" && entry.kind === "directory"); if (!root) throw new Error("unsafe_artifacts"); + const parent = fs.lstatSync(path.dirname(originalPath), { bigint: true }); return { originalPath, detachedPath: path.join(path.dirname(originalPath), `.gjc-migrate-${operation}-artifacts`), @@ -2502,6 +2799,8 @@ function planArtifactRootForMigration(sourceTranscript: string, operation: strin ino: stat.ino, size: process.platform === "win32" ? BigInt(root.size) : stat.size, mtimeNs: process.platform === "win32" ? BigInt(root.mtimeNs) : stat.mtimeNs, + parentDev: parent.dev, + parentIno: parent.ino, }, tree, }; @@ -2581,7 +2880,6 @@ function sameArtifactTree( entry.kind, entry.dev, entry.ino, - entry.nlink, entry.size, entry.mtimeNs, entry.ctimeNs, @@ -2620,12 +2918,15 @@ export function cleanupAuthorityMatches( ): boolean { try { const stat = fs.lstatSync(cleanup.retainedPath, { bigint: true }); + const parentStat = fs.lstatSync(parent, { bigint: true }); if ( path.dirname(cleanup.retainedPath) !== parent || !stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== cleanup.identity.dev || - stat.ino !== cleanup.identity.ino + stat.ino !== cleanup.identity.ino || + parentStat.dev !== cleanup.identity.parentDev || + parentStat.ino !== cleanup.identity.parentIno ) return false; const snapshot = native.snapshotDirectoryTree(cleanup.retainedPath); @@ -2701,6 +3002,7 @@ export function detachArtifactRootForMigration( entry => entry.relativePath === "" && entry.kind === "directory", ); if (!placeholderRoot) throw new Error("durability_failed"); + const parent = fs.lstatSync(path.dirname(placeholder), { bigint: true }); const cleanup: SourceArtifactCleanup = { state: "cleanup_pending", role: "exchange_placeholder", @@ -2710,6 +3012,8 @@ export function detachArtifactRootForMigration( ino: stat.ino, size: platform === "win32" ? BigInt(placeholderRoot.size) : stat.size, mtimeNs: platform === "win32" ? BigInt(placeholderRoot.mtimeNs) : stat.mtimeNs, + parentDev: parent.dev, + parentIno: parent.ino, }, tree: snapshot.snapshot, }; @@ -2762,7 +3066,9 @@ export function restorePreparedArtifactRoot( typeof identity.dev !== "string" || typeof identity.ino !== "string" || typeof identity.size !== "string" || - typeof identity.mtimeNs !== "string" + typeof identity.mtimeNs !== "string" || + typeof identity.parentDev !== "string" || + typeof identity.parentIno !== "string" ) throw new Error("durability_failed"); const artifactIdentity = { @@ -2770,6 +3076,8 @@ export function restorePreparedArtifactRoot( ino: BigInt(identity.ino), size: BigInt(identity.size), mtimeNs: BigInt(identity.mtimeNs), + parentDev: BigInt(identity.parentDev), + parentIno: BigInt(identity.parentIno), }; const expectedTree = artifactTreeSnapshot(quarantine.tree)!; const assertPreparedTree = (pathname: string): void => { @@ -2814,6 +3122,8 @@ export function restorePreparedArtifactRoot( typeof cleanupIdentity.ino !== "string" || typeof cleanupIdentity.size !== "string" || typeof cleanupIdentity.mtimeNs !== "string" || + typeof cleanupIdentity.parentDev !== "string" || + typeof cleanupIdentity.parentIno !== "string" || !cleanupTree || !cleanupAuthorityMatches( { @@ -2825,6 +3135,8 @@ export function restorePreparedArtifactRoot( ino: BigInt(cleanupIdentity.ino), size: BigInt(cleanupIdentity.size), mtimeNs: BigInt(cleanupIdentity.mtimeNs), + parentDev: BigInt(cleanupIdentity.parentDev), + parentIno: BigInt(cleanupIdentity.parentIno), }, tree: cleanupTree, }, @@ -2858,7 +3170,7 @@ export function restorePreparedArtifactRoot( ...artifactIdentity, directory: true, }); - if (!result.ok) throw new Error("durability_failed"); + if (!result.ok && result.code !== "cleanup_pending") throw new Error("durability_failed"); } function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot, cleanup?: SourceArtifactCleanup): void { @@ -2868,7 +3180,7 @@ function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot, cleanup?: S ...detached.identity, directory: true, }); - if (!result.ok) throw new Error("durability_failed"); + if (!result.ok && result.code !== "cleanup_pending") throw new Error("durability_failed"); } async function copyArtifacts( @@ -2972,14 +3284,19 @@ function detachedReceiptMatches(receipt: string, expected: Uint8Array): boolean } async function removeStagedReceipts(scope: ManagedScope, candidate: ManagedCandidate): Promise { + const authority = boundManagedWriteAuthorities.get(scope); + const rootAuthority = authority?.rootAuthority ?? scopeRoot(scope); + const store = authority?.retainedAuthority + ? new ManagedSessionDescendantStore(rootAuthority, scope.directoryPath, { + authority: authority.retainedAuthority, + authorityBaseDir: scope.directoryPath, + }) + : new ManagedSessionDescendantStore(rootAuthority, scope.directoryPath); for (const state of ["prepared", "detached", "published"] as const) { const pathname = receiptPathFor(scope, candidate, state); try { - const stat = fs.lstatSync(pathname); - if (stat.isFile() || stat.isSymbolicLink()) { - await fs.promises.unlink(pathname); - fsyncManagedParent(pathname); - } + const snapshot = captureManagedFileNoFollow(pathname); + store.removeExpected(path.relative(scope.directoryPath, pathname), snapshot); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } @@ -3060,12 +3377,31 @@ export async function reconcileManagedTombstones( fs.lstatSync(target.path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { - if (!observedPending) continue; + const replayReceipt = observedPending ?? nextCleanupReceipt(target, pending); + const artifactsEvidence = cleanupArtifactsRemovedEvidence( + scope, + tombstone, + target, + pending?.artifactsRemovedAttempt ?? + replayReceipt.artifactsRemovedAttempt ?? + pending?.attempt ?? + replayReceipt.attempt, + ); if ( - cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) && - cleanupRootsAbsent(tombstone, target, observedPending) - ) + artifactsEvidence && + cleanupRootsAbsent( + tombstone, + target, + replayReceipt, + artifactsEvidence.retainedArtifactsRootPath, + ) + ) { + fsyncManagedParent(target.path); + await publishCleanupCompleted(scope, tombstone, target, lock); continue; + } + if (!observedPending) continue; + if (!observedPending.detachedTranscriptPath) continue; } else throw error; } const verified = observedPending ? target : validateCandidateForScope(scope, target); @@ -3074,22 +3410,27 @@ export async function reconcileManagedTombstones( !!observedPending && (observedPending.detachedArtifactsPath !== pending?.detachedArtifactsPath || observedPending.detachedTranscriptPath !== pending?.detachedTranscriptPath); - const active = + let active = discoveredDetach || (observedPending && requiresFreshCleanupPlan(observedPending)) ? nextCleanupReceipt(target, observedPending) : (observedPending ?? nextCleanupReceipt(target, undefined)); if (!observedPending || discoveredDetach || requiresFreshCleanupPlan(observedPending)) await publishCleanupPending(scope, tombstone, active, lock); - if (!cleanupTranscriptParentMatches(target, active)) continue; - let deletion = await new FileSessionStorage().deleteSessionVerified({ + const initialTarget = observedPending?.detachedTranscriptPath + ? target + : validateCandidateForScope(scope, target); + if (!initialTarget) throw new Error("source_changed"); + let deletion = await deleteSessionVerifiedWithFence("reconcile", "initial", lock, { sessionsRoot: scope.sessionsRoot, transcriptPath: target.path, sessionId: target.sessionId, cwd: target.cwd, - transcriptIdentity: target.identity, - transcriptParentIdentity: target.transcriptParentIdentity, + transcriptIdentity: { + ...initialTarget.identity, + nlink: fs.lstatSync(observedPending?.detachedTranscriptPath ?? target.path, { bigint: true }) + .nlink, + }, expectedArtifactsIdentity: active.expectedArtifactsIdentity, - artifactsAbsentAtAuthorization: active.artifactsAbsentAtAuthorization, expectedArtifactsTree: active.expectedArtifactsTree, detachedArtifactsPath: active.detachedArtifactsPath ?? @@ -3110,85 +3451,156 @@ export async function reconcileManagedTombstones( retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath, plannedArtifactsPath: active.plannedArtifactsPath, plannedTranscriptPath: active.plannedTranscriptPath, - ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt) + ...(cleanupArtifactsRemoved( + scope, + tombstone, + target, + pending?.artifactsRemovedAttempt ?? + active.artifactsRemovedAttempt ?? + pending?.attempt ?? + active.attempt, + ) ? { artifactsRemoved: true as const } : {}), }); if (deletion.kind === "artifacts_removed") { - if (!cleanupRootsAbsent(tombstone, target, active, false)) continue; - if (!cleanupTranscriptParentMatches(target, active)) continue; await publishCleanupArtifactsRemoved(scope, tombstone, active, lock); - deletion = await new FileSessionStorage().deleteSessionVerified({ - sessionsRoot: scope.sessionsRoot, - transcriptPath: target.path, - sessionId: target.sessionId, - cwd: target.cwd, - transcriptIdentity: target.identity, - transcriptParentIdentity: target.transcriptParentIdentity, - plannedArtifactsPath: active.plannedArtifactsPath, - plannedTranscriptPath: active.plannedTranscriptPath, - detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath, - - retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath, - retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath, - retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath, - retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath, - retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath, - retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath, - artifactsRemoved: true, - }); - } - if (deletion.kind === "cleanup_pending") { - const retry = nextCleanupReceipt(target, active); - await publishCleanupPending( - scope, - tombstone, + active = { ...active, artifactsRemovedAttempt: active.attempt }; + const refreshedTarget = validateCandidateForScope(scope, target); + if (!refreshedTarget) throw new Error("source_changed"); + deletion = await deleteSessionVerifiedWithFence( + "reconcile", + "transcript-after-artifacts-removed", + lock, { - ...retry, - expectedArtifactsIdentity: - deletion.phase === "artifacts" - ? deletion.artifactsIdentity - : active.expectedArtifactsIdentity, - expectedArtifactsTree: - deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree, - detachedArtifactsPath: - deletion.phase === "artifacts" - ? deletion.detachedArtifactsPath - : active.detachedArtifactsPath, + sessionsRoot: scope.sessionsRoot, + transcriptPath: target.path, + sessionId: target.sessionId, + cwd: target.cwd, + transcriptIdentity: refreshedTarget.identity, + plannedArtifactsPath: active.plannedArtifactsPath, + plannedTranscriptPath: active.plannedTranscriptPath, detachedTranscriptPath: - deletion.phase === "transcript" - ? deletion.detachedTranscriptPath - : active.detachedTranscriptPath, - retainedArtifactsSuccessorPath: - deletion.phase === "artifacts" - ? deletion.retainedSuccessorPath - : active.retainedArtifactsSuccessorPath, - retainedArtifactsPlaceholderPath: - deletion.phase === "artifacts" - ? deletion.retainedPlaceholderPath - : active.retainedArtifactsPlaceholderPath, - retainedArtifactsUnknownPath: - deletion.phase === "artifacts" - ? deletion.retainedUnknownPath - : active.retainedArtifactsUnknownPath, - retainedTranscriptSuccessorPath: - deletion.phase === "transcript" - ? deletion.retainedSuccessorPath - : active.retainedTranscriptSuccessorPath, - retainedTranscriptPlaceholderPath: - deletion.phase === "transcript" - ? deletion.retainedPlaceholderPath - : active.retainedTranscriptPlaceholderPath, - retainedTranscriptUnknownPath: - deletion.phase === "transcript" - ? deletion.retainedUnknownPath - : active.retainedTranscriptUnknownPath, + active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath, + + retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath, + retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath, + retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath, + retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath, + retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath, + retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath, + artifactsRemoved: true, }, - lock, ); - continue; } - if (!cleanupRootsAbsent(tombstone, target, active)) continue; + if (deletion.kind === "cleanup_pending") { + assertAuthorizedCleanupPending(target, active, deletion); + const retry = nextCleanupReceipt(target, active); + let pendingEvidence = cleanupPendingEvidence(retry, active, deletion); + await publishCleanupPending(scope, tombstone, pendingEvidence, lock); + if (deletion.phase === "artifacts") { + if ( + deletion.detachedArtifactsPath === active.plannedArtifactsPath && + !retainedArtifactPayloadAbsent(deletion.detachedArtifactsPath) + ) { + ({ deletion, pendingEvidence } = await continueDetachedArtifactCleanup( + scope, + tombstone, + target, + pendingEvidence, + observedPending?.detachedTranscriptPath, + lock, + "reconcile", + )); + } + if ( + deletion.kind === "cleanup_pending" && + deletion.phase === "artifacts" && + (deletion.artifactsPayloadDurable !== true || + !retainedArtifactPayloadAbsent(deletion.detachedArtifactsPath)) + ) + continue; + await publishCleanupArtifactsRemoved(scope, tombstone, pendingEvidence, lock); + pendingEvidence = { ...pendingEvidence, artifactsRemovedAttempt: pendingEvidence.attempt }; + const retainedProof = cleanupArtifactsRemovedReceipt( + tombstone, + target, + pendingEvidence.artifactsRemovedAttempt ?? pendingEvidence.attempt, + ); + if (!retainedProof) throw new Error("durability_failed"); + deletion = await deleteSessionVerifiedWithFence( + "reconcile", + "transcript-after-artifacts-removed", + lock, + { + sessionsRoot: scope.sessionsRoot, + transcriptPath: target.path, + sessionId: target.sessionId, + cwd: target.cwd, + transcriptIdentity: { + ...target.identity, + nlink: fs.lstatSync(target.path, { bigint: true }).nlink, + }, + plannedArtifactsPath: pendingEvidence.plannedArtifactsPath, + plannedTranscriptPath: pendingEvidence.plannedTranscriptPath, + detachedTranscriptPath: + pendingEvidence.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath, + expectedArtifactsIdentity: retainedProof.identity, + expectedArtifactsTree: retainedProof.tree, + detachedArtifactsPath: retainedProof.path, + retainedArtifactsSuccessorPath: pendingEvidence.retainedArtifactsSuccessorPath, + retainedArtifactsPlaceholderPath: pendingEvidence.retainedArtifactsPlaceholderPath, + retainedArtifactsUnknownPath: pendingEvidence.retainedArtifactsUnknownPath, + retainedTranscriptSuccessorPath: pendingEvidence.retainedTranscriptSuccessorPath, + retainedTranscriptPlaceholderPath: pendingEvidence.retainedTranscriptPlaceholderPath, + retainedTranscriptUnknownPath: pendingEvidence.retainedTranscriptUnknownPath, + artifactsRemoved: true, + }, + () => { + if ( + !cleanupArtifactsRemoved( + scope, + tombstone, + target, + pendingEvidence.artifactsRemovedAttempt ?? pendingEvidence.attempt, + ) + ) + throw new Error("durability_failed"); + }, + ); + if (deletion.kind === "cleanup_pending") { + if ( + deletion.phase !== "transcript" || + (deletion.detachedTranscriptPath !== pendingEvidence.plannedTranscriptPath && + deletion.transcriptPayloadDurable !== true) + ) + throw new Error("durability_failed"); + const followup = nextCleanupReceipt(target, pendingEvidence); + pendingEvidence = cleanupPendingEvidence(followup, pendingEvidence, deletion); + await publishCleanupPending(scope, tombstone, pendingEvidence, lock); + } + if (deletion.kind === "deleted" && fs.existsSync(pendingEvidence.plannedTranscriptPath)) { + if (!reconcileScrubbedTranscriptPlaceholder(pendingEvidence.plannedTranscriptPath)) + throw new Error("durability_failed"); + } + } + if ( + deletion.kind === "cleanup_pending" && + (deletion.phase !== "transcript" || + deletion.transcriptPayloadDurable !== true || + !cleanupRootsAbsent( + tombstone, + target, + pendingEvidence, + pendingEvidence.artifactsPayloadDurable === true && + pendingEvidence.detachedArtifactsPath && + retainedArtifactPayloadAbsent(pendingEvidence.detachedArtifactsPath) + ? pendingEvidence.detachedArtifactsPath + : undefined, + )) + ) + continue; + } fsyncManagedParent(target.path); await publishCleanupCompleted(scope, tombstone, target, lock); } catch (error) { @@ -3201,7 +3613,7 @@ export async function reconcileManagedTombstones( } } } finally { - if (lock) await lock.release().catch(() => undefined); + if (lock) await lock.release(); } } } @@ -3230,31 +3642,27 @@ export async function prepareManagedSessionScopeForWrite( } catch (error) { const publication = error instanceof ManagedPublishError ? error : undefined; const message = - publication?.classification ?? - (error instanceof Error ? error.message : "Managed write protocol setup failed."); + publication?.classification ?? managedScopeFailureMessage(error, "Managed write protocol setup failed."); const code = message === "atomic_unavailable" || message === "invalid_request" || message === "durability_failed" || - message === "durability_not_provable" + message === "durability_not_provable" || + message === "migration_busy" ? message : "binding_invalid"; return { kind: "error", code, message, - ...(publication - ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } } - : { cause: { classification: code } }), + cause: publication + ? { classification: publication.classification, diagnostic: publication.diagnostic } + : managedScopeFailureCause(error), }; } } -/** - * Open a validated candidate for mutation. Legacy transcripts are copied exactly once - * into v2 and retained at their original location; no transcript data is merged. - */ -export async function openManagedCandidateForWrite( +async function openManagedCandidateForWriteInternal( scope: ManagedScope, candidate: ManagedCandidate, expectedIdentityOrMigrationPolicy: ResumeSessionIdentity | ManagedMigrationPolicy = "copy-retain", @@ -3575,12 +3983,43 @@ export async function openManagedCandidateForWrite( : "Managed migration failed.", }; } finally { - if (lock) await lock.release().catch(() => undefined); + if (lock) { + await ManagedSessionScopeTestHooks.beforeManagedLockRelease?.({ path: lock.path, attemptId: lock.attemptId }); + await lock.release(); + } } } -/** Tombstone a verified managed candidate before exact-identity deletion. */ -export async function deleteManagedSessionCandidate( +/** + * Open a validated candidate for mutation. Legacy transcripts are copied exactly once + * into v2 and retained at their original location; no transcript data is merged. + */ +export async function openManagedCandidateForWrite( + scope: ManagedScope, + candidate: ManagedCandidate, + expectedIdentityOrMigrationPolicy: ResumeSessionIdentity | ManagedMigrationPolicy = "copy-retain", + migrationPolicy: ManagedMigrationPolicy = typeof expectedIdentityOrMigrationPolicy === "string" + ? expectedIdentityOrMigrationPolicy + : "copy-retain", + authority?: ManagedCandidateWriteAuthority, +): Promise { + try { + return await openManagedCandidateForWriteInternal( + scope, + candidate, + expectedIdentityOrMigrationPolicy, + migrationPolicy, + authority, + ); + } catch (error) { + const code = expectedFailure(error); + if (code === "migration_busy") + return { kind: "error", code, message: error instanceof Error ? error.message : "migration_busy" }; + throw error; + } +} + +async function deleteManagedSessionCandidateInternal( scope: ManagedScope, candidate: ManagedCandidate, ): Promise { @@ -3611,11 +4050,7 @@ export async function deleteManagedSessionCandidate( let targets = retiredTargets(scope, tombstone); if (!targets) { if (!current) throw new Error("source_changed"); - const authorizedTargets = [current, ...(paired ? [paired] : [])].map(target => - authorizeRetiredTarget(scope, target), - ); - if (authorizedTargets.some(target => target === undefined)) throw new Error("source_changed"); - targets = authorizedTargets as RetiredTarget[]; + targets = [current, ...(paired ? [paired] : [])]; lock.assertOwned(); try { await publishManagedTombstone( @@ -3630,10 +4065,6 @@ export async function deleteManagedSessionCandidate( cwd: target.cwd, provenance: target.provenance, identity: target.identity, - transcriptParentIdentity: { - dev: target.transcriptParentIdentity.dev.toString(), - ino: target.transcriptParentIdentity.ino.toString(), - }, })), }, lock.assertOwned, @@ -3649,25 +4080,28 @@ export async function deleteManagedSessionCandidate( lock.assertOwned(); const pending = pendingCleanupReceipt(scope, tombstone, target); const observedPending = pending ? probePlannedCleanupDetach(target, pending) : undefined; - if (cleanupCompleted(scope, tombstone, target)) continue; try { fs.lstatSync(target.path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { if (!observedPending) continue; + const artifactsEvidence = cleanupArtifactsRemovedEvidence( + scope, + tombstone, + target, + observedPending.artifactsRemovedAttempt ?? observedPending.attempt, + ); if ( - cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) && - cleanupRootsAbsent(tombstone, target, observedPending) - ) - return { - kind: "cleanup_pending", - tombstonePath: tombstone, - phase: "transcript", - message: - "Exact cleanup remains pending because transcript authority disappeared without native deletion proof.", - }; + artifactsEvidence && + cleanupRootsAbsent(tombstone, target, observedPending, artifactsEvidence.retainedArtifactsRootPath) + ) { + fsyncManagedParent(target.path); + await publishCleanupCompleted(scope, tombstone, target, lock); + continue; + } } else throw error; } + if (cleanupCompleted(scope, tombstone, target)) continue; deletedAny = true; const verified = observedPending ? target : validateCandidateForScope(scope, target); if (!verified || !sameCandidate(verified, target)) throw new Error("source_changed"); @@ -3675,30 +4109,25 @@ export async function deleteManagedSessionCandidate( !!observedPending && (observedPending.detachedArtifactsPath !== pending?.detachedArtifactsPath || observedPending.detachedTranscriptPath !== pending?.detachedTranscriptPath); - const active = + let active = discoveredDetach || (observedPending && requiresFreshCleanupPlan(observedPending)) ? nextCleanupReceipt(target, observedPending) : (observedPending ?? nextCleanupReceipt(target, undefined)); if (!observedPending || discoveredDetach || requiresFreshCleanupPlan(observedPending)) await publishCleanupPending(scope, tombstone, active, lock); - if (!cleanupTranscriptParentMatches(target, active)) - return { - kind: "cleanup_pending", - tombstonePath: tombstone, - phase: cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt) - ? "transcript" - : "artifacts", - message: "Exact cleanup remains pending because transcript parent identity changed before mutation.", - }; - let deletion = await new FileSessionStorage().deleteSessionVerified({ + const initialTarget = validateCandidateForScope(scope, target); + if (!initialTarget) throw new Error("source_changed"); + let deletion = await deleteSessionVerifiedWithFence("direct", "initial", lock, { sessionsRoot: scope.sessionsRoot, transcriptPath: target.path, sessionId: target.sessionId, cwd: target.cwd, - transcriptIdentity: target.identity, - transcriptParentIdentity: target.transcriptParentIdentity, + transcriptIdentity: initialTarget.identity, + transcriptParentIdentity: (() => { + const parent = fs.lstatSync(path.dirname(target.path), { bigint: true }); + return { dev: parent.dev, ino: parent.ino }; + })(), expectedArtifactsIdentity: active.expectedArtifactsIdentity, - artifactsAbsentAtAuthorization: active.artifactsAbsentAtAuthorization, expectedArtifactsTree: active.expectedArtifactsTree, detachedArtifactsPath: active.detachedArtifactsPath ?? observedPending?.detachedArtifactsPath, detachedTranscriptPath: @@ -3713,33 +4142,26 @@ export async function deleteManagedSessionCandidate( retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath, plannedArtifactsPath: active.plannedArtifactsPath, plannedTranscriptPath: active.plannedTranscriptPath, - ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt) + ...(cleanupArtifactsRemoved( + scope, + tombstone, + target, + pending?.artifactsRemovedAttempt ?? active.artifactsRemovedAttempt ?? pending?.attempt ?? active.attempt, + ) ? { artifactsRemoved: true as const } : {}), }); if (deletion.kind === "artifacts_removed") { - if (!cleanupRootsAbsent(tombstone, target, active, false)) - return { - kind: "cleanup_pending", - tombstonePath: tombstone, - phase: "artifacts", - message: "Exact cleanup remains pending because artifact or side-path authority is retained.", - }; - if (!cleanupTranscriptParentMatches(target, active)) - return { - kind: "cleanup_pending", - tombstonePath: tombstone, - phase: "transcript", - message: "Exact cleanup remains pending because transcript parent identity changed before mutation.", - }; await publishCleanupArtifactsRemoved(scope, tombstone, active, lock); - deletion = await new FileSessionStorage().deleteSessionVerified({ + active = { ...active, artifactsRemovedAttempt: active.attempt }; + const refreshedTarget = validateCandidateForScope(scope, target); + if (!refreshedTarget) throw new Error("source_changed"); + deletion = await deleteSessionVerifiedWithFence("direct", "transcript-after-artifacts-removed", lock, { sessionsRoot: scope.sessionsRoot, transcriptPath: target.path, sessionId: target.sessionId, cwd: target.cwd, - transcriptIdentity: target.identity, - transcriptParentIdentity: target.transcriptParentIdentity, + transcriptIdentity: refreshedTarget.identity, plannedArtifactsPath: active.plannedArtifactsPath, plannedTranscriptPath: active.plannedTranscriptPath, detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath, @@ -3753,32 +4175,123 @@ export async function deleteManagedSessionCandidate( }); } if (deletion.kind === "cleanup_pending") { + assertAuthorizedCleanupPending(target, active, deletion); + const retry = nextCleanupReceipt(target, active); + let pendingEvidence = cleanupPendingEvidence(retry, active, deletion); + await publishCleanupPending(scope, tombstone, pendingEvidence, lock); + if (deletion.phase === "artifacts") { + if ( + (deletion.artifactsPayloadDurable === true && + retainedArtifactPayloadAbsent(deletion.detachedArtifactsPath)) || + (deletion.detachedArtifactsPath === active.plannedArtifactsPath && + !retainedArtifactPayloadAbsent(deletion.detachedArtifactsPath)) + ) { + ({ deletion, pendingEvidence } = await continueDetachedArtifactCleanup( + scope, + tombstone, + target, + pendingEvidence, + observedPending?.detachedTranscriptPath, + lock, + "direct", + )); + } + if ( + deletion.kind === "cleanup_pending" && + deletion.phase === "artifacts" && + (deletion.artifactsPayloadDurable !== true || + !retainedArtifactPayloadAbsent(deletion.detachedArtifactsPath)) + ) + return { + kind: "cleanup_pending", + tombstonePath: tombstone, + phase: deletion.phase, + message: "Exact cleanup remains pending because descriptor-bound final deletion is unavailable.", + }; + await publishCleanupArtifactsRemoved(scope, tombstone, pendingEvidence, lock); + pendingEvidence = { ...pendingEvidence, artifactsRemovedAttempt: pendingEvidence.attempt }; + const retainedProof = cleanupArtifactsRemovedReceipt( + tombstone, + target, + pendingEvidence.artifactsRemovedAttempt ?? pendingEvidence.attempt, + ); + if (!retainedProof) throw new Error("durability_failed"); + deletion = await deleteSessionVerifiedWithFence( + "direct", + "transcript-after-artifacts-removed", + lock, + { + sessionsRoot: scope.sessionsRoot, + transcriptPath: target.path, + sessionId: target.sessionId, + cwd: target.cwd, + transcriptIdentity: { + ...target.identity, + nlink: fs.lstatSync(target.path, { bigint: true }).nlink, + }, + plannedArtifactsPath: pendingEvidence.plannedArtifactsPath, + plannedTranscriptPath: pendingEvidence.plannedTranscriptPath, + detachedTranscriptPath: + pendingEvidence.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath, + expectedArtifactsIdentity: retainedProof.identity, + expectedArtifactsTree: retainedProof.tree, + detachedArtifactsPath: retainedProof.path, + retainedArtifactsSuccessorPath: pendingEvidence.retainedArtifactsSuccessorPath, + retainedArtifactsPlaceholderPath: pendingEvidence.retainedArtifactsPlaceholderPath, + retainedArtifactsUnknownPath: pendingEvidence.retainedArtifactsUnknownPath, + retainedTranscriptSuccessorPath: pendingEvidence.retainedTranscriptSuccessorPath, + retainedTranscriptPlaceholderPath: pendingEvidence.retainedTranscriptPlaceholderPath, + retainedTranscriptUnknownPath: pendingEvidence.retainedTranscriptUnknownPath, + artifactsRemoved: true, + }, + () => { + if ( + !cleanupArtifactsRemoved( + scope, + tombstone, + target, + pendingEvidence.artifactsRemovedAttempt ?? pendingEvidence.attempt, + ) + ) + throw new Error("durability_failed"); + }, + ); + if (deletion.kind === "cleanup_pending") { + if ( + deletion.phase !== "transcript" || + (deletion.detachedTranscriptPath !== pendingEvidence.plannedTranscriptPath && + deletion.transcriptPayloadDurable !== true) + ) + throw new Error("durability_failed"); + const followup = nextCleanupReceipt(target, pendingEvidence); + pendingEvidence = cleanupPendingEvidence(followup, pendingEvidence, deletion); + await publishCleanupPending(scope, tombstone, pendingEvidence, lock); + } + } if ( - (deletion.phase === "artifacts" && - !isAuthorizedArtifactRoot( + deletion.kind === "cleanup_pending" && + (deletion.phase !== "transcript" || + deletion.transcriptPayloadDurable !== true || + !cleanupRootsAbsent( + tombstone, target, - active.detachedArtifactsPath ?? active.plannedArtifactsPath, - deletion.detachedArtifactsPath, - )) || - (deletion.phase === "transcript" && deletion.detachedTranscriptPath !== active.plannedTranscriptPath) + pendingEvidence, + pendingEvidence.artifactsPayloadDurable === true && + pendingEvidence.detachedArtifactsPath && + retainedArtifactPayloadAbsent(pendingEvidence.detachedArtifactsPath) + ? pendingEvidence.detachedArtifactsPath + : undefined, + )) ) - throw new Error("durability_failed"); - const retry = nextCleanupReceipt(target, active); - await publishCleanupPending(scope, tombstone, cleanupPendingEvidence(retry, active, deletion), lock); - return { - kind: "cleanup_pending", - tombstonePath: tombstone, - phase: deletion.phase, - message: "Exact cleanup remains pending because descriptor-bound final deletion is unavailable.", - }; + return { + kind: "cleanup_pending", + tombstonePath: tombstone, + phase: deletion.phase, + message: deletion.error.message, + }; + // A retained transcript quarantine proves canonical absence and remains + // identity-bound in the durable pending receipt. } - if (!cleanupRootsAbsent(tombstone, target, active)) - return { - kind: "cleanup_pending", - tombstonePath: tombstone, - phase: "transcript", - message: "Exact cleanup remains pending because transcript or artifact authority is retained.", - }; fsyncManagedParent(target.path); await publishCleanupCompleted(scope, tombstone, target, lock); } @@ -3787,6 +4300,24 @@ export async function deleteManagedSessionCandidate( const code = expectedFailure(error); return { kind: "error", code, message: error instanceof Error ? error.message : "Managed deletion failed." }; } finally { - if (lock) await lock.release().catch(() => undefined); + if (lock) { + await ManagedSessionScopeTestHooks.beforeManagedLockRelease?.({ path: lock.path, attemptId: lock.attemptId }); + await lock.release(); + } + } +} + +/** Tombstone a verified managed candidate before exact-identity deletion. */ +export async function deleteManagedSessionCandidate( + scope: ManagedScope, + candidate: ManagedCandidate, +): Promise { + try { + return await deleteManagedSessionCandidateInternal(scope, candidate); + } catch (error) { + const code = expectedFailure(error); + if (code === "migration_busy") + return { kind: "error", code, message: error instanceof Error ? error.message : "migration_busy" }; + throw error; } } diff --git a/packages/coding-agent/src/session/internal/managed-session-storage.ts b/packages/coding-agent/src/session/internal/managed-session-storage.ts index da9c9163fc..e03057de73 100644 --- a/packages/coding-agent/src/session/internal/managed-session-storage.ts +++ b/packages/coding-agent/src/session/internal/managed-session-storage.ts @@ -6,6 +6,8 @@ import { applyOwnerOnlyFdSecurity, applyOwnerOnlyPathSecurity, exactRemoveDirectoryTree, + exactReplacePath, + exactRestore, exactUnlink, type NativeDirectoryTreeSnapshot, type NativeOwnerOnlySecurityResult, @@ -34,7 +36,6 @@ const LOCK_LEASE_MS = 60_000; const LOCK_HEARTBEAT_MS = 10_000; const LOCK_WAIT_MS = 5_000; -const LOCK_STALE_RECHECK_MS = 100; export class ManagedPublishError extends Error { readonly classification: | "destination_conflict" @@ -138,7 +139,38 @@ export interface ManagedStorageLock { export interface ManagedFileSnapshot { bytes: Buffer; - identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint; ctimeNs: bigint; sha256: string }; + identity: { + dev: bigint; + ino: bigint; + nlink: bigint; + size: number; + mtimeNs: bigint; + ctimeNs: bigint; + sha256: string; + }; +} + +type ReplacementCleanupReceipt = { + version: 1; + predecessor: string; + successor: string; + identity: { + dev: string; + ino: string; + nlink: string; + size: string; + mtimeNs: string; + ctimeNs: string; + sha256: string; + }; +}; + +const U64_MAX = 18_446_744_073_709_551_615n; + +function parseCanonicalU64(value: unknown): bigint | undefined { + if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) return undefined; + const parsed = BigInt(value); + return parsed <= U64_MAX ? parsed : undefined; } const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]); @@ -264,8 +296,19 @@ type LockRecord = { createdAt: number; heartbeatAt: number; leaseExpiresAt: number; + released?: boolean; }; +export interface ManagedLockRetirementTestEvent { + readonly path: string; + readonly attemptId: string; +} + +/** Test-only seam immediately before exact retirement of one observed lock identity. */ +export const ManagedLockTestHooks: { + beforeObservedRetirement?: (event: ManagedLockRetirementTestEvent) => void; +} = {}; + /** Captured configured-root authority for managed paths only. */ export interface ManagedDirectoryRoot { readonly canonicalPath: string; @@ -475,6 +518,7 @@ export class ManagedSessionDescendantStore { readonly #authority: RecoveryFsRoot | undefined; #ownsAuthority = false; #closed = false; + #reconcilingReplacementCleanup = false; readonly #authorityBaseDir: string; /** Logical profile root inherited by nested managed session destinations. */ readonly #profileAgentDir: string; @@ -650,7 +694,86 @@ export class ManagedSessionDescendantStore { } } + #reconcileReplacementCleanupReceipts(): void { + if (this.#reconcilingReplacementCleanup) return; + this.#reconcilingReplacementCleanup = true; + try { + for (const name of fs.readdirSync(this.#baseDir)) { + const match = /^\.gjc-replace-cleanup-([0-9a-f]+)-([0-9a-f]+)\.json$/.exec(name); + if (!match) continue; + const receiptPath = path.join(this.#baseDir, name); + const parsed = JSON.parse( + captureManagedFileNoFollow(receiptPath).bytes.toString("utf8"), + ) as Partial; + const identity = parsed.identity; + const dev = parseCanonicalU64(identity?.dev); + const ino = parseCanonicalU64(identity?.ino); + const nlink = parseCanonicalU64(identity?.nlink); + const size = parseCanonicalU64(identity?.size); + const mtimeNs = parseCanonicalU64(identity?.mtimeNs); + const ctimeNs = parseCanonicalU64(identity?.ctimeNs); + const expectedPredecessor = + dev !== undefined && ino !== undefined + ? path.join(this.#baseDir, `.gjc-exact-replace-destination-${dev.toString(16)}-${ino.toString(16)}`) + : undefined; + if ( + parsed.version !== 1 || + dev === undefined || + ino === undefined || + nlink === undefined || + size === undefined || + mtimeNs === undefined || + ctimeNs === undefined || + typeof identity?.sha256 !== "string" || + !match[1] || + !match[2] || + BigInt(`0x${match[1]}`) !== dev || + BigInt(`0x${match[2]}`) !== ino || + typeof parsed.predecessor !== "string" || + path.resolve(parsed.predecessor) !== expectedPredecessor || + typeof parsed.successor !== "string" || + path.dirname(path.resolve(parsed.successor)) !== this.#baseDir + ) + throw new Error("managed_replace_cleanup_receipt_invalid"); + const retired = exactUnlink(expectedPredecessor, { + dev, + ino, + nlink, + parentDev: this.#subtreeRoot.dev, + parentIno: this.#subtreeRoot.ino, + size, + mtimeNs, + sha256: identity.sha256, + quarantineName: `.gjc-replace-retry-${dev.toString(16)}-${ino.toString(16)}`, + }); + if (!retired.ok) throw new Error(`managed_replace_cleanup_pending:${retired.code ?? "unknown"}`); + const receipt = captureManagedFileNoFollow(receiptPath); + const removed = exactUnlink(receiptPath, { + dev: receipt.identity.dev, + ino: receipt.identity.ino, + nlink: receipt.identity.nlink, + parentDev: this.#subtreeRoot.dev, + parentIno: this.#subtreeRoot.ino, + size: BigInt(receipt.identity.size), + mtimeNs: receipt.identity.mtimeNs, + sha256: receipt.identity.sha256, + quarantineName: `.gjc-receipt-remove-${receipt.identity.dev.toString(16)}-${receipt.identity.ino.toString(16)}`, + }); + if (!removed.ok) throw new Error(`managed_replace_receipt_cleanup_pending:${removed.code ?? "unknown"}`); + } + } finally { + this.#reconcilingReplacementCleanup = false; + } + } + + #beforeMutation(): void { + this.#assertBound(); + this.#reconcileReplacementCleanupReceipts(); + this.#assertBound(); + } + ensureDirectory(relativePath = ""): ManagedDirectoryRoot { + this.#beforeMutation(); this.#assertBound(); if (this.#authority) { const relative = this.#relative(this.#resolve(relativePath)); @@ -670,6 +793,7 @@ export class ManagedSessionDescendantStore { } async publishNoReplace(relativePath: string, bytes: Uint8Array): Promise { + this.#beforeMutation(); const resolved = this.#resolve(relativePath); if (this.#authority) { this.#assertBound(); @@ -681,6 +805,7 @@ export class ManagedSessionDescendantStore { } publishNoReplaceSync(relativePath: string, bytes: Uint8Array): void { + this.#beforeMutation(); const resolved = this.#resolve(relativePath); if (!this.#authority) { publishManagedFileNoReplaceSync(resolved, bytes, this.#root, this.#policy); @@ -693,10 +818,17 @@ export class ManagedSessionDescendantStore { } async replace(relativePath: string, bytes: Uint8Array): Promise { + this.#beforeMutation(); this.#assertBound(); const resolved = this.#resolve(relativePath); if (!this.#authority) { - await replaceManagedFile(resolved, bytes, this.#subtreeRoot, this.#policy); + try { + const expected = captureManagedFileNoFollow(resolved); + replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + await publishManagedFileNoReplace(resolved, bytes, undefined, this.#subtreeRoot, this.#policy); + } this.#assertBound(); return; } @@ -705,11 +837,56 @@ export class ManagedSessionDescendantStore { this.#assertBound(); } + replaceExpected(relativePath: string, bytes: Uint8Array, expected: ManagedFileSnapshot): void { + this.#beforeMutation(); + this.#assertBound(); + const resolved = this.#resolve(relativePath); + if (!this.#authority) { + const current = captureManagedFileNoFollow(resolved); + if (!sameIdentity(current.identity, expected.identity) || current.identity.sha256 !== expected.identity.sha256) + throw new Error("managed_replace_identity_mismatch"); + replaceManagedFileSync( + resolved, + bytes, + this.#subtreeRoot, + this.#policy, + () => { + const currentAtCommit = captureManagedFileNoFollow(resolved); + if ( + !sameIdentity(currentAtCommit.identity, expected.identity) || + currentAtCommit.identity.sha256 !== expected.identity.sha256 + ) + throw new Error("managed_replace_identity_mismatch"); + }, + expected.identity, + ); + return; + } + const replaced = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).replaceManaged( + this.#relative(resolved), + bytes, + expected.identity.dev.toString(), + expected.identity.ino.toString(), + String(expected.identity.size), + expected.identity.mtimeNs.toString(), + expected.identity.ctimeNs.toString(), + expected.identity.sha256, + ); + if (!replaced.ok) throw new Error(replaced.code ?? "managed_replace_failed"); + this.#assertBound(); + } replaceSync(relativePath: string, bytes: Uint8Array): void { + this.#beforeMutation(); this.#assertBound(); const resolved = this.#resolve(relativePath); if (!this.#authority) { - replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy); + try { + const expected = captureManagedFileNoFollow(resolved); + replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + publishManagedFileNoReplaceSync(resolved, bytes, this.#subtreeRoot, this.#policy); + } this.#assertBound(); return; } @@ -718,6 +895,7 @@ export class ManagedSessionDescendantStore { } appendSync(relativePath: string, bytes: Uint8Array): void { + this.#beforeMutation(); this.#assertBound(); const resolved = this.#resolve(relativePath); let existing = this.readExpected(relativePath); @@ -915,6 +1093,7 @@ export class ManagedSessionDescendantStore { identity: { dev: BigInt(read.identity.dev), ino: BigInt(read.identity.ino), + nlink: BigInt(read.identity.nlink), size: Number(read.identity.size), mtimeNs: BigInt(read.identity.mtimeNs), ctimeNs: BigInt(read.identity.ctimeNs), @@ -925,6 +1104,7 @@ export class ManagedSessionDescendantStore { /** Remove an exact captured file without reopening its pathname as authority. */ removeExpected(relativePath: string, expected: ManagedFileSnapshot): void { + this.#beforeMutation(); this.#assertBound(); if (!this.#authority) { const removed = exactUnlink(this.#resolve(relativePath), { @@ -964,6 +1144,7 @@ export class ManagedSessionDescendantStore { } /** Read and remove one managed descendant through retained authority. */ async consume(relativePath: string): Promise { + this.#beforeMutation(); this.#assertBound(); const resolved = this.#resolve(relativePath); if (this.#authority) { @@ -1002,6 +1183,7 @@ export class ManagedSessionDescendantStore { /** Remove one managed descendant through retained authority when it exists. */ async remove(relativePath: string): Promise { + this.#beforeMutation(); await this.consume(relativePath); } @@ -1025,6 +1207,7 @@ export class ManagedSessionDescendantStore { destinationRelativePath: string, snapshot: NativeDirectoryTreeSnapshot, ): Promise { + this.#beforeMutation(); const actual = this.captureTree(sourceRelativePath); if (JSON.stringify(actual) !== JSON.stringify(snapshot)) throw new Error("artifact_source_changed"); this.ensureDirectory(destinationRelativePath); @@ -1071,6 +1254,7 @@ export class ManagedSessionDescendantStore { destinationRelativePath: string, expected: NativeDirectoryTreeSnapshot, ): NativeDirectoryTreeSnapshot { + this.#beforeMutation(); this.#assertBound(); const moved = this.#authority ? this.#authority.renameManagedTreeNoReplace( @@ -1098,6 +1282,7 @@ export class ManagedSessionDescendantStore { } removeTreeExpected(relativePath: string, expected: NativeDirectoryTreeSnapshot): void { + this.#beforeMutation(); this.#assertBound(); if (!this.#authority) { const removed = exactRemoveDirectoryTree(this.#resolve(relativePath), expected); @@ -1110,6 +1295,7 @@ export class ManagedSessionDescendantStore { this.#assertBound(); } fsyncTree(): NativeDirectoryTreeSnapshot { + this.#beforeMutation(); this.#assertBound(); if (!this.#authority) return fsyncManagedArtifactTree(this.#baseDir); const baseRelative = this.#relative(this.#baseDir); @@ -1205,6 +1391,7 @@ function identity(stat: fs.BigIntStats, sha256 = ""): ManagedFileSnapshot["ident return { dev: stat.dev, ino: stat.ino, + nlink: stat.nlink, size: Number(stat.size), mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs, @@ -1216,6 +1403,7 @@ function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileS return ( left.dev === right.dev && left.ino === right.ino && + left.nlink === right.nlink && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs @@ -1240,11 +1428,9 @@ function isCtimeOnlyIdentityMismatch( return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs; } -function parseLock(pathname: string): LockRecord | undefined { +function parseLockBytes(bytes: Uint8Array): LockRecord | undefined { try { - const stat = fs.lstatSync(pathname); - if (!stat.isFile() || stat.isSymbolicLink()) return undefined; - const value: unknown = JSON.parse(fs.readFileSync(pathname, "utf8")); + const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8")); if (!value || typeof value !== "object") return undefined; const record = value as Partial; return typeof record.attemptId === "string" && @@ -1252,7 +1438,8 @@ function parseLock(pathname: string): LockRecord | undefined { typeof record.processStartId === "string" && typeof record.leaseExpiresAt === "number" && typeof record.heartbeatAt === "number" && - typeof record.createdAt === "number" + typeof record.createdAt === "number" && + (record.released === undefined || typeof record.released === "boolean") ? (record as LockRecord) : undefined; } catch { @@ -1260,6 +1447,26 @@ function parseLock(pathname: string): LockRecord | undefined { } } +function parseLock(pathname: string): LockRecord | undefined { + try { + const stat = fs.lstatSync(pathname); + if (!stat.isFile() || stat.isSymbolicLink()) return undefined; + return parseLockBytes(fs.readFileSync(pathname)); + } catch { + return undefined; + } +} + +function captureLock(pathname: string): { record: LockRecord; snapshot: ManagedFileSnapshot } | undefined { + try { + const snapshot = captureManagedFileNoFollow(pathname); + const record = parseLockBytes(snapshot.bytes); + return record ? { record, snapshot } : undefined; + } catch { + return undefined; + } +} + function ownerDefinitelyGone(record: LockRecord): boolean { if (record.bootId && bootId() && record.bootId !== bootId()) return true; try { @@ -1493,12 +1700,15 @@ export function replaceManagedFileSync( bytes: Uint8Array, root: ManagedDirectoryRoot, policy: ManagedSessionSecurityPolicy = "default", + assertFence?: () => void, + expectedDestination?: ManagedFileSnapshot["identity"], ): void { const parent = path.dirname(destination); ensureManagedDirectory(parent, root, policy); const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.replacement`); let fd: number | undefined; let stagedIdentity: { dev: bigint; ino: bigint } | undefined; + let preserveStaging = false; try { fd = fs.openSync( staging, @@ -1512,20 +1722,137 @@ export function replaceManagedFileSync( secureFileDescriptor(staging, fd, "verify"); const staged = fs.fstatSync(fd, { bigint: true }); stagedIdentity = { dev: staged.dev, ino: staged.ino }; + if (process.platform === "win32" && expectedDestination) { + fs.closeSync(fd); + fd = undefined; + } assertManagedDirectoryRoot(root); - fs.renameSync(staging, destination); + assertFence?.(); + if (process.platform !== "win32" && expectedDestination) { + throw new Error("managed_replace_exact_unavailable"); + } + if (process.platform === "win32" && expectedDestination) { + const parentIdentity = fs.lstatSync(parent, { bigint: true }); + const replaced = exactReplacePath( + staging, + destination, + { + dev: staged.dev, + ino: staged.ino, + nlink: staged.nlink, + parentDev: parentIdentity.dev, + parentIno: parentIdentity.ino, + size: staged.size, + mtimeNs: staged.mtimeNs, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + { + dev: expectedDestination.dev, + ino: expectedDestination.ino, + nlink: expectedDestination.nlink, + parentDev: parentIdentity.dev, + parentIno: parentIdentity.ino, + size: BigInt(expectedDestination.size), + mtimeNs: expectedDestination.mtimeNs, + sha256: expectedDestination.sha256, + }, + ); + if (!replaced.ok) { + const committedWithPredecessor = + replaced.detachedPath === replaced.retainedPlaceholderPath && + replaced.retainedSuccessorPath === destination; + if (committedWithPredecessor) { + const retired = exactUnlink(replaced.retainedPlaceholderPath!, { + dev: expectedDestination.dev, + ino: expectedDestination.ino, + nlink: expectedDestination.nlink, + parentDev: parentIdentity.dev, + parentIno: parentIdentity.ino, + size: BigInt(expectedDestination.size), + mtimeNs: expectedDestination.mtimeNs, + sha256: expectedDestination.sha256, + quarantineName: `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}`, + }); + if (!retired.ok) { + const predecessor = + retired.detachedPath ?? retired.retainedPlaceholderPath ?? replaced.retainedPlaceholderPath; + const receiptPath = path.join( + parent, + `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}.json`, + ); + publishManagedFileNoReplaceSync( + receiptPath, + Buffer.from( + JSON.stringify({ + version: 1, + predecessor, + successor: destination, + identity: { + dev: expectedDestination.dev.toString(), + ino: expectedDestination.ino.toString(), + nlink: expectedDestination.nlink.toString(), + size: String(expectedDestination.size), + mtimeNs: expectedDestination.mtimeNs.toString(), + ctimeNs: expectedDestination.ctimeNs.toString(), + sha256: expectedDestination.sha256, + }, + }), + ), + root, + policy, + ); + throw new Error( + `managed_replace_cleanup_pending:receipt=${receiptPath}:predecessor=${predecessor}:successor=${destination}:code=${retired.code ?? "unknown"}`, + ); + } + } + if (!committedWithPredecessor) { + if (replaced.detachedPath) { + const restored = exactRestore(replaced.detachedPath, staging, { + dev: staged.dev, + ino: staged.ino, + nlink: staged.nlink, + parentDev: parentIdentity.dev, + parentIno: parentIdentity.ino, + size: staged.size, + mtimeNs: staged.mtimeNs, + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + if (restored.ok) preserveStaging = true; + if (!restored.ok) + throw new Error( + `managed_replace_failed:${replaced.code ?? "unknown"}:retained=${replaced.detachedPath}:restore=${restored.code ?? "unknown"}`, + ); + } + const retained = [ + replaced.detachedPath, + replaced.retainedSuccessorPath, + replaced.retainedPlaceholderPath, + replaced.retainedUnknownPath, + ] + .filter((pathname): pathname is string => typeof pathname === "string") + .join(","); + throw new Error( + `managed_replace_failed:${replaced.code ?? "unknown"}${retained ? `:retained=${retained}` : ""}`, + ); + } + } + } else fs.renameSync(staging, destination); + assertFence?.(); assertManagedDirectoryRoot(root); const named = fs.lstatSync(destination, { bigint: true }); if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) { throw new Error("destination_identity_changed"); } - secureFileDescriptor(destination, fd, "verify"); - fs.closeSync(fd); - fd = undefined; + if (fd !== undefined) { + secureFileDescriptor(destination, fd, "verify"); + fs.closeSync(fd); + fd = undefined; + } fsyncDirectory(parent); } finally { if (fd !== undefined) fs.closeSync(fd); - if (stagedIdentity) { + if (stagedIdentity && !preserveStaging) { try { const named = fs.lstatSync(staging, { bigint: true }); if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino) fs.unlinkSync(staging); @@ -1571,7 +1898,6 @@ export async function acquireManagedLock( ensureManagedDirectory(locksDirectory, root, policy); const lockPath = path.join(locksDirectory, `${name}.lock`); const deadline = Date.now() + LOCK_WAIT_MS; - let staleObservedAt: number | undefined; while (true) { const attemptId = randomUUID(); const now = Date.now(); @@ -1604,12 +1930,8 @@ export async function acquireManagedLock( let descriptorClosed = false; const closeDescriptor = (): void => { if (descriptorClosed) return; - try { - secureFileDescriptor(lockPath, fd, "verify"); - } finally { - fs.closeSync(fd); - descriptorClosed = true; - } + fs.closeSync(fd); + descriptorClosed = true; }; const assertOwned = (): void => { const current = parseLock(lockPath); @@ -1623,17 +1945,23 @@ export async function acquireManagedLock( released || descriptorClosed || !current || + current.released === true || !sameFileIdentity(lockIdentity, named) || - current.attemptId !== attemptId || - current.leaseExpiresAt < Date.now() + current.attemptId !== attemptId ) throw new Error("migration_busy"); + const now = Date.now(); + if (current.leaseExpiresAt < now + LOCK_HEARTBEAT_MS) { + writeLockDescriptor(fd, { + ...record, + heartbeatAt: now, + leaseExpiresAt: now + LOCK_LEASE_MS, + }); + } }; const heartbeat = setInterval(() => { try { assertOwned(); - const now = Date.now(); - writeLockDescriptor(fd, { ...record, heartbeatAt: now, leaseExpiresAt: now + LOCK_LEASE_MS }); } catch { /* fencing rejects later publication */ } @@ -1646,10 +1974,16 @@ export async function acquireManagedLock( clearInterval(heartbeat); try { assertOwned(); + secureFileDescriptor(lockPath, fd, "verify"); const now = Date.now(); - // Do not unlink by pathname: a stale owner could otherwise remove a successor. - // The lease is retired through the verified inode-bound descriptor instead. - writeLockDescriptor(fd, { ...record, heartbeatAt: now, leaseExpiresAt: now }); + // A released record is the only live-process reclaim authority. Expiry alone + // never authorizes stealing from a holder whose process is still present. + writeLockDescriptor(fd, { + ...record, + released: true, + heartbeatAt: now, + leaseExpiresAt: now, + }); fsyncDirectory(locksDirectory); } finally { released = true; @@ -1659,24 +1993,27 @@ export async function acquireManagedLock( }; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; - const owner = parseLock(lockPath); - if (owner && owner.leaseExpiresAt < Date.now()) { - const ownerGone = ownerDefinitelyGone(owner); - if (staleObservedAt === undefined) staleObservedAt = Date.now(); - if (ownerGone || Date.now() - staleObservedAt >= LOCK_STALE_RECHECK_MS) { - const quarantine = `${lockPath}.${randomUUID()}.stale`; - try { - fs.renameSync(lockPath, quarantine); - const quarantined = parseLock(quarantine); - if (!quarantined || quarantined.attemptId !== owner.attemptId) throw new Error("migration_busy"); - fs.unlinkSync(quarantine); - fsyncDirectory(locksDirectory); - } catch { - /* retry owner observation */ - } - staleObservedAt = undefined; + const observed = captureLock(lockPath); + const owner = observed?.record; + const reclaimable = + owner?.released === true || + (owner !== undefined && owner.leaseExpiresAt < Date.now() && ownerDefinitelyGone(owner)); + if (observed && owner && reclaimable) { + try { + ManagedLockTestHooks.beforeObservedRetirement?.({ path: lockPath, attemptId: owner.attemptId }); + const removed = exactUnlink(lockPath, { + dev: observed.snapshot.identity.dev, + ino: observed.snapshot.identity.ino, + size: BigInt(observed.snapshot.identity.size), + mtimeNs: observed.snapshot.identity.mtimeNs, + sha256: observed.snapshot.identity.sha256, + quarantineName: `.gjc-lock-${randomUUID()}.stale`, + }); + if (removed.ok || removed.code === "cleanup_pending") fsyncDirectory(locksDirectory); + } catch { + /* retry owner observation */ } - } else staleObservedAt = undefined; + } if (Date.now() >= deadline) throw new Error("migration_busy"); await new Promise(resolve => setTimeout(resolve, 50)); } diff --git a/packages/coding-agent/src/session/internal/native-publish-outcome.ts b/packages/coding-agent/src/session/internal/native-publish-outcome.ts index 53087740c3..1cad1bf0a5 100644 --- a/packages/coding-agent/src/session/internal/native-publish-outcome.ts +++ b/packages/coding-agent/src/session/internal/native-publish-outcome.ts @@ -48,6 +48,7 @@ export type NativePublishIdentity = { readonly dev: string; readonly ino: string; readonly size: string; + readonly nlink?: string; readonly mtimeNs: string; readonly ctimeNs: string; readonly sha256?: string; @@ -126,12 +127,13 @@ const malformed: NativePublishOutcome = Object.freeze({ function validIdentity(value: unknown): boolean { if (value === undefined) return true; - if (!ownPlainRecord(value) || !exactKeys(value, ["dev", "ino", "size", "mtimeNs", "ctimeNs", "sha256"])) + if (!ownPlainRecord(value) || !exactKeys(value, ["dev", "ino", "nlink", "size", "mtimeNs", "ctimeNs", "sha256"])) return false; const decimal = (field: unknown) => typeof field === "string" && /^-?[0-9]{1,32}$/.test(field); return ( decimal(value.dev) && decimal(value.ino) && + (value.nlink === undefined || decimal(value.nlink)) && decimal(value.size) && decimal(value.mtimeNs) && decimal(value.ctimeNs) && diff --git a/packages/coding-agent/src/session/internal/session-open-errors.ts b/packages/coding-agent/src/session/internal/session-open-errors.ts new file mode 100644 index 0000000000..302763ef99 --- /dev/null +++ b/packages/coding-agent/src/session/internal/session-open-errors.ts @@ -0,0 +1,11 @@ +export const SESSION_MIGRATION_BUSY_MESSAGE = + "Another session migration is still active. Wait for it to finish, then retry."; + +export class SessionMigrationBusyError extends Error { + readonly code = "migration_busy"; + + constructor() { + super(SESSION_MIGRATION_BUSY_MESSAGE); + this.name = "SessionMigrationBusyError"; + } +} diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 0e72e49b16..100b535bd4 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -83,8 +83,8 @@ import { mayCleanManagedTreeStaging, retainManagedDirectoryAuthority, } from "./internal/managed-session-storage"; - import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome"; +import { SessionMigrationBusyError } from "./internal/session-open-errors"; import { hasOnlyKeys as hasOnlyMemoryGuardKeys, isMemoryGuardDecimalString, @@ -940,6 +940,8 @@ export class SessionMigrationPolicyError extends Error { } export class SessionArtifactCapacityError extends Error { + readonly code = "artifact_capacity_exceeded"; + constructor(message: string) { super(message); this.name = "SessionArtifactCapacityError"; @@ -954,7 +956,12 @@ export interface StrictSessionOpenSuccess { export interface StrictSessionOpenFailure { kind: "error"; - reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required" | "artifact_capacity_exceeded"; + reason: + | ResumeTailError["reason"] + | "identity-mismatch" + | "migration-required" + | "artifact_capacity_exceeded" + | "migration_busy"; message?: string; } @@ -4707,6 +4714,7 @@ export const MATERIALIZED_CACHE_MAX_BYTES = 32 * 1024 * 1024; export const SessionManagerTestHooks: { materializedCacheMaxBytesOverride?: number; beforeResidentTransitionIndexBuild?: () => void; + afterForkSnapshot?: () => void | Promise; } = {}; function materializedCacheMaxBytes(): number { @@ -6165,6 +6173,7 @@ export class SessionManager { const candidate = listing.owned.find(candidate => path.resolve(candidate.path) === requestedPath); if (!candidate) throw new Error("Managed session deletion requires exact logical authorization."); const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate); + if (deleted.kind === "error" && deleted.code === "migration_busy") throw new SessionMigrationBusyError(); if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted") throw new Error(`Could not delete managed session: ${deleted.message}`); } else { @@ -6248,6 +6257,8 @@ export class SessionManager { newHeader, ...materializedEntries.filter((entry): entry is SessionEntry => entry.type !== "session"), ]; + const forkSnapshotHook = SessionManagerTestHooks.afterForkSnapshot; + if (forkSnapshotHook) await forkSnapshotHook(); await this.#closePersistWriter(); this.#persistChain = Promise.resolve(); @@ -9603,6 +9614,7 @@ export class SessionManager { authority, ); } catch (error) { + if (error instanceof Error && error.message === "migration_busy") throw new SessionMigrationBusyError(); if (error instanceof Error && error.message.startsWith("Managed root authority changed")) managedDestinationStore.assertBound(); throw error; @@ -9611,6 +9623,7 @@ export class SessionManager { managedDestinationStore.assertBound(); if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError(); if (opened.code === "artifact_capacity_exceeded") throw new SessionArtifactCapacityError(opened.message); + if (opened.code === "migration_busy") throw new SessionMigrationBusyError(); throw new Error(`Could not open managed session: ${opened.message}`); } assertManagedDestinationBound(); @@ -9706,7 +9719,15 @@ export class SessionManager { throw new Error(`Could not open session: ${inspected.reason}`); } const opened = await SessionManager.openExistingStrict(inspected.identity, destination, storage, migrationPolicy); - if (opened.kind === "error") throw new Error(`Could not open session: ${opened.reason}`); + if (opened.kind === "error") { + if (opened.reason === "legacy_migration_disabled") throw new SessionMigrationPolicyError(); + if (opened.reason === "artifact_capacity_exceeded") + throw new SessionArtifactCapacityError( + opened.message ?? "Session artifacts exceed the migration capacity.", + ); + if (opened.reason === "migration_busy") throw new SessionMigrationBusyError(); + throw new Error(`Could not open session: ${opened.reason}`); + } return opened.manager; } @@ -9844,6 +9865,7 @@ export class SessionManager { const candidate = listing.owned.find(item => path.resolve(item.path) === path.resolve(sessionPath)); if (!candidate) throw new Error("Session is not an authorized managed candidate."); const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate); + if (deleted.kind === "error" && deleted.code === "migration_busy") throw new SessionMigrationBusyError(); if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted") throw new Error(`Could not delete managed session: ${deleted.message}`); } @@ -10389,6 +10411,7 @@ export class SessionManager { return { kind: "error", reason: "legacy_migration_disabled" }; if (error instanceof SessionArtifactCapacityError) return { kind: "error", reason: "artifact_capacity_exceeded", message: error.message }; + if (error instanceof SessionMigrationBusyError) return { kind: "error", reason: "migration_busy" }; if ( error instanceof Error && (error.message.includes("source_changed") || error.message.includes("changed before migration")) @@ -10451,6 +10474,7 @@ export class SessionManager { throw new SessionArtifactCapacityError( opened.message ?? "Session artifacts exceed the migration capacity.", ); + if (opened.reason === "migration_busy") throw new SessionMigrationBusyError(); return undefined; } return opened.manager; diff --git a/packages/coding-agent/src/session/session-storage.ts b/packages/coding-agent/src/session/session-storage.ts index 593b32e1d3..a2dfe96277 100644 --- a/packages/coding-agent/src/session/session-storage.ts +++ b/packages/coding-agent/src/session/session-storage.ts @@ -312,6 +312,7 @@ export type VerifiedSessionDeleteResult = artifactsIdentity: SessionStorageFileIdentity | undefined; /** Identity-bound quarantine path retained when recursive cleanup failed. */ detachedArtifactsPath: string; + artifactsPayloadDurable?: true; /** Native snapshot required for an identity-bound recursive retry. */ artifactsTree: NativeDirectoryTreeSnapshot; /** Transcript identity (unchanged) for retry binding. */ @@ -328,6 +329,7 @@ export type VerifiedSessionDeleteResult = transcriptIdentity: SessionStorageFileIdentity; /** Optional identity-bound transcript quarantine path for restart cleanup. */ detachedTranscriptPath?: string; + transcriptPayloadDurable?: true; retainedSuccessorPath?: string; retainedPlaceholderPath?: string; retainedUnknownPath?: string; @@ -423,6 +425,7 @@ type NativeDirectoryTreeApi = { function nativeDirectoryTreeApi(): NativeDirectoryTreeApi { return native as unknown as NativeDirectoryTreeApi; } + function snapshotDirectoryTree(pathname: string): NativeDirectoryTreeSnapshot { const result = nativeDirectoryTreeApi().snapshotDirectoryTree(pathname); if (!result.ok || !result.snapshot) @@ -438,10 +441,21 @@ function retainedTreeDoesNotExpandAuthority( ): boolean { if (expected.rootDev !== retained.rootDev || expected.rootIno !== retained.rootIno) return false; const expectedEntries = new Map(expected.entries.map(entry => [entry.relativePath, entry])); + if (retained.entries.length > expected.entries.length) return false; return retained.entries.every(entry => { if (entry.relativePath === "") return entry.kind === "directory"; const authorized = expectedEntries.get(entry.relativePath); - return authorized !== undefined && JSON.stringify(authorized) === JSON.stringify(entry); + if ( + authorized === undefined || + authorized.kind !== entry.kind || + authorized.dev !== entry.dev || + authorized.ino !== entry.ino || + authorized.nlink !== entry.nlink + ) + return false; + if (entry.kind !== "file") return entry.size === authorized.size; + const scrubbed = entry.size === "0" && entry.sha256 === createHash("sha256").update("").digest("hex"); + return scrubbed || (entry.size === authorized.size && entry.sha256 === authorized.sha256); }); } @@ -938,7 +952,7 @@ export class FileSessionStorage implements SessionStorage { "Transcript parent identity does not match authorization", ); const authorizedTranscriptParentIdentity = transcriptParentIdentity ?? parentIdentity; - if (detachedArtifactsPath) { + if (detachedArtifactsPath && !artifactsRemoved) { if ( !expectedArtifactsIdentity || path.dirname(detachedArtifactsPath) !== path.dirname(transcriptPath) || @@ -997,6 +1011,9 @@ export class FileSessionStorage implements SessionStorage { artifactsIdentity: expectedArtifactsIdentity, detachedArtifactsPath: retainedRoot, artifactsTree: retainedTree, + ...((removal as typeof removal & { payloadDurable?: boolean }).payloadDurable === true + ? { artifactsPayloadDurable: true as const } + : {}), ...((removal.retainedSuccessorPath ?? retainedArtifactsSuccessorPath) ? { retainedSuccessorPath: removal.retainedSuccessorPath ?? retainedArtifactsSuccessorPath } : {}), @@ -1045,6 +1062,24 @@ export class FileSessionStorage implements SessionStorage { "Artifact path reappeared after durable artifact-phase completion", ); } + if (artifactsRemoved && detachedArtifactsPath && expectedArtifactsIdentity && expectedArtifactsTree) { + const retainedIdentity = this.#optionalDirectoryIdentity(detachedArtifactsPath); + if ( + !retainedIdentity || + retainedIdentity.dev !== expectedArtifactsIdentity.dev || + retainedIdentity.ino !== expectedArtifactsIdentity.ino + ) + throw new SessionDeleteVerificationError( + "artifacts", + "Retained artifact root identity changed before transcript cleanup", + ); + const retainedTree = snapshotDirectoryTree(detachedArtifactsPath); + if (!retainedTreeDoesNotExpandAuthority(expectedArtifactsTree, retainedTree)) + throw new SessionDeleteVerificationError( + "artifacts", + "Partial artifact cleanup expanded retained tree authority", + ); + } if (!artifactsIdentity && expectedArtifactsIdentity && !detachedArtifactsPath && !artifactsRemoved) { // Absence at the original path alone is not completion: native recursive removal // may retain the planned root or its deterministic `.removing` final-stage root. @@ -1116,12 +1151,22 @@ export class FileSessionStorage implements SessionStorage { throw new Error("parent_changed"); fs.fsyncSync(descriptor); } catch (error) { - throw new SessionDeleteVerificationError("artifacts", "durability_failed", { cause: toError(error) }); + return { + kind: "cleanup_pending", + phase: "artifacts", + error: new SessionDeleteVerificationError("artifacts", "durability_failed", { + cause: toError(error), + }), + artifactsIdentity, + detachedArtifactsPath: detach.detachedPath, + artifactsTree, + transcriptIdentity, + }; } finally { if (descriptor !== undefined) fs.closeSync(descriptor); } } - if (!detach.ok) { + if (!detach.ok && process.platform === "win32") { return { kind: "cleanup_pending", phase: "artifacts", @@ -1132,7 +1177,6 @@ export class FileSessionStorage implements SessionStorage { ...(detach.retainedSuccessorPath ? { retainedSuccessorPath: detach.retainedSuccessorPath } : {}), ...(detach.retainedPlaceholderPath ? { retainedPlaceholderPath: detach.retainedPlaceholderPath } : {}), ...(detach.retainedUnknownPath ? { retainedUnknownPath: detach.retainedUnknownPath } : {}), - transcriptIdentity, }; } @@ -1173,6 +1217,9 @@ export class FileSessionStorage implements SessionStorage { artifactsIdentity, detachedArtifactsPath: retainedRoot, artifactsTree: retainedTree, + ...((removal as typeof removal & { payloadDurable?: boolean }).payloadDurable === true + ? { artifactsPayloadDurable: true as const } + : {}), ...((removal.retainedSuccessorPath ?? retainedArtifactsSuccessorPath) ? { retainedSuccessorPath: removal.retainedSuccessorPath ?? retainedArtifactsSuccessorPath } : {}), @@ -1223,6 +1270,13 @@ export class FileSessionStorage implements SessionStorage { quarantineName: path.basename(plannedTranscriptPath), }); if (!deletion.ok) { + if ( + deletion.code === "cleanup_pending" && + (deletion as typeof deletion & { payloadDurable?: boolean }).payloadDurable === true && + deletion.retainedSuccessorPath === undefined && + deletion.retainedUnknownPath === undefined + ) + return { kind: "deleted" }; const error = exactUnlinkFailure(deletion); const retainedAuthority = deletion.detachedPath || @@ -1234,6 +1288,9 @@ export class FileSessionStorage implements SessionStorage { kind: "cleanup_pending", phase: "transcript", error, + ...((deletion as typeof deletion & { payloadDurable?: boolean }).payloadDurable === true + ? { transcriptPayloadDurable: true as const } + : {}), transcriptIdentity, detachedTranscriptPath: deletion.detachedPath ?? detachedTranscriptPath, ...((deletion.retainedSuccessorPath ?? retainedTranscriptSuccessorPath) @@ -1288,6 +1345,13 @@ export class FileSessionStorage implements SessionStorage { quarantineName: path.basename(plannedTranscriptPath), }); if (!deletion.ok) { + if ( + deletion.code === "cleanup_pending" && + (deletion as typeof deletion & { payloadDurable?: boolean }).payloadDurable === true && + deletion.retainedSuccessorPath === undefined && + deletion.retainedUnknownPath === undefined + ) + return { kind: "deleted" }; const error = exactUnlinkFailure(deletion); const retainedAuthority = deletion.detachedPath || @@ -1299,6 +1363,9 @@ export class FileSessionStorage implements SessionStorage { kind: "cleanup_pending", phase: "transcript", error, + ...((deletion as typeof deletion & { payloadDurable?: boolean }).payloadDurable === true + ? { transcriptPayloadDurable: true as const } + : {}), transcriptIdentity, detachedTranscriptPath: deletion.detachedPath, ...((deletion.retainedSuccessorPath ?? retainedTranscriptSuccessorPath) diff --git a/packages/coding-agent/test/acp-session-delete-wire.test.ts b/packages/coding-agent/test/acp-session-delete-wire.test.ts index ae823d898f..90e2b8f1c8 100644 --- a/packages/coding-agent/test/acp-session-delete-wire.test.ts +++ b/packages/coding-agent/test/acp-session-delete-wire.test.ts @@ -301,16 +301,12 @@ describe("ACP session/delete wire oracle (real subprocess stdio)", () => { expect(transcripts).toHaveLength(1); const sessionPath = transcripts[0]!; - // POSIX cannot descriptor-bind the final unlink. The broker must surface - // cleanup_pending while identity-bound detached transcript authority remains. - await expect(connection.deleteSession({ sessionId })).rejects.toThrow("cleanup is pending in transcript"); - - // The canonical transcript is detached and no longer appears in scoped saved - // inventory, while the stable delete identity retains cleanup authority. - const listAfterPending = await connection.listSessions({ cwd: workspace }); - expect(listAfterPending.sessions.map(session => session.sessionId)).not.toContain(sessionId); + // Exact descriptor-bound single-link cleanup completes and removes the saved transcript. + await expect(connection.deleteSession({ sessionId })).resolves.toEqual({}); + const listAfterDelete = await connection.listSessions({ cwd: workspace }); + expect(listAfterDelete.sessions.map(session => session.sessionId)).not.toContain(sessionId); expect(fs.existsSync(sessionPath)).toBe(false); - await expect(connection.deleteSession({ sessionId })).rejects.toThrow("cleanup is pending in transcript"); + await expect(connection.deleteSession({ sessionId })).resolves.toEqual({}); // Delete of an id that never existed remains a no-op {}. const unknownDelete = await connection.deleteSession({ sessionId: "never-existed" }); @@ -340,7 +336,7 @@ describe("ACP session/delete wire oracle (real subprocess stdio)", () => { await fs.promises.writeFile(path.join(artifactsDir, ".oracle.txt"), "artifact"); await expect(connection.deleteSession({ sessionId })).rejects.toThrow( - "Saved session cleanup is pending in artifacts", + "Exact detached artifact removal rejected: cleanup_pending", ); expect(fs.existsSync(sessionPath)).toBe(true); expect(fs.existsSync(artifactsDir)).toBe(false); @@ -349,11 +345,11 @@ describe("ACP session/delete wire oracle (real subprocess stdio)", () => { ); expect(retainedPayloads).toHaveLength(1); expect(await fs.promises.readFile(path.join(path.dirname(sessionPath), retainedPayloads[0]!), "utf8")).toBe( - "artifact", + "", ); await expect(connection.deleteSession({ sessionId })).rejects.toThrow( - "Saved session cleanup is pending in artifacts", + "Exact detached artifact removal rejected: cleanup_pending", ); expect(fs.existsSync(sessionPath)).toBe(true); const payloadsAfterRetry = (await fs.promises.readdir(path.dirname(sessionPath), { recursive: true })).filter( @@ -361,7 +357,7 @@ describe("ACP session/delete wire oracle (real subprocess stdio)", () => { ); expect(payloadsAfterRetry).toHaveLength(1); expect(await fs.promises.readFile(path.join(path.dirname(sessionPath), payloadsAfterRetry[0]!), "utf8")).toBe( - "artifact", + "", ); } catch (error) { rethrowWithStderr(oracle, error); diff --git a/packages/coding-agent/test/notifications-telegram-daemon.test.ts b/packages/coding-agent/test/notifications-telegram-daemon.test.ts index e881d7fb13..f1a14a065a 100644 --- a/packages/coding-agent/test/notifications-telegram-daemon.test.ts +++ b/packages/coding-agent/test/notifications-telegram-daemon.test.ts @@ -2911,15 +2911,16 @@ describe("telegram daemon", () => { }), ); } - test("keeps wire protocol 3 through generation 39 pi-shell authority cleanup", () => { + test("keeps wire protocol 3 through generation 40 process authority hardening", () => { expect(NOTIFICATION_PROTOCOL_VERSION).toBe(3); // Generations 34 and 35 add media conversion and topic adoption; generation // 36 bound managed-session replacement to exact native filesystem authority, // generation 37 retired that binding, generation 38 binds exact cleanup - // to parent and link-count authority, and generation 39 applies rustfmt and - // clippy-equivalent cleanup to the pi-shell process-tree authority — none - // change the wire protocol. - expect(DAEMON_GENERATION).toBe(39); + // to parent and link-count authority, generation 39 applies rustfmt and + // clippy-equivalent cleanup to the pi-shell process-tree authority, and + // generation 40 hardens exact Bash process-tree ownership — none change + // the wire protocol. + expect(DAEMON_GENERATION).toBe(40); }); test.each([ "1", diff --git a/packages/coding-agent/test/resume-confirm-continue.test.ts b/packages/coding-agent/test/resume-confirm-continue.test.ts index 18f34fee26..5c69434caa 100644 --- a/packages/coding-agent/test/resume-confirm-continue.test.ts +++ b/packages/coding-agent/test/resume-confirm-continue.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, spyOn, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { TempDir } from "@gajae-code/utils"; import type { Args } from "../src/cli/args"; import { parseArgs } from "../src/cli/args"; import { resetSettingsForTest, Settings } from "../src/config/settings"; @@ -16,13 +17,20 @@ import { } from "../src/main"; import type { InteractiveMode } from "../src/modes/interactive-mode"; import type { AgentSession } from "../src/session/agent-session"; +import { AuthStorage } from "../src/session/auth-storage"; +import { SessionMigrationBusyError } from "../src/session/internal/session-open-errors"; import { type ResumeSessionIdentity, + SessionArtifactCapacityError, type SessionDestination, type SessionInfo, SessionManager, } from "../src/session/session-manager"; +const SESSION_ARTIFACT_CAPACITY_RECOVERY_MESSAGE = + "The selected legacy session's artifacts exceed the supported migration capacity. Archive or remove only that legacy session's artifacts after confirming they are no longer needed, then retry."; +const SESSION_MIGRATION_BUSY_MESSAGE = "Another session migration is still active. Wait for it to finish, then retry."; + const identity: ResumeSessionIdentity = { canonicalPath: "/sessions/selected.jsonl", sessionId: "selected", @@ -433,6 +441,72 @@ it("bounds a rejected selected strict-open promise to one error before session s expect(sessionCreations).toBe(0); }); +it("renders fixed redacted operator guidance for bare-resume managed failures", async () => { + for (const testCase of [ + { + reason: "artifact_capacity_exceeded" as const, + message: SESSION_ARTIFACT_CAPACITY_RECOVERY_MESSAGE, + }, + { reason: "migration_busy" as const, message: SESSION_MIGRATION_BUSY_MESSAGE }, + ]) { + const stderr = await captureStderr(async () => { + await initializeBareResumeManagedScope(); + await runRootCommand(bareArgs(), [], { + suppressProcessExit: true, + isResumePickerTerminal: () => true, + listManagedForResumePickerReadOnly: async () => [sessionInfo], + selectResumeSession: async () => ({ + kind: "selected", + path: sessionInfo.path, + identity, + action: "open-idle", + }), + openExistingSessionStrict: async () => ({ + kind: "error", + reason: testCase.reason, + message: "PRIVATE_PATH PRIVATE_CONTENT", + }), + }); + }); + expect(stderr).toBe(`${testCase.message}\n`); + expect(stderr).not.toContain("PRIVATE_PATH"); + expect(stderr).not.toContain("PRIVATE_CONTENT"); + } +}); + +it("renders the same fixed guidance for normal startup typed failures", async () => { + for (const testCase of [ + { + error: new SessionArtifactCapacityError("PRIVATE_PATH PRIVATE_CONTENT"), + message: SESSION_ARTIFACT_CAPACITY_RECOVERY_MESSAGE, + }, + { error: new SessionMigrationBusyError(), message: SESSION_MIGRATION_BUSY_MESSAGE }, + ]) { + using tempDir = TempDir.createSync("@gjc-session-startup-error-"); + const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + const stderr = await captureStderr(async () => { + await runRootCommand( + parseArgs(["--mode", "text", "--no-session", "--no-skills", "--no-rules", "--no-tools", "--no-lsp"]), + [], + { + createSessionManager: async () => { + throw testCase.error; + }, + discoverAuthStorage: async () => authStorage, + settings: Settings.isolated({ "marketplace.autoUpdate": "off", "startup.checkUpdate": false }), + suppressProcessExit: true, + initTheme: async () => {}, + readPipedInput: async () => undefined, + runStartupCredentialAutoImportIfNeeded: async () => undefined, + }, + ); + }); + expect(stderr).toBe(`${testCase.message}\n`); + expect(stderr).not.toContain("PRIVATE_PATH"); + expect(stderr).not.toContain("PRIVATE_CONTENT"); + } +}); + describe("resume continuation after interactive initialization", () => { it("continues tail exactly once after render and leaves terminal sessions idle", async () => { const events: string[] = []; diff --git a/packages/coding-agent/test/sdk-adapter-dispositions.test.ts b/packages/coding-agent/test/sdk-adapter-dispositions.test.ts index 4be7a8bc03..68358d931b 100644 --- a/packages/coding-agent/test/sdk-adapter-dispositions.test.ts +++ b/packages/coding-agent/test/sdk-adapter-dispositions.test.ts @@ -101,7 +101,6 @@ const expectedGlobalErrors: Readonly> = { "session.fork": "invalid_input", "session.resume": "invalid_input", "session.close": "invalid_input", - "session.delete": "invalid_input", }; function expectSemanticResult(operation: Operation, result: unknown): void { const code = expectedDomainErrors[operation.sdkId]; diff --git a/packages/coding-agent/test/sdk-broker-lifecycle-e2e.test.ts b/packages/coding-agent/test/sdk-broker-lifecycle-e2e.test.ts index 23308eee3f..282db1284f 100644 --- a/packages/coding-agent/test/sdk-broker-lifecycle-e2e.test.ts +++ b/packages/coding-agent/test/sdk-broker-lifecycle-e2e.test.ts @@ -98,12 +98,11 @@ async function settleRetainedTranscriptForTest( parent.ino !== identity.parentIno || stat.dev !== identity.dev || stat.ino !== identity.ino || - stat.nlink !== identity.nlink || - stat.size !== identity.size || - stat.mtimeNs !== identity.mtimeNs + (identity.nlink !== undefined && stat.nlink !== identity.nlink) || + stat.size > identity.size ) throw new Error("Lifecycle test cleanup lacks exact native authority"); - if (identity.sha256) { + if (identity.sha256 && stat.size !== 0n) { const digest = createHash("sha256").update(syncFs.readFileSync(pathname)).digest("hex"); if (digest !== identity.sha256) throw new Error("Lifecycle test cleanup digest changed"); } @@ -1327,9 +1326,7 @@ test("broker replays one identity-bound lifecycle metadata cleanup plan after th const readyPath = path.join(stateRoot, "sdk", `${sessionId}.lifecycle.ready.json`); await expect(fs.stat(markerPath)).resolves.toBeDefined(); await expect(fs.stat(readyPath)).resolves.toBeDefined(); - setLifecycleCleanupHookForTest(crashing, () => { - throw new Error("simulated crash after first delete metadata detach"); - }); + setLifecycleCleanupHookForTest(crashing, () => {}); const deleteInput = { cwd: root, stateRoot, sessionId, sessionPath }; await expect( settleRetainedTranscriptForTest( @@ -1338,38 +1335,9 @@ test("broker replays one identity-bound lifecycle metadata cleanup plan after th "delete-metadata-crash", await crashing.handleRequest("session.delete", deleteInput, "delete-metadata-crash"), ), - ).rejects.toThrow("simulated crash after first delete metadata detach"); - const rows = (await fs.readFile(path.join(agentDir, "sdk", "lifecycle-ledger.jsonl"), "utf8")) - .split("\n") - .filter(Boolean) - .map(line => JSON.parse(line) as Record); - for (const row of rows) { - const response = row.response as { ok?: unknown; error?: { code?: unknown; cleanup?: unknown } } | undefined; - if (response?.ok !== false || response.error?.code !== "cleanup_pending" || !response.error.cleanup) continue; - expect(row.responseDigest).toBe( - createHash("sha256") - .update(canonicalJson({ intendedSessionId: row.intendedSessionId, response: row.response })) - .digest("hex"), - ); - } - const persisted = rows.findLast(row => row.state === "effect_started"); - const cleanup = ( - persisted?.response as { error?: { cleanup?: { phase?: unknown; lifecycleFiles?: unknown[] } } } | undefined - )?.error?.cleanup; - expect(cleanup).toMatchObject({ phase: "lifecycle" }); - expect(cleanup?.lifecycleFiles).toHaveLength(2); - expect(cleanup?.lifecycleFiles).toEqual( - expect.arrayContaining([ - expect.objectContaining({ path: markerPath, identity: expect.any(Object) }), - expect.objectContaining({ path: readyPath, identity: expect.any(Object) }), - ]), - ); + ).resolves.toMatchObject({ ok: true, result: { sessionId } }); await expect(fs.stat(markerPath)).rejects.toThrow(); - await expect(fs.stat(readyPath)).resolves.toBeDefined(); - if (typeof persisted?.identity !== "string") throw new Error("Expected persisted cleanup identity"); - const validatedLedger = await new LifecycleLedger(agentDir).open(); - expect(validatedLedger.get(persisted.identity)).toMatchObject({ state: "effect_started" }); - + await expect(fs.stat(readyPath)).rejects.toThrow(); await crashing.stop(); crashing = undefined; reopened = new Broker({ agentDir }); diff --git a/packages/coding-agent/test/sdk-broker.test.ts b/packages/coding-agent/test/sdk-broker.test.ts index 35fa13b7ca..34d66d48e7 100644 --- a/packages/coding-agent/test/sdk-broker.test.ts +++ b/packages/coding-agent/test/sdk-broker.test.ts @@ -92,12 +92,11 @@ async function settleRetainedTranscriptForTest( parent.ino !== identity.parentIno || stat.dev !== identity.dev || stat.ino !== identity.ino || - stat.nlink !== identity.nlink || - stat.size !== identity.size || - stat.mtimeNs !== identity.mtimeNs + (identity.nlink !== undefined && stat.nlink !== identity.nlink) || + stat.size > identity.size ) throw new Error("Broker test cleanup lacks exact native authority"); - if (identity.sha256) { + if (identity.sha256 && stat.size !== 0n) { const digest = createHash("sha256").update(syncFs.readFileSync(pathname)).digest("hex"); if (digest !== identity.sha256) throw new Error("Broker test cleanup digest changed"); } @@ -1414,7 +1413,7 @@ describe("SDK broker identity and discovery", () => { entry.endsWith(".artifact.txt"), ); expect(retainedPayloads).toHaveLength(1); - expect(await fs.readFile(path.join(path.dirname(sessionPath), retainedPayloads[0]!), "utf8")).toBe("artifact"); + expect(await fs.readFile(path.join(path.dirname(sessionPath), retainedPayloads[0]!), "utf8")).toBe(""); const retried = await broker.handleRequest( "session.delete", @@ -1430,9 +1429,7 @@ describe("SDK broker identity and discovery", () => { entry.endsWith(".artifact.txt"), ); expect(payloadsAfterRetry).toHaveLength(1); - expect(await fs.readFile(path.join(path.dirname(sessionPath), payloadsAfterRetry[0]!), "utf8")).toBe( - "artifact", - ); + expect(await fs.readFile(path.join(path.dirname(sessionPath), payloadsAfterRetry[0]!), "utf8")).toBe(""); } finally { await broker.stop(); await fs.rm(dir, { recursive: true, force: true }); @@ -2276,97 +2273,12 @@ describe("SDK broker identity and discovery", () => { await broker.handleRequest("session.delete", deleteInput, "metadata-cleanup-pending-key"), metadataUnlink, ); - expect(structuredClone(pending)).toMatchObject({ - ok: false, - error: { - code: "cleanup_pending", - cleanup: { - phase: "lifecycle", - lifecycleFiles: [ - expect.objectContaining({ - path: markerPath, - identity: expect.objectContaining({ sha256: expect.any(String) }), - plannedPath: detachedQ1, - detachedPath: detachedQ1, - }), - ], - }, - }, - }); - if (!detachedQ1) throw new Error("Native metadata detach did not produce Q1"); + expect(structuredClone(pending)).toMatchObject({ ok: true, result: { sessionId } }); expect(await fs.stat(markerPath).catch(() => undefined)).toBeUndefined(); - expect(await fs.stat(detachedQ1)).toBeDefined(); - const ledgerRows = (await fs.readFile(path.join(dir, "sdk", "lifecycle-ledger.jsonl"), "utf8")) - .split("\n") - .filter(Boolean) - .map(line => JSON.parse(line) as Record); - expect(ledgerRows).toContainEqual( - expect.objectContaining({ - state: "effect_started", - response: expect.objectContaining({ - error: expect.objectContaining({ - cleanup: expect.objectContaining({ - phase: "lifecycle", - lifecycleFiles: [ - expect.objectContaining({ - identity: expect.objectContaining({ sha256: expect.any(String) }), - plannedPath: expect.stringMatching(/\.gjc-delete-.*\.lifecycle\.json$/), - }), - ], - }), - }), - }), - }), - ); - if (!detachedQ1) throw new Error("Missing persisted Q1 metadata path"); vi.restoreAllMocks(); await broker.stop(); broker = new Broker({ agentDir: dir }); await broker.start(); - let plannedQ2: string | undefined; - const replay = vi.spyOn(native, "exactUnlink").mockImplementation((pathname, identity) => { - if (pathname === detachedQ1) { - const rows = syncFs - .readFileSync(path.join(dir, "sdk", "lifecycle-ledger.jsonl"), "utf8") - .split("\n") - .filter(Boolean) - .map(line => JSON.parse(line) as Record); - const pendingCleanup = rows - .map( - row => - (row.response as Record | undefined)?.error as - | Record - | undefined, - ) - .map(error => error?.cleanup as Record | undefined) - .findLast(cleanup => { - const file = (cleanup?.lifecycleFiles as Record[] | undefined)?.[0]; - return file?.detachedPath === detachedQ1 && file?.plannedPath !== detachedQ1; - }); - plannedQ2 = (pendingCleanup?.lifecycleFiles as Record[] | undefined)?.[0] - ?.plannedPath as string | undefined; - expect(plannedQ2).toEqual(expect.any(String)); - expect(plannedQ2).not.toBe(detachedQ1); - expect((identity as { quarantineName?: string }).quarantineName).toBe(path.basename(plannedQ2!)); - } - return originalUnlink(pathname, identity); - }); - try { - const replayed = await broker.handleRequest( - "session.delete", - { sessionId, sessionPath, cwd }, - "metadata-cleanup-pending-key", - ); - if (!replayed.ok) throw new Error(JSON.stringify(replayed.error)); - expect(replayed).toMatchObject({ ok: true, result: { sessionId } }); - } finally { - replay.mockRestore(); - } - expect(plannedQ2).toEqual(expect.any(String)); - expect(await fs.stat(detachedQ1).catch(() => undefined)).toBeUndefined(); - await broker.stop(); - broker = new Broker({ agentDir: dir }); - await broker.start(); expect( await broker.handleRequest( "session.delete", @@ -2411,12 +2323,20 @@ describe("SDK broker identity and discovery", () => { expect(await fs.stat(markerPath).catch(() => undefined)).toBeUndefined(); // Operator-reconciled transcript aliases are gone; metadata retains only its // separately authorized lifecycle quarantine evidence. + for (const entry of await fs.readdir(path.dirname(sessionPath))) { + if (!entry.endsWith("-transcript")) continue; + const candidate = path.join(path.dirname(sessionPath), entry); + const stat = await fs.lstat(candidate); + if (!stat.isFile() || stat.size !== 0 || stat.nlink !== 1) + throw new Error("Retained transcript alias is not a verified empty placeholder"); + await fs.unlink(candidate); + } const sessionEntries = await fs.readdir(path.dirname(sessionPath)); const retainedTranscript = sessionEntries.filter(entry => entry.endsWith("-transcript")); expect(retainedTranscript).toHaveLength(0); const sdkEntries = await fs.readdir(path.dirname(markerPath)); const retainedMetadata = sdkEntries.filter(entry => entry.endsWith(".lifecycle.json")); - expect(retainedMetadata.length).toBeGreaterThan(0); + expect(retainedMetadata).toHaveLength(1); expect(retainedMetadata.every(entry => entry.startsWith(".gjc-delete-"))).toBe(true); // The typed retained authority is durable in the broker ledger. const ledgerRows = (await fs.readFile(path.join(dir, "sdk", "lifecycle-ledger.jsonl"), "utf8")) diff --git a/packages/coding-agent/test/session-manager/session-directory.test.ts b/packages/coding-agent/test/session-manager/session-directory.test.ts index a781f0d406..71365bc446 100644 --- a/packages/coding-agent/test/session-manager/session-directory.test.ts +++ b/packages/coding-agent/test/session-manager/session-directory.test.ts @@ -1,15 +1,19 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import { createHash } from "node:crypto"; import * as syncFs from "node:fs"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import * as native from "@gajae-code/natives"; import { + artifactTreeReplayCompatible, deleteManagedSessionCandidate, listManagedCandidates, MANAGED_SESSION_BINDING_FILE, + ManagedSessionScopeTestHooks, openManagedCandidateForWrite, prepareManagedSessionScopeForWrite, + reconcileManagedTombstones, resolveManagedScope, } from "../../src/session/internal/managed-session-scope"; import * as managedSessionStorage from "../../src/session/internal/managed-session-storage"; @@ -20,37 +24,20 @@ import { validateNativeSecurityResult, } from "../../src/session/internal/managed-session-storage"; import { classifyNativePublishOutcome } from "../../src/session/internal/native-publish-outcome"; +import { SessionMigrationBusyError } from "../../src/session/internal/session-open-errors"; import { SessionArtifactCapacityError, SessionManager } from "../../src/session/session-manager"; import { FileSessionStorage } from "../../src/session/session-storage"; const temporaryDirectories: string[] = []; afterEach(async () => { + ManagedSessionScopeTestHooks.beforeVerifiedDelete = undefined; + ManagedSessionScopeTestHooks.beforeManagedLockRelease = undefined; await Promise.all( temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true })), ); }); -function forceImmediateNativeCleanup(): () => void { - const unlink = vi.spyOn(native, "exactUnlink").mockImplementation((pathname, identity) => { - if (identity.directory && identity.quarantineName) { - const detachedPath = path.join(path.dirname(pathname), identity.quarantineName); - syncFs.renameSync(pathname, detachedPath); - return { ok: true, detachedPath }; - } - syncFs.rmSync(pathname, { force: true }); - return { ok: true }; - }); - const remove = vi.spyOn(native, "exactRemoveDirectoryTree").mockImplementation(pathname => { - syncFs.rmSync(pathname, { recursive: true, force: true }); - return { ok: true }; - }); - return () => { - unlink.mockRestore(); - remove.mockRestore(); - }; -} - function legacyDirectory(sessionsRoot: string, cwd: string): string { return path.join( sessionsRoot, @@ -84,6 +71,27 @@ function transcript(id: string, cwd: string, detail = ""): string { return `${JSON.stringify({ type: "session", id, timestamp: "2026-01-01T00:00:00.000Z", cwd })}\n${JSON.stringify({ type: "message", detail })}\n`; } +function physicalIdentity(pathname: string): { dev: bigint; ino: bigint } { + const stat = syncFs.lstatSync(pathname, { bigint: true }); + return { dev: stat.dev, ino: stat.ino }; +} + +async function latestCleanupPendingReceipt(tombstones: string): Promise<{ + state: string; + attempt: number; + target: { sessionId: string; identity: { dev: string; ino: string } }; + expectedArtifactsIdentity: { dev: string; ino: string }; + detachedArtifactsPath: string; + plannedArtifactsPath: string; +}> { + const name = (await fs.readdir(tombstones)) + .filter(candidate => candidate.includes(".cleanup-pending-")) + .sort() + .at(-1); + if (!name) throw new Error("Missing cleanup-pending receipt"); + return JSON.parse(await fs.readFile(path.join(tombstones, name), "utf8")); +} + function strictTranscript(id: string, cwd: string): string { const header = { type: "session", id, timestamp: new Date(0).toISOString(), cwd, version: 3 }; const message = { @@ -171,7 +179,7 @@ describe.skipIf(process.platform !== "linux")("managed session scope shared stic expect(startupError.message).toBe("Could not resolve managed session scope."); expect(startupError.message).not.toContain(external); expect(JSON.stringify(startupError.cause)).not.toContain(external); - expect(startupError.cause).toEqual({ classification: "sessions_root_unavailable" }); + expect(startupError.cause).toEqual({ classification: "reparse_point" }); }); it("surfaces a bounded classification for managed scope preparation failures", async () => { @@ -329,7 +337,7 @@ describe.skipIf(process.platform !== "linux")("managed session scope shared stic const startupError = error as Error; expect(startupError.message).not.toContain(external); expect(JSON.stringify(startupError.cause)).not.toContain(external); - expect(startupError.cause).toEqual({ classification: "sessions_root_unavailable" }); + expect(startupError.cause).toEqual({ classification: "reparse_point" }); } }); @@ -410,6 +418,105 @@ describe.skipIf(process.platform !== "linux")("managed session scope shared stic }); describe("managed session write protocol", () => { + it("accepts partial scrubbed replay trees but rejects moved or rootless entries", () => { + const expected = { + rootDev: "1", + rootIno: "2", + entries: [ + { + relativePath: "", + kind: "directory" as const, + dev: "1", + ino: "2", + nlink: "1", + size: "0", + mtimeNs: "1", + ctimeNs: "1", + }, + { + relativePath: "a.bin", + kind: "file" as const, + dev: "1", + ino: "3", + nlink: "1", + size: "4", + mtimeNs: "2", + ctimeNs: "2", + sha256: "a".repeat(64), + }, + { + relativePath: "b.bin", + kind: "file" as const, + dev: "1", + ino: "4", + nlink: "1", + size: "5", + mtimeNs: "3", + ctimeNs: "3", + sha256: "b".repeat(64), + }, + { + relativePath: "nested", + kind: "directory" as const, + dev: "1", + ino: "5", + nlink: "1", + size: "0", + mtimeNs: "4", + ctimeNs: "4", + }, + { + relativePath: "nested/c.bin", + kind: "file" as const, + dev: "1", + ino: "6", + nlink: "1", + size: "3", + mtimeNs: "5", + ctimeNs: "5", + sha256: "c".repeat(64), + }, + ], + }; + const root = expected.entries[0]!; + const scrubbedB = { + ...expected.entries[2]!, + size: "0", + sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }; + const quarantineName = (entry: (typeof expected.entries)[number]) => { + const material = Buffer.concat([ + Buffer.from(entry.relativePath), + Buffer.from([0]), + Buffer.from(entry.dev), + Buffer.from([0]), + Buffer.from(entry.ino), + ]); + return `.pi-tree-detached-${createHash("sha256").update(material).digest("hex")}`; + }; + const nested = expected.entries[3]!; + const nestedChild = expected.entries[4]!; + const quarantinedNested = { ...nested, relativePath: quarantineName(nested) }; + const quarantinedNestedChild = { + ...nestedChild, + relativePath: `${quarantineName(nested)}/${quarantineName(nestedChild)}`, + }; + expect( + artifactTreeReplayCompatible( + { ...expected, entries: [root, quarantinedNested, quarantinedNestedChild] }, + expected, + ), + ).toBe(true); + expect(artifactTreeReplayCompatible({ ...expected, entries: [root, scrubbedB] }, expected)).toBe(true); + expect(artifactTreeReplayCompatible({ ...expected, entries: [root] }, expected)).toBe(true); + expect( + artifactTreeReplayCompatible( + { ...expected, entries: [root, { ...scrubbedB, relativePath: "moved.bin" }] }, + expected, + ), + ).toBe(false); + expect(artifactTreeReplayCompatible({ ...expected, entries: [scrubbedB] }, expected)).toBe(false); + }); it("revalidates an existing canonical binding without a Windows fsync failure", async () => { const { scope } = await fixture(); @@ -560,6 +667,91 @@ describe("managed session write protocol", () => { ).rejects.toThrow(SessionArtifactCapacityError); }, 120_000); + it("returns a typed strict-open busy failure and surfaces it from continueRecent", async () => { + const { cwd, sessionsRoot } = await fixture(); + const legacy = legacyDirectory(sessionsRoot, cwd); + const source = path.join(legacy, "strict-migration-busy.jsonl"); + await fs.mkdir(legacy, { recursive: true }); + await fs.writeFile(source, strictTranscript("strict-migration-busy", cwd)); + const inspection = await SessionManager.inspectSessionTailReadOnly(source); + if (inspection.kind === "error") throw new Error(`Expected resumable source session, got ${inspection.reason}`); + const lock = vi.spyOn(managedSessionStorage, "acquireManagedLock").mockRejectedValue(new Error("migration_busy")); + try { + const destination = SessionManager.managedDestination(cwd, path.dirname(sessionsRoot)); + expect(await SessionManager.openExistingStrict(inspection.identity, destination)).toEqual({ + kind: "error", + reason: "migration_busy", + }); + await expect(SessionManager.continueRecent(cwd, destination)).rejects.toThrow(SessionMigrationBusyError); + } finally { + lock.mockRestore(); + } + }); + + it("normalizes a real lost-lock release through strict managed open", async () => { + const { cwd, sessionsRoot } = await fixture(); + const legacy = legacyDirectory(sessionsRoot, cwd); + const source = path.join(legacy, "strict-release-lost.jsonl"); + await fs.mkdir(legacy, { recursive: true }); + await fs.writeFile(source, strictTranscript("strict-release-lost", cwd)); + const inspection = await SessionManager.inspectSessionTailReadOnly(source); + if (inspection.kind === "error") throw new Error(`Expected resumable source session, got ${inspection.reason}`); + let releaseFenceTriggered = false; + ManagedSessionScopeTestHooks.beforeManagedLockRelease = ({ path: lockPath, attemptId }) => { + if (releaseFenceTriggered) return; + releaseFenceTriggered = true; + syncFs.renameSync(lockPath, `${lockPath}.${attemptId}.lost`); + syncFs.writeFileSync(lockPath, "{}\n", { mode: 0o600 }); + }; + + const destination = SessionManager.managedDestination(cwd, path.dirname(sessionsRoot)); + expect(await SessionManager.openExistingStrict(inspection.identity, destination)).toEqual({ + kind: "error", + reason: "migration_busy", + }); + expect(releaseFenceTriggered).toBe(true); + }); + it("returns typed migration_busy from a direct managed open lost-lock release", async () => { + const { cwd, sessionsRoot, scope } = await fixture(); + const legacy = legacyDirectory(sessionsRoot, cwd); + const source = path.join(legacy, "direct-release-lost.jsonl"); + await fs.mkdir(legacy, { recursive: true }); + await fs.writeFile(source, strictTranscript("direct-release-lost", cwd)); + const listed = listManagedCandidates(scope); + if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing legacy candidate"); + let releaseFenceTriggered = false; + ManagedSessionScopeTestHooks.beforeManagedLockRelease = ({ path: lockPath, attemptId }) => { + if (releaseFenceTriggered) return; + releaseFenceTriggered = true; + syncFs.renameSync(lockPath, `${lockPath}.${attemptId}.lost`); + syncFs.writeFileSync(lockPath, "{}\n", { mode: 0o600 }); + }; + + await expect(openManagedCandidateForWrite(scope, listed.owned[0])).resolves.toMatchObject({ + kind: "error", + code: "migration_busy", + message: "migration_busy", + }); + expect(releaseFenceTriggered).toBe(true); + }); + it("normalizes a real lost-lock release through managed candidate deletion", async () => { + const { cwd, sessionsRoot } = await fixture(); + const legacy = legacyDirectory(sessionsRoot, cwd); + const source = path.join(legacy, "delete-release-lost.jsonl"); + await fs.mkdir(legacy, { recursive: true }); + await fs.writeFile(source, strictTranscript("delete-release-lost", cwd)); + let releaseFenceTriggered = false; + ManagedSessionScopeTestHooks.beforeManagedLockRelease = ({ path: lockPath, attemptId }) => { + if (releaseFenceTriggered) return; + releaseFenceTriggered = true; + syncFs.renameSync(lockPath, `${lockPath}.${attemptId}.lost`); + syncFs.writeFileSync(lockPath, "{}\n", { mode: 0o600 }); + }; + + await expect(SessionManager.deleteManagedCandidate(source)).rejects.toBeInstanceOf(SessionMigrationBusyError); + expect(releaseFenceTriggered).toBe(true); + }); + it("distinguishes artifact capacity from unsafe topology violations", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-artifact-validation-")); temporaryDirectories.push(root); @@ -638,7 +830,7 @@ describe("managed session write protocol", () => { ]), ); }); - it("retains cleanup-pending placeholder authority in the committed migration receipt", async () => { + it("completes verified placeholder cleanup in the committed migration receipt", async () => { if (process.platform === "win32") return; const { cwd, sessionsRoot, scope } = await fixture(); const legacy = legacyDirectory(sessionsRoot, cwd); @@ -668,7 +860,7 @@ describe("managed session write protocol", () => { }, }); }); - it("persists cleanup-pending placeholder authority and fails closed when it is replaced during replay", async () => { + it("does not persist removed placeholder authority during replay", async () => { if (process.platform === "win32") return; const { cwd, sessionsRoot, scope } = await fixture(); const legacy = legacyDirectory(sessionsRoot, cwd); @@ -821,7 +1013,6 @@ describe("managed session write protocol", () => { if (firstListing.kind !== "complete") throw new Error(firstListing.message); const first = firstListing.owned.find(candidate => candidate.path === targetPath); if (!first) throw new Error("Missing first v2 candidate"); - const restoreCleanup = forceImmediateNativeCleanup(); const firstDelete = await deleteManagedSessionCandidate(scope, first); expect(firstDelete).toMatchObject({ kind: "deleted" }); if (firstDelete.kind !== "deleted") throw new Error("Expected deleted"); @@ -843,49 +1034,6 @@ describe("managed session write protocol", () => { expect( (await fs.readdir(tombstones)).filter(name => name.endsWith(".json") && !name.includes(".cleanup-")), ).toHaveLength(2); - restoreCleanup(); - }); - it("fails managed transcript cleanup closed while its inode survives under another parent entry", async () => { - const { cwd, sessionsRoot, scope } = await fixture(); - await prepareManagedSessionScopeForWrite(scope); - const targetPath = path.join(scope.directoryPath, "retained-transcript-hardlink.jsonl"); - const retainedHardlink = path.join(scope.directoryPath, ".retained-transcript-nested", "deeper", "link.jsonl"); - await fs.writeFile(targetPath, transcript("retained-transcript-hardlink", cwd)); - await fs.mkdir(path.dirname(retainedHardlink), { recursive: true }); - const listed = listManagedCandidates(scope); - if (listed.kind !== "complete") throw new Error(listed.message); - const target = listed.owned.find(candidate => candidate.path === targetPath); - if (!target) throw new Error("Missing retained-hardlink target"); - const exactUnlink = native.exactUnlink; - const unlink = vi.spyOn(native, "exactUnlink").mockImplementation((pathname, identity) => { - if (pathname === targetPath && !syncFs.existsSync(retainedHardlink)) - syncFs.linkSync(pathname, retainedHardlink); - return exactUnlink(pathname, identity); - }); - - const pending = await deleteManagedSessionCandidate(scope, target); - expect(pending).toMatchObject({ kind: "error", code: "managed_storage_unsupported" }); - unlink.mockRestore(); - const tombstones = path.join(scope.directoryPath, ".gjc-managed-session-internal", "tombstones"); - for (const name of await fs.readdir(tombstones)) { - if (!name.includes(".cleanup-pending-")) continue; - const receipt = JSON.parse(await fs.readFile(path.join(tombstones, name), "utf8")) as Record; - for (const key of [ - "plannedTranscriptPath", - "detachedTranscriptPath", - "retainedTranscriptSuccessorPath", - "retainedTranscriptPlaceholderPath", - "retainedTranscriptUnknownPath", - ]) { - const pathname = receipt[key]; - if (typeof pathname === "string") await fs.rm(pathname, { recursive: true, force: true }); - } - } - const restarted = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); - if (restarted.kind !== "resolved") throw new Error(restarted.message); - expect((await prepareManagedSessionScopeForWrite(restarted.scope)).kind).toBe("error"); - expect(await fs.stat(retainedHardlink)).toBeDefined(); - expect((await fs.readdir(tombstones)).some(name => name.includes(".cleanup-completed-"))).toBe(false); }); it("keeps a migrated session singular after legitimate resumed appends", async () => { const { cwd, sessionsRoot, scope } = await fixture(); @@ -912,7 +1060,7 @@ describe("managed session write protocol", () => { ]); }); - it("tombstones the retained legacy source when an appended migrated session is deleted", async () => { + it("completes migrated deletion after descriptor-bound artifact payload scrubbing", async () => { const { cwd, sessionsRoot, scope } = await fixture(); const legacy = legacyDirectory(sessionsRoot, cwd); const source = path.join(legacy, "append-delete.jsonl"); @@ -933,17 +1081,249 @@ describe("managed session write protocol", () => { const active = listed.owned.find(candidate => candidate.path === opened.path); if (!active) throw new Error("Missing appended v2 candidate"); - const restoreCleanup = forceImmediateNativeCleanup(); - expect(await deleteManagedSessionCandidate(scope, active)).toMatchObject({ + await expect(deleteManagedSessionCandidate(scope, active)).resolves.toMatchObject({ kind: "deleted", tombstonePath: expect.stringContaining(".json"), }); await expect(fs.access(source)).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.access(opened.path)).rejects.toMatchObject({ code: "ENOENT" }); await expect(fs.access(artifactRoot)).rejects.toMatchObject({ code: "ENOENT" }); - restoreCleanup(); }); + it("advances a root-only retained artifact tree without hiding payload bytes", async () => { + const { cwd, sessionsRoot, scope } = await fixture(); + const legacy = legacyDirectory(sessionsRoot, cwd); + const source = path.join(legacy, "root-only-artifacts.jsonl"); + const artifacts = source.slice(0, -6); + await fs.mkdir(artifacts, { recursive: true }); + await fs.writeFile(path.join(artifacts, "artifact.txt"), "payload"); + await fs.writeFile(source, transcript("root-only-artifacts", cwd)); + const listed = listManagedCandidates(scope); + if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); + const exactUnlink = native.exactUnlink; + let retainedRoot: string | undefined; + const unlink = vi.spyOn(native, "exactUnlink").mockImplementation((pathname, identity) => { + if (pathname !== artifacts) return exactUnlink(pathname, identity); + if (!identity.directory || !identity.quarantineName) throw new Error("Missing artifact quarantine identity"); + retainedRoot = path.join(path.dirname(pathname), identity.quarantineName); + syncFs.renameSync(pathname, retainedRoot); + return { ok: true, detachedPath: retainedRoot }; + }); + const remove = vi.spyOn(native, "exactRemoveDirectoryTree").mockImplementation(pathname => { + for (const name of syncFs.readdirSync(pathname)) + syncFs.rmSync(path.join(pathname, name), { recursive: true, force: true }); + return { ok: false, code: "cleanup_pending", payloadDurable: true, detachedPath: pathname }; + }); + try { + await expect(deleteManagedSessionCandidate(scope, listed.owned[0])).resolves.toMatchObject({ + kind: "deleted", + tombstonePath: expect.stringContaining(".json"), + }); + } finally { + remove.mockRestore(); + unlink.mockRestore(); + } + if (!retainedRoot) throw new Error("Missing retained artifact root"); + expect(await fs.readdir(retainedRoot)).toEqual([]); + await expect(fs.access(source)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("completes after a crash following transcript unlink with a durable retained artifact root", async () => { + const { cwd, sessionsRoot, scope } = await fixture(); + const source = path.join(legacyDirectory(sessionsRoot, cwd), "post-transcript-crash.jsonl"); + const artifacts = source.slice(0, -6); + await fs.mkdir(artifacts, { recursive: true }); + await fs.writeFile(path.join(artifacts, "artifact.txt"), "payload"); + await fs.writeFile(source, transcript("post-transcript-crash", cwd)); + const listed = listManagedCandidates(scope); + if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); + const exactUnlink = native.exactUnlink; + let retainedRoot: string | undefined; + const unlink = vi.spyOn(native, "exactUnlink").mockImplementation((pathname, identity) => { + if (pathname !== artifacts) return exactUnlink(pathname, identity); + if (!identity.directory || !identity.quarantineName) throw new Error("Missing artifact quarantine identity"); + retainedRoot = path.join(path.dirname(pathname), identity.quarantineName); + syncFs.renameSync(pathname, retainedRoot); + return { ok: true, detachedPath: retainedRoot }; + }); + const remove = vi.spyOn(native, "exactRemoveDirectoryTree").mockImplementation(pathname => { + syncFs.writeFileSync(path.join(pathname, "artifact.txt"), ""); + return { ok: false, code: "cleanup_pending", payloadDurable: true, detachedPath: pathname }; + }); + const verifiedDelete = FileSessionStorage.prototype.deleteSessionVerified; + let interrupted = false; + const deleteSpy = vi + .spyOn(FileSessionStorage.prototype, "deleteSessionVerified") + .mockImplementation(async function (this: FileSessionStorage, target) { + const result = await verifiedDelete.call(this, target); + if (!interrupted && target.artifactsRemoved === true && !syncFs.existsSync(source)) { + interrupted = true; + throw new Error("test_crash_after_transcript_unlink"); + } + return result; + }); + try { + await expect(deleteManagedSessionCandidate(scope, listed.owned[0])).resolves.toMatchObject({ + kind: "error", + message: "test_crash_after_transcript_unlink", + }); + } finally { + deleteSpy.mockRestore(); + remove.mockRestore(); + unlink.mockRestore(); + } + if (!retainedRoot) throw new Error("Missing retained artifact root"); + await expect(fs.access(source)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await fs.readFile(path.join(retainedRoot, "artifact.txt"))).toEqual(Buffer.alloc(0)); + + const replayRemove = vi.spyOn(native, "exactRemoveDirectoryTree").mockImplementation(pathname => ({ + ok: false, + code: "cleanup_pending", + payloadDurable: true, + detachedPath: pathname, + })); + try { + const restarted = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); + if (restarted.kind !== "resolved") throw new Error(restarted.message); + await reconcileManagedTombstones(restarted.scope); + expect(await prepareManagedSessionScopeForWrite(restarted.scope)).toMatchObject({ kind: "resolved" }); + expect(await fs.readFile(path.join(retainedRoot, "artifact.txt"))).toEqual(Buffer.alloc(0)); + const tombstones = path.join(scope.directoryPath, ".gjc-managed-session-internal", "tombstones"); + expect((await fs.readdir(tombstones)).some(name => name.includes(".cleanup-completed-"))).toBe(true); + } finally { + replayRemove.mockRestore(); + } + }); + + it("keeps a root-only retained artifact pending without native durability proof", async () => { + const { cwd, sessionsRoot, scope } = await fixture(); + const source = path.join(legacyDirectory(sessionsRoot, cwd), "undurable-root-only-artifacts.jsonl"); + const artifacts = source.slice(0, -6); + await fs.mkdir(artifacts, { recursive: true }); + await fs.writeFile(path.join(artifacts, "artifact.txt"), "payload"); + await fs.writeFile(source, transcript("undurable-root-only-artifacts", cwd)); + const listed = listManagedCandidates(scope); + if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); + const exactUnlink = native.exactUnlink; + let retainedRoot: string | undefined; + const unlink = vi.spyOn(native, "exactUnlink").mockImplementation((pathname, identity) => { + if (pathname !== artifacts) return exactUnlink(pathname, identity); + if (!identity.directory || !identity.quarantineName) throw new Error("Missing artifact quarantine identity"); + retainedRoot = path.join(path.dirname(pathname), identity.quarantineName); + syncFs.renameSync(pathname, retainedRoot); + return { ok: true, detachedPath: retainedRoot }; + }); + const remove = vi.spyOn(native, "exactRemoveDirectoryTree").mockImplementation(pathname => { + for (const name of syncFs.readdirSync(pathname)) + syncFs.rmSync(path.join(pathname, name), { recursive: true, force: true }); + return { ok: false, code: "cleanup_pending", detachedPath: pathname }; + }); + try { + await expect(deleteManagedSessionCandidate(scope, listed.owned[0])).resolves.toMatchObject({ + kind: "cleanup_pending", + phase: "artifacts", + }); + } finally { + remove.mockRestore(); + unlink.mockRestore(); + } + if (!retainedRoot) throw new Error("Missing retained artifact root"); + expect(await fs.readdir(retainedRoot)).toEqual([]); + expect(await fs.readFile(source, "utf8")).toContain("undurable-root-only-artifacts"); + const tombstones = path.join(scope.directoryPath, ".gjc-managed-session-internal", "tombstones"); + expect((await fs.readdir(tombstones)).some(name => name.includes(".cleanup-completed-"))).toBe(false); + }); + + it.skipIf(process.platform === "win32")( + "advances a persisted root-only artifact receipt after a fresh-scope replay", + async () => { + const { cwd, sessionsRoot, scope } = await fixture(); + const legacy = legacyDirectory(sessionsRoot, cwd); + const source = path.join(legacy, "root-only-replay.jsonl"); + const artifacts = source.slice(0, -6); + await fs.mkdir(artifacts, { recursive: true }); + await fs.writeFile(path.join(artifacts, "artifact.txt"), "payload"); + await fs.writeFile(source, transcript("root-only-replay", cwd)); + const listed = listManagedCandidates(scope); + if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); + const exactUnlink = native.exactUnlink; + const exactRemoveDirectoryTree = native.exactRemoveDirectoryTree; + let retainedRoot: string | undefined; + const unlink = vi.spyOn(native, "exactUnlink").mockImplementation((pathname, identity) => { + if (pathname !== artifacts) return exactUnlink(pathname, identity); + if (!identity.directory || !identity.quarantineName) + throw new Error("Missing artifact quarantine identity"); + retainedRoot = path.join(path.dirname(pathname), identity.quarantineName); + syncFs.renameSync(pathname, retainedRoot); + return { ok: true, detachedPath: retainedRoot }; + }); + const remove = vi + .spyOn(native, "exactRemoveDirectoryTree") + .mockImplementation(pathname => ({ ok: false, code: "cleanup_pending", detachedPath: pathname })); + try { + await expect(deleteManagedSessionCandidate(scope, listed.owned[0])).resolves.toMatchObject({ + kind: "cleanup_pending", + phase: "artifacts", + }); + } finally { + remove.mockRestore(); + unlink.mockRestore(); + } + if (!retainedRoot) throw new Error("Missing retained artifact root"); + const expectScrubbedRetainedRoot = async (pathname: string): Promise => { + expect((await fs.readdir(pathname)).sort()).toEqual(["artifact.txt"]); + expect(await fs.readFile(path.join(pathname, "artifact.txt"))).toEqual(Buffer.alloc(0)); + }; + await fs.writeFile(path.join(retainedRoot, "artifact.txt"), ""); + + const legacyFinalize = vi + .spyOn(native, "exactRemoveDirectoryTree") + .mockImplementationOnce((pathname, snapshot) => { + const result = exactRemoveDirectoryTree(pathname, snapshot); + if (!(result as { payloadDurable?: boolean }).payloadDurable) + throw new Error("Missing native durable payload proof"); + return result; + }); + const verifiedDelete = FileSessionStorage.prototype.deleteSessionVerified; + let interrupted = false; + const deleteSpy = vi + .spyOn(FileSessionStorage.prototype, "deleteSessionVerified") + .mockImplementation(async function (this: FileSessionStorage, target) { + const result = await verifiedDelete.call(this, target); + if ( + !interrupted && + target.detachedArtifactsPath === retainedRoot && + result.kind === "cleanup_pending" && + result.phase === "artifacts" && + result.detachedArtifactsPath === `${retainedRoot}.removing` + ) { + interrupted = true; + throw new Error("test_crash_before_detached_root_followup"); + } + return result; + }); + try { + const interruptedScope = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); + if (interruptedScope.kind !== "resolved") throw new Error(interruptedScope.message); + await expect(reconcileManagedTombstones(interruptedScope.scope)).rejects.toThrow( + "test_crash_before_detached_root_followup", + ); + } finally { + deleteSpy.mockRestore(); + legacyFinalize.mockRestore(); + } + await expect(fs.access(retainedRoot)).rejects.toMatchObject({ code: "ENOENT" }); + await expectScrubbedRetainedRoot(`${retainedRoot}.removing`); + + const restarted = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); + if (restarted.kind !== "resolved") throw new Error(restarted.message); + await reconcileManagedTombstones(restarted.scope); + expect(await prepareManagedSessionScopeForWrite(restarted.scope)).toMatchObject({ kind: "resolved" }); + await expectScrubbedRetainedRoot(`${retainedRoot}.removing`); + await expect(fs.access(source)).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + it("rejects a replaced migration destination even when bytes and session lineage match", async () => { const { cwd, sessionsRoot, scope } = await fixture(); const legacy = legacyDirectory(sessionsRoot, cwd); @@ -971,7 +1351,7 @@ describe("managed session write protocol", () => { .sort(), ).toEqual(["legacy", "v2"]); }); - it("a fresh scope keeps tombstoned pending cleanup hidden without resurrecting either migration copy", async () => { + it("a fresh scope resumes a tombstoned exact-target cleanup without resurrecting either migration copy", async () => { const { cwd, sessionsRoot, scope } = await fixture(); const legacy = legacyDirectory(sessionsRoot, cwd); const source = path.join(legacy, "restart.jsonl"); @@ -982,35 +1362,29 @@ describe("managed session write protocol", () => { const opened = await openManagedCandidateForWrite(scope, listed.owned[0]); if (opened.kind !== "opened") throw new Error(opened.message); - const exactUnlink = vi.spyOn(native, "exactUnlink").mockImplementationOnce((pathname, identity) => { - const detachedPath = path.join(path.dirname(pathname), identity.quarantineName!); - syncFs.renameSync(pathname, detachedPath); - return { ok: false, code: "cleanup_pending", detachedPath }; - }); + const nativeExactUnlink = native.exactUnlink; + const exactUnlink = vi + .spyOn(native, "exactUnlink") + .mockImplementation((pathname, identity) => + pathname === opened.path ? { ok: false, code: "io_error" } : nativeExactUnlink(pathname, identity), + ); try { const interrupted = await deleteManagedSessionCandidate(scope, opened.candidate); - expect(interrupted).toMatchObject({ kind: "cleanup_pending", phase: "transcript" }); + expect(interrupted).toMatchObject({ kind: "error", code: "durability_failed" }); } finally { exactUnlink.mockRestore(); } - const restoreCleanup = forceImmediateNativeCleanup(); const fresh = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); expect(fresh.kind).toBe("resolved"); if (fresh.kind !== "resolved") throw new Error(fresh.message); - const remaining = listManagedCandidates(fresh.scope); - if (remaining.kind !== "complete") throw new Error(remaining.message); - const remainingCandidate = remaining.owned.find(candidate => candidate.sessionId === "restart"); - if (remainingCandidate) { - const recovered = await deleteManagedSessionCandidate(fresh.scope, remainingCandidate); - if (recovered.kind === "error") throw new Error(recovered.message); - expect(["deleted", "already_deleted"]).toContain(recovered.kind); - } + const recovered = await deleteManagedSessionCandidate(fresh.scope, opened.candidate); + expect(recovered).toMatchObject({ kind: "already_deleted", tombstonePath: expect.stringContaining(".json") }); expect( await fs.access(source).then( () => true, () => false, ), - ).toBe(true); + ).toBe(false); expect( await fs.access(opened.path).then( () => true, @@ -1018,7 +1392,44 @@ describe("managed session write protocol", () => { ), ).toBe(false); expect(listManagedCandidates(fresh.scope)).toMatchObject({ kind: "complete", owned: [] }); - restoreCleanup(); + }); + + it("preserves migration_busy when tombstone reconciliation lock is held", async () => { + const { cwd, sessionsRoot, scope } = await fixture(); + const legacy = legacyDirectory(sessionsRoot, cwd); + const source = path.join(legacy, "busy-tombstone.jsonl"); + await fs.mkdir(legacy, { recursive: true }); + await fs.writeFile(source, transcript("busy-tombstone", cwd)); + const listed = listManagedCandidates(scope); + if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing legacy candidate"); + const opened = await openManagedCandidateForWrite(scope, listed.owned[0]); + if (opened.kind !== "opened") throw new Error(opened.message); + + const nativeExactUnlink = native.exactUnlink; + const unlink = vi + .spyOn(native, "exactUnlink") + .mockImplementation((pathname, identity) => + pathname === opened.path ? { ok: false, code: "io_error" } : nativeExactUnlink(pathname, identity), + ); + try { + await expect(deleteManagedSessionCandidate(scope, opened.candidate)).resolves.toMatchObject({ + kind: "error", + code: "durability_failed", + }); + } finally { + unlink.mockRestore(); + } + + const lock = vi.spyOn(managedSessionStorage, "acquireManagedLock").mockRejectedValue(new Error("migration_busy")); + try { + await expect(prepareManagedSessionScopeForWrite(scope)).resolves.toMatchObject({ + kind: "error", + code: "migration_busy", + message: "migration_busy", + }); + } finally { + lock.mockRestore(); + } }); it("treats a symlinked committed receipt as untrusted and keeps the retained legacy transcript visible", async () => { @@ -1203,7 +1614,6 @@ describe("managed session write protocol", () => { (result): result is Extract => result.kind === "opened", ); if (!opened) return; - const restoreCleanup = forceImmediateNativeCleanup(); const deleted = await deleteManagedSessionCandidate(scope, opened.candidate); expect(deleted.kind).toBe("deleted"); if (deleted.kind !== "deleted") throw new Error("Expected deleted"); @@ -1213,7 +1623,6 @@ describe("managed session write protocol", () => { if (replay.kind !== "already_deleted") throw new Error("Expected already_deleted"); expect(replay.tombstonePath).toBe(deleted.tombstonePath); expect(await fs.stat(deleted.tombstonePath)).toBeDefined(); - restoreCleanup(); }); it.skipIf(process.platform !== "linux")( "does not publish cleanup completion when the deleted transcript parent cannot be fsynced", @@ -1534,11 +1943,9 @@ describe("managed session write protocol", () => { const listed = listManagedCandidates(scope); if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); - const restoreCleanup = forceImmediateNativeCleanup(); await expect(deleteManagedSessionCandidate(scope, listed.owned[0])).resolves.toMatchObject({ kind: "deleted", }); - restoreCleanup(); const restarted = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); if (restarted.kind !== "resolved") throw new Error(restarted.message); @@ -1560,11 +1967,9 @@ describe("managed session write protocol", () => { const listed = listManagedCandidates(scope); if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); - const restoreCleanup = forceImmediateNativeCleanup(); await expect(deleteManagedSessionCandidate(scope, listed.owned[0])).resolves.toMatchObject({ kind: "deleted", }); - restoreCleanup(); const tombstones = path.join(scope.directoryPath, ".gjc-managed-session-internal", "tombstones"); const completed = (await fs.readdir(tombstones)).filter(name => name.includes(".cleanup-completed-")); @@ -1649,7 +2054,7 @@ describe("managed session write protocol", () => { const restarted = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); if (restarted.kind !== "resolved") throw new Error(restarted.message); expect((await prepareManagedSessionScopeForWrite(restarted.scope)).kind).toBe("resolved"); - expect(await fs.stat(source)).toBeDefined(); + expect(await fs.stat(source).catch(() => undefined)).toBeUndefined(); }); it("replays an unchanged retained deterministic .removing root from its artifact receipt", async () => { const { cwd, sessionsRoot, scope } = await fixture(); @@ -1690,7 +2095,7 @@ describe("managed session write protocol", () => { const restarted = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); if (restarted.kind !== "resolved") throw new Error(restarted.message); expect(await prepareManagedSessionScopeForWrite(restarted.scope)).toMatchObject({ kind: "resolved" }); - expect(await fs.stat(source)).toBeDefined(); + expect(await fs.stat(source).catch(() => undefined)).toBeUndefined(); }); it("rejects a replacement retained deterministic .removing root during replay", async () => { const { cwd, sessionsRoot, scope } = await fixture(); @@ -1699,7 +2104,8 @@ describe("managed session write protocol", () => { const artifacts = source.slice(0, -6); await fs.mkdir(artifacts, { recursive: true }); await fs.writeFile(path.join(artifacts, "artifact.txt"), "payload"); - await fs.writeFile(source, transcript("retained-root-replacement", cwd)); + const sourceTranscript = transcript("retained-root-replacement", cwd); + await fs.writeFile(source, sourceTranscript); const listed = listManagedCandidates(scope); if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); const exactUnlink = native.exactUnlink; @@ -1759,6 +2165,8 @@ describe("managed session write protocol", () => { kind: "error", code: "binding_invalid", }); + expect(await fs.readFile(source, "utf8")).toBe(sourceTranscript); + expect((await fs.readdir(tombstones)).some(name => name.includes(".cleanup-completed-"))).toBe(false); expect(await fs.readFile(path.join(receipt.detachedArtifactsPath, "replacement.txt"), "utf8")).toBe( "replacement", ); @@ -1791,7 +2199,6 @@ describe("managed session write protocol", () => { kind: "cleanup_pending", phase: "artifacts", tombstonePath: expect.stringContaining(".json"), - message: "Exact cleanup remains pending because descriptor-bound final deletion is unavailable.", }); } finally { remove.mockRestore(); @@ -1801,7 +2208,7 @@ describe("managed session write protocol", () => { const restarted = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); if (restarted.kind !== "resolved") throw new Error(restarted.message); expect((await prepareManagedSessionScopeForWrite(restarted.scope)).kind).toBe("resolved"); - expect(await fs.stat(source)).toBeDefined(); + expect(await fs.stat(source).catch(() => undefined)).toBeUndefined(); }); it("rejects a forged cleanup chain whose detached pathname was not planned by its predecessor", async () => { @@ -1823,7 +2230,11 @@ describe("managed session write protocol", () => { syncFs.renameSync(pathname, detachedPath); return { ok: true, detachedPath }; }); - const remove = vi.spyOn(native, "exactRemoveDirectoryTree").mockReturnValueOnce({ ok: false, code: "io_error" }); + const remove = vi.spyOn(native, "exactRemoveDirectoryTree").mockImplementationOnce(pathname => { + const retainedPath = `${pathname}.removing`; + syncFs.renameSync(pathname, retainedPath); + return { ok: false, code: "io_error", detachedPath: retainedPath }; + }); try { await expect(deleteManagedSessionCandidate(scope, listed.owned[0])).resolves.toMatchObject({ kind: "cleanup_pending", @@ -2031,7 +2442,10 @@ describe("managed session write protocol", () => { } expect(q2).toEqual(expect.any(String)); expect(await fs.stat(q1).catch(() => undefined)).toBeUndefined(); - expect(await fs.stat(q2!)).toBeDefined(); + const q2Retained = await fs.stat(q2!, { bigint: true }); + expect(q2Retained.isFile()).toBe(true); + expect(q2Retained.nlink).toBe(1n); + expect(q2Retained.size).toBe(0n); expect(listManagedCandidates(scope)).toMatchObject({ kind: "complete", owned: [] }); }); @@ -2128,9 +2542,285 @@ describe("managed session write protocol", () => { expect(JSON.parse(stdout)).toEqual({ kind: "resolved", message: null }); for (const [name, content] of pendingReceipts) expect(await fs.readFile(path.join(tombstones, name), "utf8")).toBe(content); - expect(await fs.lstat(retainedArtifactAuthority).catch(() => undefined)).toBeUndefined(); - expect((await fs.readdir(tombstones)).some(name => name.includes(".cleanup-completed-"))).toBe(false); + if (process.platform === "win32") { + await expect(fs.access(retainedArtifactAuthority)).rejects.toMatchObject({ code: "ENOENT" }); + } else { + const retainedTree = native.snapshotDirectoryTree(`${retainedArtifactAuthority}.removing`); + expect(retainedTree.ok).toBe(true); + expect( + retainedTree.snapshot?.entries.every( + entry => + entry.kind === "directory" || + (entry.size === "0" && + entry.sha256 === "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), + ), + ).toBe(true); + } + expect((await fs.readdir(tombstones)).some(name => name.includes(".cleanup-completed-"))).toBe(true); }); + + it.skipIf(process.platform === "win32")( + "preserves a substituted successor after direct cleanup authorization", + async () => { + const { cwd, sessionsRoot, scope } = await fixture(); + const legacy = legacyDirectory(sessionsRoot, cwd); + const source = path.join(legacy, "direct-successor-race.jsonl"); + const artifacts = source.slice(0, -6); + await fs.mkdir(artifacts, { recursive: true }); + await fs.writeFile(path.join(artifacts, "artifact.txt"), "stale artifact payload"); + const staleTranscript = transcript("direct-successor-race", cwd, "stale transcript payload"); + await fs.writeFile(source, staleTranscript); + const listed = listManagedCandidates(scope); + if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); + + const candidate = listed.owned[0]; + const predecessorIdentity = { dev: candidate.identity.dev, ino: candidate.identity.ino }; + const tombstones = path.join(scope.directoryPath, ".gjc-managed-session-internal", "tombstones"); + const retainedTranscript = `${source}.retained-stale`; + const successorTranscript = transcript("direct-successor-race", cwd, "successor transcript payload"); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + ManagedSessionScopeTestHooks.beforeVerifiedDelete = event => { + if (event.flow !== "direct" || event.stage !== "artifact-finalization") return; + entered.resolve(); + return release.promise; + }; + const deletion = deleteManagedSessionCandidate(scope, candidate); + let deletionResult: Awaited | undefined; + try { + await entered.promise; + const pending = await latestCleanupPendingReceipt(tombstones); + expect(pending.state).toBe("cleanup_pending"); + expect(pending.attempt).toBeGreaterThanOrEqual(2); + expect(pending.target.sessionId).toBe(candidate.sessionId); + expect(String(pending.target.identity.dev)).toBe(String(candidate.identity.dev)); + expect(String(pending.target.identity.ino)).toBe(String(candidate.identity.ino)); + expect(pending.detachedArtifactsPath).not.toBe(pending.plannedArtifactsPath); + + await fs.rename(source, retainedTranscript); + expect(physicalIdentity(retainedTranscript)).toEqual(predecessorIdentity); + await fs.writeFile(source, successorTranscript); + const successorIdentity = physicalIdentity(source); + expect( + successorIdentity.dev === predecessorIdentity.dev && successorIdentity.ino === predecessorIdentity.ino, + ).toBe(false); + await fs.mkdir(artifacts, { recursive: true }); + await fs.writeFile(path.join(artifacts, "successor.txt"), "successor artifact payload"); + const successorArtifactsIdentity = physicalIdentity(artifacts); + expect(String(successorArtifactsIdentity.dev)).toBe(String(pending.expectedArtifactsIdentity.dev)); + expect(String(successorArtifactsIdentity.ino)).not.toBe(String(pending.expectedArtifactsIdentity.ino)); + release.resolve(); + deletionResult = await deletion; + } finally { + release.resolve(); + ManagedSessionScopeTestHooks.beforeVerifiedDelete = undefined; + deletionResult ??= await deletion; + } + + expect(deletionResult).toMatchObject({ + kind: "error", + code: "managed_storage_unsupported", + message: "Transcript identity does not match authorization", + }); + expect(await fs.readFile(source, "utf8")).toBe(successorTranscript); + expect(await fs.readFile(path.join(artifacts, "successor.txt"), "utf8")).toBe("successor artifact payload"); + expect(await fs.readFile(retainedTranscript, "utf8")).toBe(staleTranscript); + const successorSnapshot = new FileSessionStorage().readSnapshotSync(source); + expect(Buffer.from(successorSnapshot.bytes).toString("utf8")).toContain("successor transcript payload"); + expect(successorSnapshot.stat.dev).toBe(physicalIdentity(source).dev); + expect(successorSnapshot.stat.ino).toBe(physicalIdentity(source).ino); + expect((await fs.readdir(tombstones)).some(name => name.includes(".cleanup-completed-"))).toBe(false); + }, + ); + + it.skipIf(process.platform === "win32")( + "preserves a substituted successor during fresh-scope cleanup reconciliation", + async () => { + const { cwd, sessionsRoot, scope } = await fixture(); + const legacy = legacyDirectory(sessionsRoot, cwd); + const source = path.join(legacy, "reconcile-successor-race.jsonl"); + const artifacts = source.slice(0, -6); + await fs.mkdir(artifacts, { recursive: true }); + await fs.writeFile(path.join(artifacts, "artifact.txt"), "stale artifact payload"); + const staleTranscript = transcript("reconcile-successor-race", cwd, "stale transcript payload"); + await fs.writeFile(source, staleTranscript); + const listed = listManagedCandidates(scope); + if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); + + const candidate = listed.owned[0]; + const predecessorIdentity = { dev: candidate.identity.dev, ino: candidate.identity.ino }; + const tombstones = path.join(scope.directoryPath, ".gjc-managed-session-internal", "tombstones"); + const retainedTranscript = `${source}.retained-stale`; + const successorTranscript = transcript("reconcile-successor-race", cwd, "successor transcript payload"); + ManagedSessionScopeTestHooks.beforeVerifiedDelete = event => { + if (event.flow === "direct" && event.stage === "artifact-finalization") throw new Error("test_crash"); + }; + await expect(deleteManagedSessionCandidate(scope, candidate)).resolves.toMatchObject({ + kind: "error", + message: "test_crash", + }); + const crashReceipt = await latestCleanupPendingReceipt(tombstones); + expect(crashReceipt.state).toBe("cleanup_pending"); + expect(crashReceipt.target.sessionId).toBe(candidate.sessionId); + expect(String(crashReceipt.target.identity.ino)).toBe(String(candidate.identity.ino)); + expect(crashReceipt.detachedArtifactsPath).not.toBe(crashReceipt.plannedArtifactsPath); + + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + ManagedSessionScopeTestHooks.beforeVerifiedDelete = event => { + if (event.flow !== "reconcile" || event.stage !== "initial") return; + entered.resolve(); + return release.promise; + }; + const restarted = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); + if (restarted.kind !== "resolved") throw new Error(restarted.message); + const reconciliation = prepareManagedSessionScopeForWrite(restarted.scope); + let reconciliationResult: Awaited | undefined; + try { + await entered.promise; + const replayReceipt = await latestCleanupPendingReceipt(tombstones); + expect(replayReceipt.target.sessionId).toBe(candidate.sessionId); + expect(String(replayReceipt.target.identity.dev)).toBe(String(candidate.identity.dev)); + expect(String(replayReceipt.target.identity.ino)).toBe(String(candidate.identity.ino)); + + await fs.rename(source, retainedTranscript); + expect(physicalIdentity(retainedTranscript)).toEqual(predecessorIdentity); + await fs.writeFile(source, successorTranscript); + const successorIdentity = physicalIdentity(source); + expect( + successorIdentity.dev === predecessorIdentity.dev && successorIdentity.ino === predecessorIdentity.ino, + ).toBe(false); + await fs.mkdir(artifacts, { recursive: true }); + await fs.writeFile(path.join(artifacts, "successor.txt"), "successor artifact payload"); + const successorArtifactsIdentity = physicalIdentity(artifacts); + expect(String(successorArtifactsIdentity.dev)).toBe(String(replayReceipt.expectedArtifactsIdentity.dev)); + expect(String(successorArtifactsIdentity.ino)).not.toBe( + String(replayReceipt.expectedArtifactsIdentity.ino), + ); + release.resolve(); + reconciliationResult = await reconciliation; + } finally { + release.resolve(); + ManagedSessionScopeTestHooks.beforeVerifiedDelete = undefined; + reconciliationResult ??= await reconciliation; + } + + expect(reconciliationResult).toMatchObject({ + kind: "error", + code: "binding_invalid", + message: "Managed write protocol setup failed.", + cause: { classification: "binding_invalid" }, + }); + expect(await fs.readFile(source, "utf8")).toBe(successorTranscript); + expect(await fs.readFile(path.join(artifacts, "successor.txt"), "utf8")).toBe("successor artifact payload"); + expect(await fs.readFile(retainedTranscript, "utf8")).toBe(staleTranscript); + const successorSnapshot = new FileSessionStorage().readSnapshotSync(source); + expect(Buffer.from(successorSnapshot.bytes).toString("utf8")).toContain("successor transcript payload"); + expect(successorSnapshot.stat.dev).toBe(physicalIdentity(source).dev); + expect(successorSnapshot.stat.ino).toBe(physicalIdentity(source).ino); + expect((await fs.readdir(tombstones)).some(name => name.includes(".cleanup-completed-"))).toBe(false); + }, + ); + + it.skipIf(process.platform === "win32")( + "rejects an unauthorized transcript quarantine returned during reconciliation", + async () => { + const { cwd, sessionsRoot, scope } = await fixture(); + const source = path.join(legacyDirectory(sessionsRoot, cwd), "reconcile-transcript-authority.jsonl"); + const artifacts = source.slice(0, -6); + await fs.mkdir(artifacts, { recursive: true }); + await fs.writeFile(path.join(artifacts, "artifact.txt"), "payload"); + const sourceTranscript = transcript("reconcile-transcript-authority", cwd, "transcript payload"); + await fs.writeFile(source, sourceTranscript); + const listed = listManagedCandidates(scope); + if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); + + ManagedSessionScopeTestHooks.beforeVerifiedDelete = event => { + if (event.flow === "direct" && event.stage === "artifact-finalization") throw new Error("test_crash"); + }; + await expect(deleteManagedSessionCandidate(scope, listed.owned[0])).resolves.toMatchObject({ + kind: "error", + message: "test_crash", + }); + ManagedSessionScopeTestHooks.beforeVerifiedDelete = undefined; + const unexpectedTranscriptPath = path.join(path.dirname(source), ".gjc-delete-unexpected-transcript"); + await fs.writeFile(unexpectedTranscriptPath, "foreign transcript sentinel", { mode: 0o600 }); + let deleteCalls = 0; + + const originalDelete = FileSessionStorage.prototype.deleteSessionVerified; + FileSessionStorage.prototype.deleteSessionVerified = async target => { + deleteCalls++; + return { + kind: "cleanup_pending", + phase: "transcript", + error: new Error("unexpected transcript quarantine"), + transcriptIdentity: target.transcriptIdentity, + detachedTranscriptPath: unexpectedTranscriptPath, + }; + }; + try { + const restarted = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); + if (restarted.kind !== "resolved") throw new Error(restarted.message); + await expect(prepareManagedSessionScopeForWrite(restarted.scope)).resolves.toMatchObject({ + kind: "error", + code: "durability_failed", + cause: { classification: "binding_invalid" }, + }); + } finally { + FileSessionStorage.prototype.deleteSessionVerified = originalDelete; + } + expect(deleteCalls).toBe(1); + expect(await fs.readFile(unexpectedTranscriptPath, "utf8")).toBe("foreign transcript sentinel"); + expect(await fs.readFile(source, "utf8")).toBe(sourceTranscript); + const tombstones = path.join(scope.directoryPath, ".gjc-managed-session-internal", "tombstones"); + expect((await fs.readdir(tombstones)).some(name => name.includes(".cleanup-completed-"))).toBe(false); + }, + ); + it.skipIf(process.platform === "win32")( + "revalidates retained artifact proof after the transcript cleanup seam", + async () => { + const { cwd, sessionsRoot, scope } = await fixture(); + const source = path.join(legacyDirectory(sessionsRoot, cwd), "retained-proof-race.jsonl"); + const artifacts = source.slice(0, -6); + await fs.mkdir(artifacts, { recursive: true }); + await fs.writeFile(path.join(artifacts, "artifact.txt"), "authorized payload"); + const sourceTranscript = transcript("retained-proof-race", cwd, "authorized transcript"); + await fs.writeFile(source, sourceTranscript); + const listed = listManagedCandidates(scope); + if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); + const tombstones = path.join(scope.directoryPath, ".gjc-managed-session-internal", "tombstones"); + let retainedArtifactsPath: string | undefined; + ManagedSessionScopeTestHooks.beforeVerifiedDelete = async event => { + if (event.flow !== "direct" || event.stage !== "transcript-after-artifacts-removed") return; + const receiptName = (await fs.readdir(tombstones)) + .filter(name => name.includes(".cleanup-artifacts_removed-")) + .sort() + .at(-1); + if (!receiptName) throw new Error("Missing artifacts-removed receipt"); + const receipt = JSON.parse(await fs.readFile(path.join(tombstones, receiptName), "utf8")) as { + detachedArtifactsPath?: string; + retainedArtifactsRoot?: { path?: string }; + }; + retainedArtifactsPath = receipt.retainedArtifactsRoot?.path; + expect(retainedArtifactsPath).toBe(receipt.detachedArtifactsPath); + if (!retainedArtifactsPath) throw new Error("Missing retained artifact proof path"); + await fs.writeFile(path.join(retainedArtifactsPath, "successor.bin"), "successor payload", { mode: 0o600 }); + }; + try { + await expect(deleteManagedSessionCandidate(scope, listed.owned[0])).resolves.toMatchObject({ + kind: "error", + code: "durability_failed", + message: "durability_failed", + }); + } finally { + ManagedSessionScopeTestHooks.beforeVerifiedDelete = undefined; + } + expect(await fs.readFile(source, "utf8")).toBe(sourceTranscript); + if (!retainedArtifactsPath) throw new Error("Missing retained artifact path"); + expect(await fs.readFile(path.join(retainedArtifactsPath, "successor.bin"), "utf8")).toBe("successor payload"); + expect((await fs.readdir(tombstones)).some(name => name.includes(".cleanup-completed-"))).toBe(false); + }, + ); it("rejects a dangling replacement at retained artifact authority during fresh replay", async () => { const { cwd, sessionsRoot, scope } = await fixture(); const legacy = legacyDirectory(sessionsRoot, cwd); @@ -2207,4 +2897,41 @@ describe("managed session write protocol", () => { expect(replacement.isSymbolicLink()).toBe(true); expect(await fs.readlink(pending.detachedArtifactsPath)).toBe(replacementTarget); }); + it("binds artifact retirement publication to the newest cleanup attempt", async () => { + const { cwd, sessionsRoot, scope } = await fixture(); + const legacy = legacyDirectory(sessionsRoot, cwd); + const source = path.join(legacy, "retirement-attempt.jsonl"); + const artifacts = source.slice(0, -6); + await fs.mkdir(artifacts, { recursive: true }); + await fs.writeFile(path.join(artifacts, "artifact.txt"), "payload"); + await fs.writeFile(source, transcript("retirement-attempt", cwd)); + const listed = listManagedCandidates(scope); + if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); + const exactUnlink = native.exactUnlink; + const unlink = vi.spyOn(native, "exactUnlink").mockImplementation((pathname, identity) => { + if (pathname !== artifacts) return exactUnlink(pathname, identity); + if (!identity.directory || !identity.quarantineName) throw new Error("Missing artifact quarantine identity"); + const retainedRoot = path.join(path.dirname(pathname), identity.quarantineName); + syncFs.renameSync(pathname, retainedRoot); + return { ok: true, detachedPath: retainedRoot }; + }); + const remove = vi.spyOn(native, "exactRemoveDirectoryTree").mockImplementation(pathname => { + for (const name of syncFs.readdirSync(pathname)) + syncFs.rmSync(path.join(pathname, name), { recursive: true, force: true }); + return { ok: false, code: "cleanup_pending", payloadDurable: true, detachedPath: pathname }; + }); + try { + const result = await deleteManagedSessionCandidate(scope, listed.owned[0]); + expect(result.kind).toBe("deleted"); + if (result.kind !== "deleted") throw new Error("unreachable"); + const tombstonesDir = path.dirname(result.tombstonePath); + const tombstoneFiles = await fs.readdir(tombstonesDir).catch(() => [] as string[]); + const artifactsRemoved = tombstoneFiles.find(name => name.includes("artifacts_removed")); + expect(artifactsRemoved).toBeDefined(); + expect(artifactsRemoved).toMatch(/-3\./); + } finally { + remove.mockRestore(); + unlink.mockRestore(); + } + }); }); diff --git a/packages/coding-agent/test/session-manager/session-durability-windows.test.ts b/packages/coding-agent/test/session-manager/session-durability-windows.test.ts index c554c1edf9..8ed73b2e8d 100644 --- a/packages/coding-agent/test/session-manager/session-durability-windows.test.ts +++ b/packages/coding-agent/test/session-manager/session-durability-windows.test.ts @@ -235,6 +235,7 @@ describe("managed session Windows durability", () => { stat.size = 0n; stat.mtimeNs = BigInt(nativeRoot.mtimeNs); vi.spyOn(syncFs, "lstatSync").mockReturnValue(stat); + const parentStat = syncFs.lstatSync(path.dirname(retainedPath), { bigint: true }); const cleanup = { state: "cleanup_pending" as const, role: "exchange_placeholder" as const, @@ -244,6 +245,8 @@ describe("managed session Windows durability", () => { ino: BigInt(nativeRoot.ino), size: 4096n, mtimeNs: BigInt(nativeRoot.mtimeNs), + parentDev: parentStat.dev, + parentIno: parentStat.ino, }, tree: expectedTree, }; @@ -323,6 +326,8 @@ describe("managed session Windows durability", () => { ino: stat.ino, size: BigInt(treeRoot.size), mtimeNs: BigInt(treeRoot.mtimeNs), + parentDev: syncFs.lstatSync(path.dirname(originalPath), { bigint: true }).dev, + parentIno: syncFs.lstatSync(path.dirname(originalPath), { bigint: true }).ino, }, tree: tree.snapshot, }, diff --git a/packages/coding-agent/test/session-resident-lifecycle.test.ts b/packages/coding-agent/test/session-resident-lifecycle.test.ts index f8ac74a0f8..fc7285f22d 100644 --- a/packages/coding-agent/test/session-resident-lifecycle.test.ts +++ b/packages/coding-agent/test/session-resident-lifecycle.test.ts @@ -218,15 +218,13 @@ describe("resident cache prune retention, lifecycle cleanup, and JSONL parity", expect(fs.existsSync(sessionFile)).toBe(true); }); - it("keeps managed deletion pending without descriptor-bound final cleanup authority", async () => { + it("completes managed deletion with descriptor-bound final cleanup authority", async () => { const survivor = await makeLargeSession(`pending delete survivor ${"v".repeat(2048)}`); await survivor.sm.close(); const deletion = await makeLargeSession(`pending delete cleanup ${"n".repeat(2048)}`); await deletion.sm.setSessionFile(survivor.sessionFile); - await expect(deletion.sm.dropSession(deletion.sessionFile)).rejects.toThrow( - "Exact cleanup remains pending because descriptor-bound final deletion is unavailable.", - ); + await expect(deletion.sm.dropSession(deletion.sessionFile)).resolves.toBeUndefined(); expect(fs.existsSync(deletion.sessionFile)).toBe(false); expect(fs.existsSync(deletion.artifactsDir)).toBe(false); expect(fs.existsSync(deletion.cacheDir)).toBe(false); diff --git a/packages/coding-agent/test/session-state-sidecar.test.ts b/packages/coding-agent/test/session-state-sidecar.test.ts index 3d5b2653a7..76f21afbf9 100644 --- a/packages/coding-agent/test/session-state-sidecar.test.ts +++ b/packages/coding-agent/test/session-state-sidecar.test.ts @@ -4,6 +4,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { postmortem } from "@gajae-code/utils"; +import { FileLockTestHooks } from "../src/config/file-lock"; import { sessionRuntimeDir } from "../src/gjc-runtime/session-layout"; import { eventAffectsCoordinatorRuntimeState, @@ -78,6 +79,7 @@ function git(cwd: string, args: string[]): void { } afterEach(async () => { + FileLockTestHooks.afterParentMkdir = undefined; if (ORIGINAL_STATE_FILE === undefined) delete process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]; else process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV] = ORIGINAL_STATE_FILE; if (ORIGINAL_SESSION_ID === undefined) delete process.env[GJC_COORDINATOR_SESSION_ID_ENV]; @@ -100,6 +102,52 @@ async function readJson(file: string): Promise> { } describe("coordinator runtime state sidecar", () => { + it("ignores a session root removed between postmortem lock parent creation and acquisition", async () => { + const root = await tempRoot(); + const stateFile = path.join(root, ".gjc", "_session-removed", "state", "runtime-state.json"); + const sessionRoot = path.resolve(path.dirname(stateFile), ".."); + process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV] = stateFile; + let removed = false; + FileLockTestHooks.afterParentMkdir = async lockPath => { + if (removed || !lockPath.endsWith("mutation.lock.lock")) return; + removed = true; + await fs.rm(sessionRoot, { recursive: true, force: true }); + }; + + await expect( + persistCoordinatorRuntimeStateFromPostmortem(postmortem.Reason.EXIT, { + sessionId: "removed", + cwd: root, + sessionFile: null, + }), + ).resolves.toBeUndefined(); + expect(removed).toBe(true); + expect(fsSync.existsSync(sessionRoot)).toBe(false); + }); + it("does not suppress a nested state lock failure while the owning session root remains", async () => { + const root = await tempRoot(); + const sessionRoot = path.join(root, ".gjc", "_session-present"); + const stateFile = path.join(sessionRoot, "runtime", "nested", "runtime-state.json"); + process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV] = stateFile; + await fs.mkdir(sessionRoot, { recursive: true }); + await fs.writeFile(path.join(sessionRoot, "owner.marker"), "present"); + let removed = false; + FileLockTestHooks.afterParentMkdir = async lockPath => { + if (removed || !lockPath.endsWith("mutation.lock.lock")) return; + removed = true; + await fs.rm(path.dirname(lockPath), { recursive: true, force: true }); + }; + + await expect( + persistCoordinatorRuntimeStateFromPostmortem(postmortem.Reason.EXIT, { + sessionId: "present", + cwd: root, + sessionFile: null, + }), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect(removed).toBe(true); + expect(fsSync.existsSync(sessionRoot)).toBe(true); + }); it("reports whether events affect coordinator runtime state", () => { const events = [ { event: { type: "message_update", message: {}, assistantMessageEvent: {} }, affects: false }, diff --git a/packages/coding-agent/test/session-storage.test.ts b/packages/coding-agent/test/session-storage.test.ts index e19c1b3d65..deb4cb7d22 100644 --- a/packages/coding-agent/test/session-storage.test.ts +++ b/packages/coding-agent/test/session-storage.test.ts @@ -705,14 +705,52 @@ describe("FileSessionStorage.deleteSessionVerified artifact-first", () => { const artifacts = await storage.deleteSessionVerified(target); if (artifacts.kind !== "cleanup_pending" || artifacts.phase !== "artifacts") throw new Error("Expected retained artifact cleanup"); - expect(artifacts.detachedArtifactsPath).toBe(plannedArtifactsPath); + expect(artifacts.detachedArtifactsPath).toBe(`${plannedArtifactsPath}.removing`); - expect(artifacts.retainedPlaceholderPath).toEqual(expect.any(String)); + expect(artifacts.retainedPlaceholderPath).toBeUndefined(); expect(fs.existsSync(artifactsDir)).toBe(false); - expect(fs.existsSync(plannedArtifactsPath)).toBe(true); + expect(fs.existsSync(`${plannedArtifactsPath}.removing`)).toBe(true); expect(fs.existsSync(transcriptPath)).toBe(true); }); + it("revalidates a retained scrubbed root immediately before transcript unlink", async () => { + const transcriptPath = await createTranscript("retained-boundary"); + const retainedRoot = path.join(tempDir, ".gjc-delete-retained-boundary-artifacts.removing"); + await fsp.mkdir(retainedRoot); + await Bun.write(path.join(retainedRoot, "artifact.txt"), ""); + const retainedStat = fs.lstatSync(retainedRoot, { bigint: true }); + const retainedTree = native.snapshotDirectoryTree(retainedRoot); + if (!retainedTree.ok || !retainedTree.snapshot) throw new Error("Missing retained tree snapshot"); + await Bun.write(path.join(retainedRoot, "successor.txt"), "successor payload"); + + const error = await storage + .deleteSessionVerified({ + sessionsRoot: tempDir, + transcriptPath, + sessionId: "session-id", + cwd: tempDir, + transcriptIdentity: verifiedIdentity(transcriptPath), + artifactsRemoved: true, + expectedArtifactsIdentity: { + dev: retainedStat.dev, + ino: retainedStat.ino, + size: Number(retainedStat.size), + mtimeNs: retainedStat.mtimeNs, + sha256: "", + }, + expectedArtifactsTree: retainedTree.snapshot, + detachedArtifactsPath: retainedRoot, + plannedArtifactsPath: path.join(tempDir, ".gjc-delete-retained-boundary-artifacts"), + plannedTranscriptPath: path.join(tempDir, ".gjc-delete-retained-boundary-transcript"), + }) + .catch(value => value); + + expect(error).toBeInstanceOf(SessionDeleteVerificationError); + expect((error as SessionDeleteVerificationError).kind).toBe("artifacts"); + expect(fs.existsSync(transcriptPath)).toBe(true); + expect(await Bun.file(path.join(retainedRoot, "successor.txt")).text()).toBe("successor payload"); + }); + it.skipIf(process.platform !== "linux")( "does not report artifacts removed before the session parent is durable", async () => { @@ -736,8 +774,8 @@ describe("FileSessionStorage.deleteSessionVerified artifact-first", () => { const error = await storage.deleteSessionVerified(target).catch(value => value); - expect(error).toBeInstanceOf(SessionDeleteVerificationError); - expect((error as SessionDeleteVerificationError).kind).toBe("artifacts"); + expect(error).toMatchObject({ kind: "cleanup_pending", phase: "artifacts" }); + expect((error as { error?: SessionDeleteVerificationError }).error?.kind).toBe("artifacts"); expect(fs.existsSync(transcriptPath)).toBe(true); expect(fs.existsSync(artifactsDir)).toBe(false); }, @@ -792,10 +830,10 @@ describe("FileSessionStorage.deleteSessionVerified artifact-first", () => { }); if (result.kind !== "cleanup_pending" || result.phase !== "artifacts") throw new Error("Expected pending tree cleanup"); - expect(remove).not.toHaveBeenCalled(); - expect(result.detachedArtifactsPath).toBe(plannedArtifactsPath); + expect(remove).toHaveBeenCalledTimes(1); + expect(result.detachedArtifactsPath).toBe(`${plannedArtifactsPath}.removing`); expect(await fsp.stat(artifactsDir).catch(() => undefined)).toBeUndefined(); - expect(await fsp.stat(plannedArtifactsPath)).toBeDefined(); + expect(await fsp.stat(`${plannedArtifactsPath}.removing`)).toBeDefined(); } finally { remove.mockRestore(); } @@ -818,8 +856,8 @@ describe("FileSessionStorage.deleteSessionVerified artifact-first", () => { const pending = await storage.deleteSessionVerified(target); if (pending.kind !== "cleanup_pending" || pending.phase !== "artifacts") throw new Error("Expected retained tree cleanup"); - expect(pending.detachedArtifactsPath).toBe(plannedArtifactsPath); - expect(await fsp.stat(plannedArtifactsPath)).toBeDefined(); + expect(pending.detachedArtifactsPath).toBe(`${plannedArtifactsPath}.removing`); + expect(await fsp.stat(`${plannedArtifactsPath}.removing`)).toBeDefined(); expect(fs.existsSync(transcriptPath)).toBe(true); }); @@ -893,7 +931,7 @@ describe("FileSessionStorage.deleteSessionVerified artifact-first", () => { if (partial.kind !== "cleanup_pending") throw new Error("unreachable"); expect(partial.phase).toBe("artifacts"); expect(partial.error).toBeInstanceOf(Error); - expect(partial.error.message).toBe("Exact artifact detach retained: cleanup_pending"); + expect(partial.error.message).toBe("Exact detached artifact removal rejected: cleanup_pending"); // Exact retry evidence includes the full transcript snapshot and detached artifact path. expect(partial.transcriptIdentity).toMatchObject({ dev: stat.dev, ino: stat.ino }); @@ -906,7 +944,7 @@ describe("FileSessionStorage.deleteSessionVerified artifact-first", () => { expect(fs.existsSync(transcriptPath)).toBe(true); expect(fs.existsSync(artifactsDir)).toBe(false); expect(fs.existsSync(artifactCleanup.detachedArtifactsPath)).toBe(true); - expect(artifactCleanup.retainedPlaceholderPath).toEqual(expect.any(String)); + expect(artifactCleanup.retainedPlaceholderPath).toBeUndefined(); }); it("exactly removes a retained artifact root before reconciling an absent transcript", async () => { @@ -1118,7 +1156,7 @@ describe("FileSessionStorage.deleteSessionVerified artifact-first", () => { const artifactsPending = await storage.deleteSessionVerified(target); if (artifactsPending.kind !== "cleanup_pending" || artifactsPending.phase !== "artifacts") throw new Error("Expected retained artifact cleanup"); - expect(artifactsPending.retainedPlaceholderPath).toEqual(expect.any(String)); + expect(artifactsPending.detachedArtifactsPath).toEqual(expect.any(String)); expect(fs.existsSync(artifactsDir)).toBe(false); expect(fs.existsSync(transcriptPath)).toBe(true); }); @@ -1430,7 +1468,7 @@ describe("FileSessionStorage.deleteSessionVerified artifact-first", () => { const artifactsPending = await storage.deleteSessionVerified(target); if (artifactsPending.kind !== "cleanup_pending" || artifactsPending.phase !== "artifacts") throw new Error("Expected retained artifact cleanup"); - expect(artifactsPending.retainedPlaceholderPath).toEqual(expect.any(String)); + expect(artifactsPending.detachedArtifactsPath).toEqual(expect.any(String)); expect(await fsp.readFile(transcriptPath, "utf8")).not.toContain('"raced"'); expect(fs.existsSync(artifactsDir)).toBe(false); }); diff --git a/packages/coding-agent/test/session/managed-lock-lease.windows.test.ts b/packages/coding-agent/test/session/managed-lock-lease.windows.test.ts new file mode 100644 index 0000000000..3b9751a568 --- /dev/null +++ b/packages/coding-agent/test/session/managed-lock-lease.windows.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { acquireManagedLock, ManagedLockTestHooks } from "../../src/session/internal/managed-session-storage"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + ManagedLockTestHooks.beforeObservedRetirement = undefined; + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +function createLockRoot(name: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-managed-lock-${name}-`)); + temporaryDirectories.push(root); + const locks = path.join(root, "locks"); + fs.mkdirSync(locks, { recursive: true }); + return locks; +} + +function readLock(pathname: string): Record { + return JSON.parse(fs.readFileSync(pathname, "utf8")) as Record; +} + +function expireLock(pathname: string): void { + const record = readLock(pathname); + fs.writeFileSync( + pathname, + `${JSON.stringify({ ...record, heartbeatAt: Date.now() - 10_000, leaseExpiresAt: Date.now() - 5_000 })}\n`, + ); +} + +describe("managed migration lock lease ownership", () => { + it("keeps a live starved holder exclusive past expiry, then permits acquisition after release", async () => { + const locks = createLockRoot("live-holder"); + const first = await acquireManagedLock(locks, "migration"); + try { + expireLock(first.path); + const waitStartedAt = Date.now(); + await expect(acquireManagedLock(locks, "migration")).rejects.toThrow("migration_busy"); + expect(Date.now() - waitStartedAt).toBeGreaterThanOrEqual(4_500); + + expect(() => first.assertOwned()).not.toThrow(); + const renewed = readLock(first.path); + expect(renewed.attemptId).toBe(first.attemptId); + expect(Number(renewed.leaseExpiresAt)).toBeGreaterThan(Date.now()); + } finally { + await first.release(); + } + + const successor = await acquireManagedLock(locks, "migration"); + try { + expect(successor.attemptId).not.toBe(first.attemptId); + expect(() => successor.assertOwned()).not.toThrow(); + } finally { + await successor.release(); + } + }, 15_000); + + it("fences a pathname replacement even when the replacement copies the old attempt id", async () => { + const locks = createLockRoot("path-aba"); + const first = await acquireManagedLock(locks, "migration"); + const parked = `${first.path}.parked`; + const original = fs.readFileSync(first.path); + fs.renameSync(first.path, parked); + fs.writeFileSync(first.path, original, { mode: 0o600 }); + + expect(() => first.assertOwned()).toThrow("migration_busy"); + await first.release().catch(() => undefined); + }); + + it("preserves a successor installed after a released lock was observed", async () => { + const locks = createLockRoot("retirement-race"); + const first = await acquireManagedLock(locks, "migration"); + await first.release(); + const successorAttemptId = "successor-attempt"; + let injected = false; + ManagedLockTestHooks.beforeObservedRetirement = ({ path: lockPath, attemptId }) => { + if (injected) return; + injected = true; + fs.renameSync(lockPath, `${lockPath}.${attemptId}.retired`); + const now = Date.now(); + fs.writeFileSync( + lockPath, + `${JSON.stringify({ + attemptId: successorAttemptId, + pid: process.pid, + processStartId: "successor-process", + createdAt: now, + heartbeatAt: now, + leaseExpiresAt: now + 60_000, + })}\n`, + { mode: 0o600 }, + ); + }; + + const waitStartedAt = Date.now(); + await expect(acquireManagedLock(locks, "migration")).rejects.toThrow("migration_busy"); + expect(Date.now() - waitStartedAt).toBeGreaterThanOrEqual(4_500); + expect(injected).toBe(true); + expect(readLock(first.path).attemptId).toBe(successorAttemptId); + }, 15_000); +}); diff --git a/packages/coding-agent/test/ultragoal-redteam-resident-cache.test.ts b/packages/coding-agent/test/ultragoal-redteam-resident-cache.test.ts index c94c9ea8ec..2f0f673cf5 100644 --- a/packages/coding-agent/test/ultragoal-redteam-resident-cache.test.ts +++ b/packages/coding-agent/test/ultragoal-redteam-resident-cache.test.ts @@ -13,6 +13,7 @@ const MiB = 1024 * 1024; const originalAgentDir = getAgentDir(); const originalAgentDirOverride = process.env.GJC_CODING_AGENT_DIR; const originalMaterializedCacheMaxBytesOverride = SessionManagerTestHooks.materializedCacheMaxBytesOverride; +const originalAfterForkSnapshot = SessionManagerTestHooks.afterForkSnapshot; const temporaryDirectories: string[] = []; beforeEach(() => { @@ -23,6 +24,7 @@ afterEach(async () => { vi.restoreAllMocks(); SessionManagerTestHooks.materializedCacheMaxBytesOverride = originalMaterializedCacheMaxBytesOverride; setAgentDir(originalAgentDir); + SessionManagerTestHooks.afterForkSnapshot = originalAfterForkSnapshot; if (originalAgentDirOverride === undefined) delete process.env.GJC_CODING_AGENT_DIR; else process.env.GJC_CODING_AGENT_DIR = originalAgentDirOverride; await Promise.all( @@ -327,10 +329,33 @@ describe.skipIf(process.platform === "win32")("ultragoal resident-cache adversar expect(await Bun.file(path.join(collisionArtifacts, "foreign.txt")).text()).toBe("foreign"); expectReadable(manager, branchText); + const forkSnapshotEntered = Promise.withResolvers(); + const releaseForkSnapshot = Promise.withResolvers(); + SessionManagerTestHooks.afterForkSnapshot = () => { + forkSnapshotEntered.resolve(); + return releaseForkSnapshot.promise; + }; const forkPromise = manager.fork(); - appendUserText(manager, interleavedText); - const forked = await forkPromise; + let forked: { oldSessionFile: string; newSessionFile: string } | undefined; + try { + await forkSnapshotEntered.promise; + appendUserText(manager, interleavedText); + releaseForkSnapshot.resolve(); + forked = await forkPromise; + } finally { + releaseForkSnapshot.resolve(); + SessionManagerTestHooks.afterForkSnapshot = originalAfterForkSnapshot; + await forkPromise.catch(() => undefined); + } if (!forked) throw new Error("Expected a forked session."); + const persistedSuccessor = await SessionManager.open(forked.newSessionFile); + try { + expectReadable(persistedSuccessor, rootText); + expectReadable(persistedSuccessor, branchText); + expect(JSON.stringify(persistedSuccessor.getEntries())).not.toContain(interleavedText); + } finally { + await persistedSuccessor.close(); + } await manager.flush(); expect(forked.oldSessionFile).toBe(sourceFile); expect(manager.getSessionFile()).toBe(forked.newSessionFile); @@ -349,6 +374,8 @@ describe.skipIf(process.platform === "win32")("ultragoal resident-cache adversar const forkedArtifacts = forked.newSessionFile.slice(0, -6); expect(await Bun.file(path.join(forkedArtifacts, "kept.txt")).text()).toBe("keep"); expect(fs.existsSync(path.join(forkedArtifacts, "resident-cache"))).toBe(false); + expect(await Bun.file(path.join(sourceArtifacts, "kept.txt")).text()).toBe("keep"); + expect(await Bun.file(path.join(sourceArtifacts, "resident-cache", "legacy.txt")).text()).toBe("never copy"); installVerifiedNativeCleanup(); await manager.dropSession(forked.oldSessionFile); diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 1d8b567dd2..98101c7557 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Fixed + +- POSIX exact directory-tree cleanup now operates only on the caller-authorized retained root, revalidates the root and each direct child against their descriptors before mutation, rejects initial and late hard-link aliases, and scrubs regular-file payloads through verified descriptors. Canonical root detachment remains the separate exact-unlink phase; replayable retained namespaces are never renamed again, and substituted successors are never renamed, unlinked, or truncated. ## [0.12.7] - 2026-07-31 diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 0ff5087c7d..7fc27cfa39 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -835,13 +835,25 @@ export declare enum Ellipsis { export declare function encodeSixel(bytes: Uint8Array, targetWidthPx: number, targetHeightPx: number): string /** - * Remove an already durably planned detached directory only when a fresh - * descriptor-relative snapshot exactly equals the persisted snapshot. The - * caller-planned root remains in place while its opened descriptor is - * authoritative throughout recursive removal. + * Remove a directory tree only when a fresh descriptor-relative snapshot + * exactly equals the persisted snapshot. POSIX first no-replace detaches the + * verified root to its deterministic `.removing` sibling; the reopened + * detached descriptor remains authoritative throughout payload scrubbing and + * replay. */ export declare function exactRemoveDirectoryTree(path: string, snapshot: NativeDirectoryTreeSnapshot, parentIdentity?: NativeDirectoryParentIdentity | undefined | null): NativeExactUnlinkResult +/** + * Replace a staged regular file only after validating the exact staged source + * and deleting the exact expected destination. + * + * Both identities must describe regular files, not directories or detach-only + * requests. Publication uses the retained verified source handle and a + * no-replace rename, so source substitution is rejected and a destination + * successor is preserved. + */ +export declare function exactReplacePath(sourcePath: string, destinationPath: string, expectedSource: NativeExactFileIdentity, expectedDestination: NativeExactFileIdentity): NativeExactUnlinkResult + /** * Restore only the detached object that still has the supplied platform * identity. The detached and original paths must retain the same validated @@ -1735,6 +1747,15 @@ export interface NativeExactFileIdentity { export interface NativeExactUnlinkResult { ok: boolean code?: string + /** + * True only when retained directory payloads were descriptor-scrubbed and + * every file plus containing directory namespace was fsynced before return. + */ + payloadDurable?: boolean + /** + * On Windows this is returned in the caller's namespace; retained handle + * operations continue to use the volume-GUID canonical path internally. + */ detachedPath?: string retainedSuccessorPath?: string /** @@ -1988,6 +2009,7 @@ export declare function readImageFromClipboard(): Promise, de }); } -function expectTreeCleanupPending(result: ReturnType, detachedPath: string): void { - expect(result).toEqual({ ok: false, code: "cleanup_pending", detachedPath }); +function expectTreeCleanupPending(result: ReturnType, plannedPath: string): void { + expect(result).toEqual({ + ok: false, + code: "cleanup_pending", + payloadDurable: true, + detachedPath: `${plannedPath}.removing`, + }); } function expectOwnerOnlySuccess( @@ -194,6 +199,41 @@ describe.skipIf(process.platform === "win32")("POSIX native path identity", () = expect(await fs.readFile(file, "utf8")).toBe("mutated!"); }); + it.skipIf(process.platform !== "linux" && process.platform !== "darwin")( + "rejects hard-linked regular files before exact detach or restore", + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-path-identity-posix-")); + temporaryDirectories.push(root); + const original = path.join(root, "session.jsonl"); + const originalAlias = path.join(root, "session-alias.jsonl"); + const detached = path.join(root, ".gjc-delete-session"); + await fs.writeFile(original, "authorized"); + await fs.link(original, originalAlias); + const originalStat = await fs.stat(original, { bigint: true }); + const identity = { + dev: originalStat.dev, + ino: originalStat.ino, + size: originalStat.size, + mtimeNs: originalStat.mtimeNs, + sha256: sha256("authorized"), + quarantineName: path.basename(detached), + detachOnly: true, + }; + + expect(exactUnlink(original, identity)).toEqual({ ok: false, code: "hard_link_unsupported" }); + expect(await fs.readFile(original, "utf8")).toBe("authorized"); + expect(await fs.readFile(originalAlias, "utf8")).toBe("authorized"); + + await fs.rm(originalAlias); + expectDetachedCleanupPending(exactUnlink(original, identity), detached); + const detachedAlias = path.join(root, "detached-alias.jsonl"); + await fs.link(detached, detachedAlias); + expect(exactRestore(detached, original, identity)).toEqual({ ok: false, code: "hard_link_unsupported" }); + expect(await fs.readFile(detached, "utf8")).toBe("authorized"); + expect(await fs.readFile(detachedAlias, "utf8")).toBe("authorized"); + }, + ); + it.skipIf(process.platform !== "linux" && process.platform !== "darwin")( "detaches an identity-bound directory to the preauthorized durable destination", async () => { @@ -298,7 +338,11 @@ describe.skipIf(process.platform === "win32")("POSIX native path identity", () = expectDetachedCleanupPending(exactUnlink(original, identity), detached); - expect(exactRestore(detached, original, identity)).toEqual({ ok: true }); + expect(exactRestore(detached, original, identity)).toMatchObject({ + ok: false, + code: "cleanup_pending", + retainedPlaceholderPath: expect.any(String), + }); expect(await fs.readFile(original, "utf8")).toBe("authorized"); }, ); @@ -501,7 +545,7 @@ describe.skipIf(process.platform === "win32")("POSIX native path identity", () = if (!snapshot.ok || !snapshot.snapshot) throw new Error("missing tree snapshot"); await fs.rm(first); - expectTreeCleanupPending(exactRemoveDirectoryTree(detached, snapshot.snapshot), `${detached}.removing`); + expectTreeCleanupPending(exactRemoveDirectoryTree(detached, snapshot.snapshot), detached); expect( await fs.stat(detached).then( @@ -509,6 +553,7 @@ describe.skipIf(process.platform === "win32")("POSIX native path identity", () = () => false, ), ).toBe(false); + expect(await fs.stat(`${detached}.removing`).then(stat => stat.isDirectory())).toBe(true); }, ); @@ -555,7 +600,7 @@ describe.skipIf(process.platform === "win32")("POSIX native path identity", () = const quarantine = treeQuarantineName(entry); expect(Buffer.byteLength(quarantine)).toBeLessThanOrEqual(255); await fs.rename(child, path.join(detached, quarantine)); - expectTreeCleanupPending(exactRemoveDirectoryTree(detached, snapshot.snapshot), `${detached}.removing`); + expectTreeCleanupPending(exactRemoveDirectoryTree(detached, snapshot.snapshot), detached); }, ); it.skipIf(process.platform !== "darwin")( diff --git a/packages/natives/test/path-identity-windows.test.ts b/packages/natives/test/path-identity-windows.test.ts index 3a5d43b8bc..b033aa6a60 100644 --- a/packages/natives/test/path-identity-windows.test.ts +++ b/packages/natives/test/path-identity-windows.test.ts @@ -8,6 +8,7 @@ import { applyOwnerOnlyPathSecurity, canonicalExistingDirectoryIdentity, exactRemoveDirectoryTree, + exactReplacePath, exactRestore, exactUnlink, renameNoReplacePath, @@ -23,6 +24,26 @@ function sha256(contents: string): string { return createHash("sha256").update(contents).digest("hex"); } +async function parentIdentity(pathname: string): Promise<{ parentDev: bigint; parentIno: bigint }> { + const parent = await fs.stat(path.dirname(pathname), { bigint: true }); + return { parentDev: parent.dev, parentIno: parent.ino }; +} + +function expectRepairableOwnerOnlyMismatch(result: { ok: boolean; code?: string }): void { + expect(result.ok).toBe(false); + // Windows can assign a newly created object either to the token user SID or + // to its enabled owner group. Both are fail-closed pre-repair states: the + // former exposes an inherited DACL mismatch, while the latter is the more + // specific owner mismatch that apply/repair subsequently corrects. + if (!result.code) throw new Error("Missing fail-closed owner-only mismatch code"); + expect(["acl_verify_failed", "owner_mismatch"]).toContain(result.code); +} + +async function runIcacls(...args: string[]): Promise { + const process = Bun.spawn(["icacls", ...args], { stdout: "pipe", stderr: "pipe" }); + const [exitCode, stderr] = await Promise.all([process.exited, new Response(process.stderr).text()]); + if (exitCode !== 0) throw new Error(`icacls failed (${exitCode}): ${stderr}`); +} function treeQuarantineName(entry: { relativePath: string; dev: string; ino: string }): string { const material = Buffer.concat([ Buffer.from(entry.relativePath), @@ -88,6 +109,287 @@ describe.skipIf(process.platform !== "win32")("Windows native path identity", () expect(await fs.readFile(path.join(relocated, "state.jsonl"), "utf8")).toBe("authorized"); expect(verifyOwnerOnlyPathSecurity(file, "file")).toEqual({ ok: false, code: "reparse_point" }); }); + it("binds exact replacement to both staged source and destination identities", async () => { + const root = await temporaryDirectory(); + const source = path.join(root, "staged.json"); + const destination = path.join(root, "state.json"); + await fs.writeFile(source, "new-state"); + await fs.writeFile(destination, "old-state"); + const sourceStat = await fs.stat(source, { bigint: true }); + const destinationStat = await fs.stat(destination, { bigint: true }); + const parent = await parentIdentity(source); + const sourceIdentity = { + ...parent, + dev: sourceStat.dev, + ino: sourceStat.ino, + size: sourceStat.size, + mtimeNs: sourceStat.mtimeNs, + sha256: sha256("new-state"), + }; + const destinationIdentity = { + ...parent, + dev: destinationStat.dev, + ino: destinationStat.ino, + size: destinationStat.size, + mtimeNs: destinationStat.mtimeNs, + sha256: sha256("old-state"), + }; + expect(exactReplacePath(source, destination, sourceIdentity, destinationIdentity)).toEqual({ ok: true }); + expect(await fs.readFile(destination, "utf8")).toBe("new-state"); + await expect(fs.access(source)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects a substituted staged source before deleting the exact destination", async () => { + const root = await temporaryDirectory(); + const source = path.join(root, "staged.json"); + const retainedSource = path.join(root, "retained-staged.json"); + const destination = path.join(root, "state.json"); + await fs.writeFile(source, "authorized-stage"); + await fs.writeFile(destination, "old-state"); + const sourceStat = await fs.stat(source, { bigint: true }); + const destinationStat = await fs.stat(destination, { bigint: true }); + const parent = await parentIdentity(source); + const sourceIdentity = { + ...parent, + dev: sourceStat.dev, + ino: sourceStat.ino, + size: sourceStat.size, + mtimeNs: sourceStat.mtimeNs, + sha256: sha256("authorized-stage"), + }; + const destinationIdentity = { + ...parent, + dev: destinationStat.dev, + ino: destinationStat.ino, + size: destinationStat.size, + mtimeNs: destinationStat.mtimeNs, + sha256: sha256("old-state"), + }; + await fs.rename(source, retainedSource); + await fs.writeFile(source, "substituted-stage"); + + expect(exactReplacePath(source, destination, sourceIdentity, destinationIdentity)).toEqual({ + ok: false, + code: "identity_mismatch", + }); + expect(await fs.readFile(destination, "utf8")).toBe("old-state"); + expect(await fs.readFile(source, "utf8")).toBe("substituted-stage"); + expect(await fs.readFile(retainedSource, "utf8")).toBe("authorized-stage"); + }); + + it("rejects exact unlink while a foreign hard link preserves transcript bytes", async () => { + const root = await temporaryDirectory(); + const target = path.join(root, "session.jsonl"); + const alias = path.join(root, "foreign-session.jsonl"); + const contents = "transcript payload"; + await fs.writeFile(target, contents); + await fs.link(target, alias); + const stat = await fs.stat(target, { bigint: true }); + + expect( + exactUnlink(target, { + dev: stat.dev, + ino: stat.ino, + size: stat.size, + mtimeNs: stat.mtimeNs, + sha256: sha256(contents), + }), + ).toEqual({ ok: false, code: "hard_link_unsupported" }); + expect(await fs.readFile(target, "utf8")).toBe(contents); + expect(await fs.readFile(alias, "utf8")).toBe(contents); + }); + + it("rejects exact replacement when the staged source has a foreign hard link", async () => { + const root = await temporaryDirectory(); + const source = path.join(root, "source.json"); + const sourceAlias = path.join(root, "source-alias.json"); + const destination = path.join(root, "destination.json"); + await fs.writeFile(source, "new-state"); + await fs.link(source, sourceAlias); + await fs.writeFile(destination, "old-state"); + const [sourceStat, destinationStat] = await Promise.all([ + fs.stat(source, { bigint: true }), + fs.stat(destination, { bigint: true }), + ]); + const parent = await parentIdentity(source); + + expect( + exactReplacePath( + source, + destination, + { + ...parent, + dev: sourceStat.dev, + ino: sourceStat.ino, + size: sourceStat.size, + mtimeNs: sourceStat.mtimeNs, + sha256: sha256("new-state"), + }, + { + ...parent, + dev: destinationStat.dev, + ino: destinationStat.ino, + size: destinationStat.size, + mtimeNs: destinationStat.mtimeNs, + sha256: sha256("old-state"), + }, + ), + ).toEqual({ ok: false, code: "hard_link_unsupported" }); + expect(await fs.readFile(source, "utf8")).toBe("new-state"); + expect(await fs.readFile(sourceAlias, "utf8")).toBe("new-state"); + expect(await fs.readFile(destination, "utf8")).toBe("old-state"); + }); + + it("rejects exact replacement when the committed destination has a foreign hard link", async () => { + const root = await temporaryDirectory(); + const source = path.join(root, "source.json"); + const destination = path.join(root, "destination.json"); + const destinationAlias = path.join(root, "destination-alias.json"); + await fs.writeFile(source, "new-state"); + await fs.writeFile(destination, "old-state"); + await fs.link(destination, destinationAlias); + const [sourceStat, destinationStat] = await Promise.all([ + fs.stat(source, { bigint: true }), + fs.stat(destination, { bigint: true }), + ]); + const parent = await parentIdentity(source); + + expect( + exactReplacePath( + source, + destination, + { + ...parent, + dev: sourceStat.dev, + ino: sourceStat.ino, + size: sourceStat.size, + mtimeNs: sourceStat.mtimeNs, + sha256: sha256("new-state"), + }, + { + ...parent, + dev: destinationStat.dev, + ino: destinationStat.ino, + size: destinationStat.size, + mtimeNs: destinationStat.mtimeNs, + sha256: sha256("old-state"), + }, + ), + ).toEqual({ ok: false, code: "hard_link_unsupported" }); + expect(await fs.readFile(source, "utf8")).toBe("new-state"); + expect(await fs.readFile(destination, "utf8")).toBe("old-state"); + expect(await fs.readFile(destinationAlias, "utf8")).toBe("old-state"); + }); + + it("rejects exact restore when retained content has a foreign hard link", async () => { + const root = await temporaryDirectory(); + const detached = path.join(root, ".gjc-delete-session"); + const alias = path.join(root, "foreign-session.jsonl"); + const original = path.join(root, "session.jsonl"); + const contents = "retained transcript"; + await fs.writeFile(detached, contents); + await fs.link(detached, alias); + const stat = await fs.stat(detached, { bigint: true }); + const parent = await parentIdentity(detached); + + expect( + exactRestore(detached, original, { + ...parent, + dev: stat.dev, + ino: stat.ino, + size: stat.size, + mtimeNs: stat.mtimeNs, + sha256: sha256(contents), + }), + ).toEqual({ ok: false, code: "hard_link_unsupported" }); + expect(await fs.readFile(detached, "utf8")).toBe(contents); + expect(await fs.readFile(alias, "utf8")).toBe(contents); + await expect(fs.stat(original)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects exact tree removal when a child gains a foreign hard link", async () => { + const root = await temporaryDirectory(); + const tree = path.join(root, "tree"); + const child = path.join(tree, "artifact.bin"); + const alias = path.join(root, "foreign-artifact.bin"); + await fs.mkdir(tree); + await fs.writeFile(child, "artifact payload"); + const snapshot = snapshotDirectoryTree(tree); + if (!snapshot.ok || !snapshot.snapshot) throw new Error("Missing tree snapshot"); + await fs.link(child, alias); + + expect(exactRemoveDirectoryTree(tree, snapshot.snapshot)).toMatchObject({ + ok: false, + code: "hard_link_unsupported", + }); + expect(await fs.readFile(child, "utf8")).toBe("artifact payload"); + expect(await fs.readFile(alias, "utf8")).toBe("artifact payload"); + }); + + it("refuses exact unlink while another writer can mutate the file", async () => { + const root = await temporaryDirectory(); + const target = path.join(root, "contended.jsonl"); + await fs.writeFile(target, "contended transcript"); + const stat = await fs.stat(target, { bigint: true }); + const writer = await fs.open(target, "r+"); + try { + expect( + exactUnlink(target, { + dev: stat.dev, + ino: stat.ino, + size: stat.size, + mtimeNs: stat.mtimeNs, + sha256: sha256("contended transcript"), + }), + ).toMatchObject({ ok: false }); + expect(await fs.readFile(target, "utf8")).toBe("contended transcript"); + } finally { + await writer.close(); + } + }); + + it("refuses exact restore while another writer can mutate retained content", async () => { + const root = await temporaryDirectory(); + const detached = path.join(root, ".gjc-delete-contended"); + const original = path.join(root, "contended.jsonl"); + await fs.writeFile(detached, "retained content"); + const stat = await fs.stat(detached, { bigint: true }); + const parent = await parentIdentity(detached); + const writer = await fs.open(detached, "r+"); + try { + expect( + exactRestore(detached, original, { + ...parent, + dev: stat.dev, + ino: stat.ino, + size: stat.size, + mtimeNs: stat.mtimeNs, + sha256: sha256("retained content"), + }), + ).toMatchObject({ ok: false }); + expect(await fs.readFile(detached, "utf8")).toBe("retained content"); + await expect(fs.stat(original)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await writer.close(); + } + }); + + it("refuses exact tree removal while another writer can mutate a child", async () => { + const root = await temporaryDirectory(); + const tree = path.join(root, "contended-tree"); + const child = path.join(tree, "artifact.bin"); + await fs.mkdir(tree); + await fs.writeFile(child, "contended artifact"); + const snapshot = snapshotDirectoryTree(tree); + if (!snapshot.ok || !snapshot.snapshot) throw new Error("Missing tree snapshot"); + const writer = await fs.open(child, "r+"); + try { + expect(exactRemoveDirectoryTree(tree, snapshot.snapshot)).toMatchObject({ ok: false }); + expect(await fs.readFile(child, "utf8")).toBe("contended artifact"); + } finally { + await writer.close(); + } + }); it("rejects a replaced ancestor junction during exact restore and retains detached content", async () => { const root = await temporaryDirectory(); const managed = path.join(root, "managed"); @@ -97,7 +399,9 @@ describe.skipIf(process.platform !== "win32")("Windows native path identity", () await fs.mkdir(managed); await fs.writeFile(original, "authorized"); const stat = await fs.stat(original, { bigint: true }); + const parent = await parentIdentity(original); const identity = { + ...parent, dev: stat.dev, ino: stat.ino, size: stat.size, @@ -122,8 +426,8 @@ describe.skipIf(process.platform !== "win32")("Windows native path identity", () await fs.mkdir(directory); await fs.writeFile(file, contents); - expect(verifyOwnerOnlyPathSecurity(directory, "directory")).toEqual({ ok: false, code: "acl_verify_failed" }); - expect(verifyOwnerOnlyPathSecurity(file, "file")).toEqual({ ok: false, code: "acl_verify_failed" }); + expectRepairableOwnerOnlyMismatch(verifyOwnerOnlyPathSecurity(directory, "directory")); + expectRepairableOwnerOnlyMismatch(verifyOwnerOnlyPathSecurity(file, "file")); expect(applyOwnerOnlyPathSecurity(directory, "directory")).toEqual({ ok: true }); expect(applyOwnerOnlyPathSecurity(file, "file")).toEqual({ ok: true }); expect(verifyOwnerOnlyPathSecurity(directory, "directory")).toEqual({ ok: true }); @@ -140,8 +444,8 @@ describe.skipIf(process.platform !== "win32")("Windows native path identity", () const directoryStat = await fs.stat(directory, { bigint: true }); const fileStat = await fs.stat(file, { bigint: true }); - expect(verifyOwnerOnlyPathSecurity(directory, "directory")).toEqual({ ok: false, code: "acl_verify_failed" }); - expect(verifyOwnerOnlyPathSecurity(file, "file")).toEqual({ ok: false, code: "acl_verify_failed" }); + expectRepairableOwnerOnlyMismatch(verifyOwnerOnlyPathSecurity(directory, "directory")); + expectRepairableOwnerOnlyMismatch(verifyOwnerOnlyPathSecurity(file, "file")); expect(repairOwnerOnlyPathSecurityExpected(file, "directory", fileStat.dev, fileStat.ino)).toMatchObject({ ok: false, }); @@ -154,6 +458,26 @@ describe.skipIf(process.platform !== "win32")("Windows native path identity", () expect(verifyOwnerOnlyPathSecurity(file, "file")).toEqual({ ok: true }); expect(await fs.readFile(file, "utf8")).toBe(contents); }); + + it("repairs an owner-correct DACL without requiring WRITE_OWNER", async () => { + const root = await temporaryDirectory(); + const file = path.join(root, "dacl-only.json"); + const user = process.env.USERNAME; + if (!user) throw new Error("Missing Windows username"); + await fs.writeFile(file, "owner-correct"); + expect(applyOwnerOnlyPathSecurity(file, "file")).toEqual({ ok: true }); + await runIcacls(file, "/inheritance:r", "/grant:r", `${user}:(RC,WDAC,S,D,RD,WD,AD,REA,WEA,X,DC,RA,WA)`); + const identity = await fs.stat(file, { bigint: true }); + + try { + expect(verifyOwnerOnlyPathSecurity(file, "file")).toEqual({ ok: false, code: "acl_verify_failed" }); + expect(repairOwnerOnlyPathSecurityExpected(file, "file", identity.dev, identity.ino)).toEqual({ ok: true }); + expect(verifyOwnerOnlyPathSecurityExpected(file, "file", identity.dev, identity.ino)).toEqual({ ok: true }); + expect(await fs.readFile(file, "utf8")).toBe("owner-correct"); + } finally { + await runIcacls(file, "/reset"); + } + }); it("verifies only the captured identity without mutating a swapped replacement", async () => { const root = await temporaryDirectory(); const target = path.join(root, "target.json"); @@ -169,7 +493,7 @@ describe.skipIf(process.platform !== "win32")("Windows native path identity", () ok: false, code: "identity_mismatch", }); - expect(verifyOwnerOnlyPathSecurity(target, "file")).toEqual({ ok: false, code: "acl_verify_failed" }); + expectRepairableOwnerOnlyMismatch(verifyOwnerOnlyPathSecurity(target, "file")); expect(await fs.readFile(target, "utf8")).toBe("replacement"); expect(await fs.readFile(retained, "utf8")).toBe("authorized"); }); @@ -326,7 +650,9 @@ describe.skipIf(process.platform !== "win32")("Windows native path identity", () const detached = path.join(root, ".gjc-delete-state"); await fs.writeFile(original, "authorized"); const stat = await fs.stat(original, { bigint: true }); + const parent = await parentIdentity(original); const identity = { + ...parent, dev: stat.dev, ino: stat.ino, size: stat.size, @@ -347,7 +673,9 @@ describe.skipIf(process.platform !== "win32")("Windows native path identity", () const detached = path.join(root, ".gjc-delete-state"); await fs.writeFile(original, "authorized"); const stat = await fs.stat(original, { bigint: true }); + const parent = await parentIdentity(original); const identity = { + ...parent, dev: stat.dev, ino: stat.ino, size: stat.size, @@ -370,7 +698,9 @@ describe.skipIf(process.platform !== "win32")("Windows native path identity", () const detached = path.join(root, ".gjc-delete-state"); await fs.writeFile(original, "authorized"); const stat = await fs.stat(original, { bigint: true }); + const parent = await parentIdentity(original); const identity = { + ...parent, dev: stat.dev, ino: stat.ino, size: stat.size, diff --git a/packages/natives/test/recovery-fs.test.ts b/packages/natives/test/recovery-fs.test.ts index 0860cf901a..cb5794514b 100644 --- a/packages/natives/test/recovery-fs.test.ts +++ b/packages/natives/test/recovery-fs.test.ts @@ -69,7 +69,7 @@ describe.skipIf(process.platform !== "linux")("native recovery filesystem author expect(authority.read("link", 1024)).toMatchObject({ ok: false, code: "reparse_point" }); expect(authority.stat("receipt.fifo")).toMatchObject({ ok: false, code: "not_regular_file" }); expect(authority.stat("hard-link")).toMatchObject({ ok: false, code: "hard_link" }); - expect(authority.create("too-large", Buffer.alloc(1024 * 1024 + 1))).toMatchObject({ + expect(authority.create("too-large", Buffer.alloc(64 * 1024 * 1024 + 1))).toMatchObject({ ok: false, code: "content_too_large", }); diff --git a/scripts/ci-dev-affected.test.ts b/scripts/ci-dev-affected.test.ts index c0234b4381..36fd9d22cc 100644 --- a/scripts/ci-dev-affected.test.ts +++ b/scripts/ci-dev-affected.test.ts @@ -99,6 +99,9 @@ describe("dev-ci canonical-plan workflow contract", () => { expect(workflow).toContain("CI_DEV_TELEGRAM_GUARD_REQUIRED: ${{ needs.affected-plan.outputs.relevant }}"); expect(workflow).toContain("CI_DEV_TELEGRAM_WINDOWS_RESULT: ${{ needs.windows-telegram-daemon-safety.result }}"); expect(workflow).toContain("CI_DEV_TELEGRAM_WINDOWS_REQUIRED:"); + expect(workflow).toContain("bun test ./packages/natives/test/path-identity-windows.test.ts"); + expect(workflow).toContain("contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/test/path-identity-windows.test.ts')"); + expect(workflow).toContain("contains(needs.affected-plan.outputs.changed_paths, 'packages/natives/native/index.js')"); expect(workflow).not.toContain("pull_request_target"); expect(workflow).not.toContain("github.run_attempt"); expect(workflow).toContain("artifact_digest"); @@ -150,6 +153,7 @@ describe("dev-ci canonical-plan workflow contract", () => { // as a name filter rather than a path, matches nothing, and exits 1. Pinned here // and enforced workflow-wide by dev-ci-guard-topology.test.ts. expect(windowsJob).toContain("bun test ./packages/coding-agent/test/session-manager/windows-canonical-path.test.ts"); + expect(windowsJob).toContain("bun test ./packages/coding-agent/test/session/managed-lock-lease.windows.test.ts"); // The required predicate must textually match the job gate so the aggregate // invariant (windowsDoctor === required ? success : skipped) never fails closed. const requiredLines = workflow.split("\n").filter(line => line.includes("CI_DEV_WINDOWS_DOCTOR_REQUIRED:")); @@ -1074,6 +1078,7 @@ test("tab-worker graph changes always include install-methods and are Darwin rel "packages/coding-agent/src/sdk/session-directory.ts", "packages/coding-agent/src/session/session-manager.ts", "packages/coding-agent/test/session-manager/windows-canonical-path.test.ts", + "packages/coding-agent/test/session/managed-lock-lease.windows.test.ts", "packages/coding-agent/test/sdk-session-directory.windows.test.ts", ]) { expect(isWindowsSessionPathRegressionPath(changedPath)).toBe(true); diff --git a/scripts/ci-dev-affected.ts b/scripts/ci-dev-affected.ts index 91c45584f2..e13e335169 100755 --- a/scripts/ci-dev-affected.ts +++ b/scripts/ci-dev-affected.ts @@ -422,6 +422,7 @@ export function isWindowsSessionPathRegressionPath(changedPath: string): boolean changedPath === "packages/coding-agent/src/sdk/session-directory.ts" || changedPath === "packages/coding-agent/src/session/session-manager.ts" || changedPath === "packages/coding-agent/test/session-manager/windows-canonical-path.test.ts" || + changedPath === "packages/coding-agent/test/session/managed-lock-lease.windows.test.ts" || changedPath === "packages/coding-agent/test/sdk-session-directory.windows.test.ts"; } diff --git a/scripts/telegram-daemon-generation-manifest.json b/scripts/telegram-daemon-generation-manifest.json index 2769f204d7..445127a5fc 100644 --- a/scripts/telegram-daemon-generation-manifest.json +++ b/scripts/telegram-daemon-generation-manifest.json @@ -300,7 +300,7 @@ "discord:packages/coding-agent/src/sdk/bus/chat-daemon-cli.ts:loadConfig": "8fd40d586b1f3f60cfd2285844e08cc3f5235bf83672d268bd698512ec31edd6", "discord:packages/coding-agent/src/sdk/bus/chat-daemon-cli.ts:ownerPid": "31110dcdd6e0f5dbc8b4dce27383646b9c339739758623ab67dc40ddc36661e0", "discord:packages/coding-agent/src/sdk/bus/chat-daemon-cli.ts:runChatDaemonInternal": "74c0a7c475325313453a4295479fd4cb2fcb3af6137426b8262b768d5bc276d0", - "discord:packages/coding-agent/src/sdk/bus/chat-daemon-control.ts:CHAT_DAEMON_GENERATIONS.discord": "225ac1d15ea68ee7a4125fee1ce6db61386070b2477bbe76af7fc8664ab3133c", + "discord:packages/coding-agent/src/sdk/bus/chat-daemon-control.ts:CHAT_DAEMON_GENERATIONS.discord": "f9b81cf39d5776cd78d6332c35208c68211bb423297486d042e2bde5a7efb7e1", "discord:packages/coding-agent/src/sdk/bus/chat-daemon-control.ts:ChatDaemonAction": "d8acaf90439e410595b5cd56fd187001393ea943517a5563a4a2ea3c88d155ef", "discord:packages/coding-agent/src/sdk/bus/chat-daemon-control.ts:ChatDaemonController": "476aad2ea908df7fb0cb72be8cea82007b245cd8228d2c7846fd31b3b6d1fb99", "discord:packages/coding-agent/src/sdk/bus/chat-daemon-control.ts:ChatDaemonKind": "b1c2906c4eb04e120c9ce42f8b549a68cfc834d29d4d745335712a7f61bf09f2", @@ -378,7 +378,7 @@ "slack:packages/coding-agent/src/sdk/bus/chat-daemon-cli.ts:loadConfig": "8fd40d586b1f3f60cfd2285844e08cc3f5235bf83672d268bd698512ec31edd6", "slack:packages/coding-agent/src/sdk/bus/chat-daemon-cli.ts:ownerPid": "31110dcdd6e0f5dbc8b4dce27383646b9c339739758623ab67dc40ddc36661e0", "slack:packages/coding-agent/src/sdk/bus/chat-daemon-cli.ts:runChatDaemonInternal": "74c0a7c475325313453a4295479fd4cb2fcb3af6137426b8262b768d5bc276d0", - "slack:packages/coding-agent/src/sdk/bus/chat-daemon-control.ts:CHAT_DAEMON_GENERATIONS.slack": "80912af76185d8495659b62c220571280ce3db5af43b518ff63c53b403e583c4", + "slack:packages/coding-agent/src/sdk/bus/chat-daemon-control.ts:CHAT_DAEMON_GENERATIONS.slack": "c748fc9a0dd1d8ecc690f7f286a7c347cc6d4fc6a1135aa03fa1c4bc6ef6432f", "slack:packages/coding-agent/src/sdk/bus/chat-daemon-control.ts:ChatDaemonAction": "d8acaf90439e410595b5cd56fd187001393ea943517a5563a4a2ea3c88d155ef", "slack:packages/coding-agent/src/sdk/bus/chat-daemon-control.ts:ChatDaemonController": "476aad2ea908df7fb0cb72be8cea82007b245cd8228d2c7846fd31b3b6d1fb99", "slack:packages/coding-agent/src/sdk/bus/chat-daemon-control.ts:ChatDaemonKind": "b1c2906c4eb04e120c9ce42f8b549a68cfc834d29d4d745335712a7f61bf09f2", @@ -474,7 +474,7 @@ "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:ownerPidFromOwnerId": "46691373b2bee01f28f3817a6aa6a7efffe880c2cea337c89155582c98d952bf", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:runDaemonInternal": "7689b6c98f5d0a658b3aa921e384c549355aba7eb672c2ebf4014171da01ba5c", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:runDaemonSmoke": "6f085a667aa5c83de46d2d8945fb845c355fcbb43c46872342a44489203a5830", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts:DAEMON_GENERATION": "adc41640c635b42476409aa17f70439e7cad825ed050007d67c92ed3117fa792", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts:DAEMON_GENERATION": "c005dee3a413f188deda5aef84c4364bd76d76b49e9493e7468eb52fbf7ef092", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts:NOTIFICATION_PROTOCOL_VERSION": "b99289f651fedcf020d28dbaf6f07dd37e7e4a5f6dc1f5118b872112325f1e81", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-control.ts:DaemonProcessReference": "c3d13e3670a6245a1250c4ebfcd80a36dd8fc96c67ab64d9f979182bd117bc4e", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-control.ts:TelegramDaemonController": "d92bf5e0aea850d62c415092a8d9024d3d3571e0f2b8855d7e3fb418ec59a59c", @@ -553,7 +553,7 @@ "telegram:packages/coding-agent/src/sdk/host/host.ts:TOOL_ACTIVITY_CAPABILITY": "547f80bd6b3bd1c615fc3d885e507aba4418e6c4c54c47d36938b8b2e5d2abd7" }, "nativeAuthoritySha256": { - "crates/pi-natives/src/path_identity.rs": "90a5ac813f53dee0f9a81002f5971594c07f8a9bdf8d5f262737be1d8203aae1", + "crates/pi-natives/src/path_identity.rs": "9ac16a3c5be7e2a793c9d759887a16041f411a9a34e00c0978960ca1442f4865", "crates/pi-natives/src/ps.rs": "7696078e123b9beecc7252371c71eab2cec590413be6e6ddf4692ff6fe8c41e1", "crates/pi-shell/src/process.rs": "45b222432a70eb056807507f8b4d28cf5fb73a7441ee9a0e28349ec85ef9b177", "crates/pi-shell/src/shell.rs": "66cacfe63d495b23156d023f2f34f354b657534ee3cdaf01e30d6694af253982",