diff --git a/crates/ffi/src/adapter.rs b/crates/ffi/src/adapter.rs index 92b3559..db79a40 100644 --- a/crates/ffi/src/adapter.rs +++ b/crates/ffi/src/adapter.rs @@ -115,6 +115,9 @@ fn build_adapter() -> Result, AdapterError> { #[cfg(target_os = "windows")] { agent_desktop_windows::ensure_hosted_library_mta_and_dpi()?; + let _ = agent_desktop_core::install_private_file_ops(Box::new( + agent_desktop_windows::WindowsPrivateFile, + )); Ok(Box::new(agent_desktop_windows::WindowsAdapter::new())) } @@ -334,9 +337,7 @@ mod tests { struct UnknownPermissionAdapter; impl ObservationOps for UnknownPermissionAdapter {} - impl ActionOps for UnknownPermissionAdapter {} - impl InputOps for UnknownPermissionAdapter {} impl SystemOps for UnknownPermissionAdapter { diff --git a/crates/ffi/tests/c_abi_session_liveness.rs b/crates/ffi/tests/c_abi_session_liveness.rs new file mode 100644 index 0000000..d5d03bd --- /dev/null +++ b/crates/ffi/tests/c_abi_session_liveness.rs @@ -0,0 +1,48 @@ +mod common; + +use agent_desktop_core::session::{ + GcOptions, SessionTraceMode, StartSessionOptions, gc, start_session, write_manifest, +}; +use common::{ad_adapter_create_with_session, ad_adapter_destroy, with_isolated_home}; +use std::ffi::CString; +use std::time::Duration; + +/// Runs alone in its own process because private-file install state is +/// process-global: on Windows the first adapter construction installs +/// `WindowsPrivateFile`, whose atomic writes hold a process-lifetime temp +/// lease inside the written file's parent. If another adapter-creating test +/// ran first in this process, the session writes below would plant that +/// lease inside the session directory and the same-process gc removal would +/// fail by design. In the product, session writes and `session gc` never +/// share a process, so the isolation here models the real topology. +#[test] +fn session_scoped_adapter_holds_liveness_until_destroyed() { + with_isolated_home(|| { + let mut manifest = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::Off, + ..Default::default() + }) + .unwrap(); + manifest.created_at = 0; + write_manifest(&manifest).unwrap(); + let session = CString::new(manifest.id.as_str()).unwrap(); + let adapter = unsafe { ad_adapter_create_with_session(session.as_ptr()) }; + assert!(!adapter.is_null()); + + let retained = gc(GcOptions { + ended_only: false, + older_than: Some(Duration::ZERO), + }) + .unwrap(); + assert!(!retained.removed.contains(&manifest.id)); + + unsafe { ad_adapter_destroy(adapter) }; + let removed = gc(GcOptions { + ended_only: false, + older_than: Some(Duration::ZERO), + }) + .unwrap(); + assert!(removed.removed.contains(&manifest.id)); + }); +} diff --git a/crates/ffi/tests/c_abi_session_trace.rs b/crates/ffi/tests/c_abi_session_trace.rs index 76fb8a5..29539df 100644 --- a/crates/ffi/tests/c_abi_session_trace.rs +++ b/crates/ffi/tests/c_abi_session_trace.rs @@ -1,7 +1,7 @@ mod common; use agent_desktop_core::session::{ - GcOptions, SessionTraceMode, StartSessionOptions, gc, start_session, trace_dir, write_manifest, + SessionTraceMode, StartSessionOptions, start_session, trace_dir, }; use common::{ AdResult, ad_adapter_create_with_session, ad_adapter_destroy, ad_check_permissions, @@ -10,7 +10,6 @@ use common::{ use std::ffi::CString; use std::fs; use std::sync::Mutex; -use std::time::Duration; static HOME_LOCK: Mutex<()> = Mutex::new(()); @@ -129,34 +128,3 @@ fn manifestless_session_does_not_create_trace_files() { } assert!(!trace_dir(session_id).unwrap().exists()); } - -#[test] -fn session_scoped_adapter_holds_liveness_until_destroyed() { - let _home = TestHome::new(); - let mut manifest = start_session(StartSessionOptions { - name: None, - trace: SessionTraceMode::Off, - ..Default::default() - }) - .unwrap(); - manifest.created_at = 0; - write_manifest(&manifest).unwrap(); - let session = CString::new(manifest.id.as_str()).unwrap(); - let adapter = unsafe { ad_adapter_create_with_session(session.as_ptr()) }; - assert!(!adapter.is_null()); - - let retained = gc(GcOptions { - ended_only: false, - older_than: Some(Duration::ZERO), - }) - .unwrap(); - assert!(!retained.removed.contains(&manifest.id)); - - unsafe { ad_adapter_destroy(adapter) }; - let removed = gc(GcOptions { - ended_only: false, - older_than: Some(Duration::ZERO), - }) - .unwrap(); - assert!(removed.removed.contains(&manifest.id)); -} diff --git a/crates/ffi/tests/c_abi_windows_private_file_install.rs b/crates/ffi/tests/c_abi_windows_private_file_install.rs new file mode 100644 index 0000000..4eff168 --- /dev/null +++ b/crates/ffi/tests/c_abi_windows_private_file_install.rs @@ -0,0 +1,95 @@ +#![cfg(target_os = "windows")] + +mod common; + +use agent_desktop_core::session::{StartSessionOptions, start_session}; +use agent_desktop_core::{PrivateFileOps, install_private_file_ops}; +use common::{ad_adapter_create, ad_adapter_destroy, with_isolated_home}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +struct ProbeOps; + +impl PrivateFileOps for ProbeOps {} + +fn plant_junction(link: &Path, target: &Path) { + let output = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .output() + .expect("cmd /c mklink starts"); + assert!( + output.status.success(), + "mklink /J must succeed without privilege: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn regular_files_under(root: &Path) -> Vec { + let mut files = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + let Ok(entries) = std::fs::read_dir(&directory) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + pending.push(entry.path()); + } else { + files.push(entry.path()); + } + } + } + files +} + +/// Core's five private-file primitives are `pub(crate)`, so the behavioral arm +/// reaches them through the public `session::start_session` surface instead: +/// the first-install rejection proves adapter construction already installed +/// an implementation (the windows arm installs only `WindowsPrivateFile`), and +/// the junction refusal ties that install to hardened behavior the portable +/// default measurably lacks. The spawned-binary junction proof in the CLI +/// crate covers the other consumer. +#[test] +fn adapter_create_without_ad_init_installs_the_windows_private_file_ops() { + unsafe { + let adapter = ad_adapter_create(); + assert!( + !adapter.is_null(), + "ad_adapter_create must succeed without any prior ad_init call" + ); + ad_adapter_destroy(adapter); + } + + let Err(rejected) = install_private_file_ops(Box::new(ProbeOps)) else { + panic!( + "a fresh install must be rejected because adapter construction \ + already installed the Windows private-file implementation" + ); + }; + drop(rejected); + + with_isolated_home(|| { + let home = PathBuf::from(std::env::var_os("HOME").expect("isolated HOME is set")); + let target = home.join("junction-target"); + std::fs::create_dir_all(&target).expect("create junction target"); + plant_junction(&home.join(".agent-desktop"), &target); + + let started = start_session(StartSessionOptions::default()); + + assert!( + started.is_err(), + "a manifest write through a junction component must be refused \ + by the installed WindowsPrivateFile" + ); + let leaked = regular_files_under(&target); + assert!( + leaked.is_empty(), + "no session artifact may land under the junction target: {leaked:?}" + ); + }); +} diff --git a/src/Cargo.toml b/src/Cargo.toml index 6456a52..e5618cd 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -39,5 +39,9 @@ path = "tests/conformance.rs" name = "cli_process" path = "tests/cli_process.rs" +[[test]] +name = "windows_private_file_install" +path = "tests/windows_private_file_install.rs" + [lints] workspace = true diff --git a/src/main.rs b/src/main.rs index e81e3fa..7e9d7ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,6 +58,11 @@ fn run() -> ExitCode { return finish("unknown", Err(pre_dispatch_error(bootstrap_error.into()))); } + #[cfg(target_os = "windows")] + let _ = agent_desktop_core::install_private_file_ops(Box::new( + agent_desktop_windows::WindowsPrivateFile, + )); + let mut cli = match Cli::try_parse() { Ok(c) => c, Err(e) => { diff --git a/src/tests/windows_private_file_install.rs b/src/tests/windows_private_file_install.rs new file mode 100644 index 0000000..766b983 --- /dev/null +++ b/src/tests/windows_private_file_install.rs @@ -0,0 +1,144 @@ +#![cfg(target_os = "windows")] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + +static SCRATCH_ID: AtomicU64 = AtomicU64::new(1); + +struct Scratch { + root: PathBuf, +} + +impl Scratch { + fn create(label: &str) -> Self { + let id = SCRATCH_ID.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "agent-desktop-install-{label}-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("create scratch root"); + Self { root } + } + + fn dir(&self, name: &str) -> PathBuf { + let path = self.root.join(name); + std::fs::create_dir_all(&path).expect("create scratch subdirectory"); + path + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn plant_junction(link: &Path, target: &Path) { + let output = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .output() + .expect("cmd /c mklink starts"); + assert!( + output.status.success(), + "mklink /J must succeed without privilege: {}", + String::from_utf8_lossy(&output.stderr) + ); + let attributes = { + use std::os::windows::fs::MetadataExt; + std::fs::symlink_metadata(link) + .expect("junction link exists") + .file_attributes() + }; + assert!( + attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0, + "planted link must carry FILE_ATTRIBUTE_REPARSE_POINT" + ); +} + +fn run_session_start(home: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_agent-desktop")) + .args(["session", "start"]) + .env("HOME", home) + .env("USERPROFILE", home) + .env_remove("AGENT_DESKTOP_SESSION") + .output() + .expect("binary starts") +} + +fn parse_envelope(output: &Output) -> serde_json::Value { + serde_json::from_slice(&output.stdout).expect("stdout is one JSON envelope") +} + +fn regular_files_under(root: &Path) -> Vec { + let mut files = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + let Ok(entries) = std::fs::read_dir(&directory) else { + continue; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + pending.push(entry.path()); + } else { + files.push(entry.path()); + } + } + } + files +} + +#[test] +fn session_start_through_a_junction_home_is_refused_by_the_installed_windows_ops() { + let scratch = Scratch::create("junction"); + let home = scratch.dir("junction-home"); + let target = scratch.dir("junction-target"); + plant_junction(&home.join(".agent-desktop"), &target); + + let output = run_session_start(&home); + let envelope = parse_envelope(&output); + + assert_eq!( + output.status.code(), + Some(1), + "session start must fail structurally when ~/.agent-desktop is a junction; \ + success means the portable default wrote through the junction" + ); + assert_eq!(envelope["ok"], false); + let leaked = regular_files_under(&target); + assert!( + leaked.is_empty(), + "no session artifact may land under the junction target: {leaked:?}" + ); +} + +#[test] +fn session_start_in_a_real_home_succeeds_as_the_junction_control() { + let scratch = Scratch::create("control"); + let home = scratch.dir("real-home"); + + let output = run_session_start(&home); + let envelope = parse_envelope(&output); + + assert_eq!(output.status.code(), Some(0)); + assert_eq!(envelope["ok"], true); + let session_id = envelope["data"]["session_id"] + .as_str() + .expect("session start reports its session id"); + let manifest = home + .join(".agent-desktop") + .join("sessions") + .join(session_id) + .join("session.json"); + assert!( + manifest.is_file(), + "the control session manifest must exist under the real home" + ); +}