diff --git a/crates/daemon/src/error.rs b/crates/daemon/src/error.rs index b8c7b38c..01d25f1a 100644 --- a/crates/daemon/src/error.rs +++ b/crates/daemon/src/error.rs @@ -90,6 +90,9 @@ pub enum DaemonError { #[error("process `{name}` started without an observable pid")] MissingProcessId { name: String }, + #[error("process `{name}` started without observable identity for pid {pid}")] + MissingProcessIdentity { name: String, pid: u32 }, + #[error("readiness check `{check}` timed out after {timeout_ms}ms; last error: {last_error:?}")] ReadinessTimedOut { check: String, diff --git a/crates/daemon/src/managed_resources/mysql_tests.rs b/crates/daemon/src/managed_resources/mysql_tests.rs index b3078185..cf202587 100644 --- a/crates/daemon/src/managed_resources/mysql_tests.rs +++ b/crates/daemon/src/managed_resources/mysql_tests.rs @@ -6,6 +6,7 @@ use camino::Utf8Path; use camino_tempfile::tempdir; use insta::{Settings, assert_debug_snapshot}; use resources::RuntimeArtifactAdapter; +use serde_json::{Value, json}; use state::{Database, LinkProjectInput, ProjectRecord, PvPaths}; use super::ManagedResourceRuntimeAdapter; @@ -293,10 +294,17 @@ fn read_dotenv(project: &ProjectRecord) -> Result { state::fs::read_to_string(&project.path.join(".env")).map_err(Into::into) } -fn read_runtime_metadata(paths: &PvPaths, track: &str) -> Result { +fn read_runtime_metadata(paths: &PvPaths, track: &str) -> Result { let content = state::fs::read_to_string(&paths.resource_runtime_metadata("mysql", track))?; + let mut metadata: Value = serde_json::from_str(&content)?; + if let Some(process_start_identity) = metadata.get_mut("process_start_identity") { + *process_start_identity = json!(""); + } + if let Some(process_executable_identity) = metadata.get_mut("process_executable_identity") { + *process_executable_identity = json!(""); + } - serde_json::from_str(&content).map_err(Into::into) + Ok(metadata) } fn mysql_system_database_initialized(paths: &PvPaths, track: &str) -> Result { diff --git a/crates/daemon/src/managed_resources/snapshots/daemon__managed_resources__mysql_tests__mysql_project_demand_installs_missing_fixture_track_before_start.snap b/crates/daemon/src/managed_resources/snapshots/daemon__managed_resources__mysql_tests__mysql_project_demand_installs_missing_fixture_track_before_start.snap index 609469f0..46506353 100644 --- a/crates/daemon/src/managed_resources/snapshots/daemon__managed_resources__mysql_tests__mysql_project_demand_installs_missing_fixture_track_before_start.snap +++ b/crates/daemon/src/managed_resources/snapshots/daemon__managed_resources__mysql_tests__mysql_project_demand_installs_missing_fixture_track_before_start.snap @@ -97,6 +97,8 @@ expression: snapshot "log_path": String("/home/.pv/logs/resources/mysql/8.0.log"), "name": String("mysql-8.0"), "pid": Number(), + "process_executable_identity": String(""), + "process_start_identity": String(""), "resource_name": String("mysql"), "started_at": String(""), "track": String("8.0"), diff --git a/crates/daemon/src/managed_resources/snapshots/daemon__managed_resources__mysql_tests__mysql_reconciliation_creates_database_allocation_and_renders_env.snap b/crates/daemon/src/managed_resources/snapshots/daemon__managed_resources__mysql_tests__mysql_reconciliation_creates_database_allocation_and_renders_env.snap index 609469f0..46506353 100644 --- a/crates/daemon/src/managed_resources/snapshots/daemon__managed_resources__mysql_tests__mysql_reconciliation_creates_database_allocation_and_renders_env.snap +++ b/crates/daemon/src/managed_resources/snapshots/daemon__managed_resources__mysql_tests__mysql_reconciliation_creates_database_allocation_and_renders_env.snap @@ -97,6 +97,8 @@ expression: snapshot "log_path": String("/home/.pv/logs/resources/mysql/8.0.log"), "name": String("mysql-8.0"), "pid": Number(), + "process_executable_identity": String(""), + "process_start_identity": String(""), "resource_name": String("mysql"), "started_at": String(""), "track": String("8.0"), diff --git a/crates/daemon/src/managed_resources/tests.rs b/crates/daemon/src/managed_resources/tests.rs index 8c08597c..a4736421 100644 --- a/crates/daemon/src/managed_resources/tests.rs +++ b/crates/daemon/src/managed_resources/tests.rs @@ -4761,7 +4761,7 @@ fn assert_with_normalized_runtime( settings.add_filter(r"timeout_ms: \d+", "timeout_ms: "); settings.add_filter(r"os error \d+", "os error "); settings.add_filter( - r"I/O error: Connection refused \(os error \)|I/O error: HTTP readiness returned non-success status|deadline has elapsed", + r"I/O error: (Connection refused|Connection reset by peer) \(os error \)|I/O error: HTTP readiness returned non-success status|deadline has elapsed", "I/O error: readiness unavailable", ); settings.add_filter(r"port: \d+", "port: "); diff --git a/crates/daemon/src/supervisor.rs b/crates/daemon/src/supervisor.rs index 1ae55ce2..04a6fc80 100644 --- a/crates/daemon/src/supervisor.rs +++ b/crates/daemon/src/supervisor.rs @@ -7,9 +7,7 @@ use std::{fmt, future::Future, io}; use camino::{Utf8Path, Utf8PathBuf}; use platform::PlatformCapability; #[cfg(target_os = "macos")] -use rustix::process::{ - Pid, Signal, kill_process_group, test_kill_process, test_kill_process_group, -}; +use rustix::process::{Pid, Signal, kill_process_group, test_kill_process_group}; use rustls::pki_types::ServerName; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -24,6 +22,9 @@ use crate::DaemonError; const READINESS_POLL_INTERVAL: Duration = Duration::from_millis(25); const READINESS_PROBE_TIMEOUT: Duration = Duration::from_secs(1); +const SCRIPT_IDENTITY_POLL_INTERVAL: Duration = Duration::from_millis(10); +const SCRIPT_IDENTITY_STABILIZATION: Duration = Duration::from_millis(250); +const SCRIPT_IDENTITY_TIMEOUT: Duration = Duration::from_secs(5); const PRIVATE_ENVIRONMENT_REDACTION: &str = ""; const PRIVATE_ENVIRONMENT_FINGERPRINT_PREFIX: &str = "sha256:v1:"; const PHP_INI_ENVIRONMENT_KEYS: [&str; 2] = ["PHPRC", "PHP_INI_SCAN_DIR"]; @@ -35,12 +36,6 @@ enum ProcessSignal { Kill, } -#[expect( - clippy::disallowed_types, - reason = "PV process supervisor verifies live process ownership" -)] -type StdCommand = std::process::Command; - #[derive(Clone, Eq, PartialEq)] pub struct ProcessSpec { pub name: String, @@ -108,6 +103,10 @@ pub struct ManagedProcess { #[derive(Clone, Debug, Eq, PartialEq)] pub struct OwnedRuntime { pid: u32, + command: Utf8PathBuf, + arguments: Vec, + process_start_identity: platform::ProcessStartIdentity, + process_executable_identity: Option, log_path: Utf8PathBuf, pid_path: Utf8PathBuf, metadata_path: Utf8PathBuf, @@ -159,6 +158,16 @@ struct RuntimeMetadata { track: String, log_path: String, started_at: String, + #[serde(default)] + process_start_identity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + process_executable_identity: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct ProcessExecutableIdentity { + executable: String, + argument_zero: String, } impl ProcessSupervisor { @@ -194,7 +203,7 @@ impl ProcessSupervisor { }; post_spawn(pid).await; - if let Err(error) = persist_runtime_files(&spec, pid) { + if let Err(error) = persist_runtime_files(&spec, pid).await { terminate_spawned_child(pid, &mut child).await; return Err(error); @@ -221,9 +230,25 @@ impl ProcessSupervisor { return Ok(None); }; - if metadata.matches(spec, pid) && live_process_matches_spec(pid, spec)? { + let Some(process_start_identity) = metadata.process_start_identity else { + return Ok(None); + }; + + if metadata.matches(spec, pid) + && live_process_matches( + pid, + &spec.command, + &spec.arguments, + process_start_identity, + metadata.process_executable_identity.as_ref(), + )? + { return Ok(Some(OwnedRuntime { pid, + command: spec.command.clone(), + arguments: spec.arguments.clone(), + process_start_identity, + process_executable_identity: metadata.process_executable_identity, log_path: spec.log_path.clone(), pid_path: spec.pid_path.clone(), metadata_path: spec.metadata_path.clone(), @@ -254,10 +279,26 @@ impl ProcessSupervisor { }; let spec = metadata.process_spec(pid_path.to_path_buf(), metadata_path.to_path_buf()); - if metadata.matches_recorded(&spec, pid) && live_process_matches_spec(pid, &spec)? { + let Some(process_start_identity) = metadata.process_start_identity else { + return Ok(None); + }; + + if metadata.matches_recorded(&spec, pid) + && live_process_matches( + pid, + &spec.command, + &spec.arguments, + process_start_identity, + metadata.process_executable_identity.as_ref(), + )? + { return Ok(Some(AdoptedProcess { owned: OwnedRuntime { pid, + command: spec.command, + arguments: spec.arguments, + process_start_identity, + process_executable_identity: metadata.process_executable_identity, log_path: spec.log_path, pid_path: spec.pid_path, metadata_path: spec.metadata_path, @@ -273,9 +314,8 @@ impl ProcessSupervisor { let Some(owned) = self.verify_ownership(spec)? else { return Ok(false); }; - signal_process_group(owned.pid, ProcessSignal::Reload)?; - Ok(true) + owned.signal(ProcessSignal::Reload) } } @@ -340,6 +380,26 @@ impl OwnedRuntime { pub fn pid(&self) -> u32 { self.pid } + + fn matches_live(&self) -> Result { + live_process_matches( + self.pid, + &self.command, + &self.arguments, + self.process_start_identity, + self.process_executable_identity.as_ref(), + ) + } + + fn signal(&self, signal: ProcessSignal) -> Result { + if !self.matches_live()? { + return Ok(false); + } + + signal_process_group(self.pid, signal)?; + + Ok(true) + } } impl AdoptedProcess { @@ -349,6 +409,10 @@ impl AdoptedProcess { pub async fn stop(self, grace_period: Duration) -> Result<(), DaemonError> { require_process_containment()?; + if !self.owned.matches_live()? { + return Ok(()); + } + stop_process_group_by_pid(self.owned.pid, grace_period).await } } @@ -670,12 +734,91 @@ fn process_command(spec: &ProcessSpec) -> tokio::process::Command { command } -fn persist_runtime_files(spec: &ProcessSpec, pid: u32) -> Result<(), DaemonError> { +async fn persist_runtime_files(spec: &ProcessSpec, pid: u32) -> Result<(), DaemonError> { + let (process_identity, process_executable_identity) = + process_identity_for_runtime_metadata(spec, pid).await?; + fs::write_sensitive_file(&spec.pid_path, &format!("{pid}\n"))?; - write_runtime_metadata(spec, pid) + write_runtime_metadata( + spec, + pid, + process_identity.start_identity, + process_executable_identity, + ) +} + +async fn process_identity_for_runtime_metadata( + spec: &ProcessSpec, + pid: u32, +) -> Result<(platform::ProcessIdentity, Option), DaemonError> { + let Some(mut process_identity) = platform::inspect_process_identity(pid)? else { + return Err(DaemonError::MissingProcessIdentity { + name: spec.name.clone(), + pid, + }); + }; + if executable_matches(&process_identity, &spec.command) { + return Ok((process_identity, None)); + } + let script_candidate = process_identity + .arguments + .iter() + .take(2) + .any(|argument| argument == spec.command.as_str()); + if !script_candidate { + return Ok((process_identity, None)); + } + let source = fs::read_to_string(&spec.command)?; + if !source.starts_with("#!") { + return Ok((process_identity, None)); + } + + let started_at = Instant::now(); + let mut stable_identity: Option<(platform::ProcessIdentity, Instant)> = None; + loop { + if script_arguments_match(&process_identity, &spec.command, &spec.arguments) { + match &stable_identity { + Some((identity, observed_at)) + if identity == &process_identity + && observed_at.elapsed() >= SCRIPT_IDENTITY_STABILIZATION => + { + let executable_identity = ProcessExecutableIdentity { + executable: process_identity.executable.to_string(), + argument_zero: process_identity.argument_zero.clone(), + }; + + return Ok((process_identity, Some(executable_identity))); + } + Some((identity, _)) if identity == &process_identity => {} + _ => stable_identity = Some((process_identity.clone(), Instant::now())), + } + } else { + stable_identity = None; + } + if started_at.elapsed() >= SCRIPT_IDENTITY_TIMEOUT { + return Err(DaemonError::MissingProcessIdentity { + name: spec.name.clone(), + pid, + }); + } + + sleep(SCRIPT_IDENTITY_POLL_INTERVAL).await; + let Some(identity) = platform::inspect_process_identity(pid)? else { + return Err(DaemonError::MissingProcessIdentity { + name: spec.name.clone(), + pid, + }); + }; + process_identity = identity; + } } -fn write_runtime_metadata(spec: &ProcessSpec, pid: u32) -> Result<(), DaemonError> { +fn write_runtime_metadata( + spec: &ProcessSpec, + pid: u32, + process_start_identity: platform::ProcessStartIdentity, + process_executable_identity: Option, +) -> Result<(), DaemonError> { let started_at = timestamp()?; let metadata = RuntimeMetadata { name: spec.name.clone(), @@ -688,6 +831,8 @@ fn write_runtime_metadata(spec: &ProcessSpec, pid: u32) -> Result<(), DaemonErro track: spec.track.clone(), log_path: spec.log_path.to_string(), started_at, + process_start_identity: Some(process_start_identity), + process_executable_identity, }; let encoded = serde_json::to_string(&metadata)?; @@ -726,30 +871,6 @@ fn read_optional_file(path: &Utf8Path) -> Result, DaemonError> { } } -#[cfg(target_os = "macos")] -fn process_exists(pid: u32) -> Result { - let pid = process_group_pid(pid)?; - - match test_kill_process(pid) { - Ok(()) => Ok(true), - Err(source) => { - let error = io::Error::from(source); - if process_not_found(&error) || error.kind() == io::ErrorKind::PermissionDenied { - return Ok(false); - } - - Err(error.into()) - } - } -} - -#[cfg(any(target_os = "linux", target_os = "windows"))] -fn process_exists(_pid: u32) -> Result { - require_process_containment()?; - - Ok(false) -} - #[cfg(target_os = "macos")] fn process_group_exists(pid: u32) -> Result { let process_group = process_group_pid(pid)?; @@ -774,108 +895,65 @@ fn process_group_exists(_pid: u32) -> Result { Ok(false) } -fn live_process_matches_spec(pid: u32, spec: &ProcessSpec) -> Result { - if !process_exists(pid)? { - return Ok(false); - } - - let Some(command_line) = live_process_command_line(pid)? else { +fn live_process_matches( + pid: u32, + command: &Utf8Path, + arguments: &[String], + process_start_identity: platform::ProcessStartIdentity, + process_executable_identity: Option<&ProcessExecutableIdentity>, +) -> Result { + let Some(process_identity) = platform::inspect_process_identity(pid)? else { return Ok(false); }; - let command_tokens = command_line_tokens(&command_line); - let Some(live_executable) = command_tokens.first().map(String::as_str) else { + if process_identity.start_identity != process_start_identity { return Ok(false); - }; - - let command_matches = live_executable == spec.command.as_str() - || spec.command.file_name().is_some_and(|file_name| { - live_executable == file_name || live_executable.ends_with(&format!("/{file_name}")) - }) - || command_tokens - .get(1) - .is_some_and(|script| script == spec.command.as_str()); - let shell_command_argument = spec - .command - .file_name() - .is_some_and(|file_name| file_name == "sh" || file_name == "bash"); - let arguments_match = spec.arguments.iter().enumerate().all(|(index, argument)| { - if shell_command_argument - && index > 0 - && spec - .arguments - .get(index - 1) - .is_some_and(|previous| previous == "-c") - && argument.split_whitespace().count() > 1 - { - command_line.contains(argument) - } else { - command_tokens.iter().any(|token| token == argument) - } - }); - - Ok(command_matches && arguments_match) -} - -fn command_line_tokens(command_line: &str) -> Vec { - let mut tokens = Vec::new(); - let mut token = String::new(); - let mut quote = None; - let mut escaped = false; - - for character in command_line.chars() { - if escaped { - token.push(character); - escaped = false; - continue; - } - if character == '\\' { - escaped = true; - continue; - } - if let Some(quote_character) = quote { - if character == quote_character { - quote = None; - } else { - token.push(character); - } - continue; - } - if character == '\'' || character == '"' { - quote = Some(character); - continue; - } - if character.is_whitespace() { - if !token.is_empty() { - tokens.push(std::mem::take(&mut token)); - } - continue; - } - - token.push(character); - } - - if !token.is_empty() { - tokens.push(token); } - tokens + Ok(process_identity_matches( + &process_identity, + command, + arguments, + process_executable_identity, + )) } -fn live_process_command_line(pid: u32) -> Result, DaemonError> { - let output = StdCommand::new("/bin/ps") - .args(["-p", &pid.to_string(), "-o", "command="]) - .output()?; - - if !output.status.success() { - return Ok(None); - } +fn process_identity_matches( + process_identity: &platform::ProcessIdentity, + command: &Utf8Path, + arguments: &[String], + process_executable_identity: Option<&ProcessExecutableIdentity>, +) -> bool { + let direct_arguments_match = process_identity.arguments == arguments; + let direct_command_matches = executable_matches(process_identity, command); + let script_arguments_match = script_arguments_match(process_identity, command, arguments); + let script_identity_matches = script_arguments_match + && fs::read_to_string(command).is_ok_and(|source| source.starts_with("#!")) + && process_executable_identity.is_some_and(|expected| { + expected.executable == process_identity.executable.as_str() + && expected.argument_zero == process_identity.argument_zero + }); + + (direct_command_matches && direct_arguments_match) || script_identity_matches +} - let command_line = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if command_line.is_empty() { - return Ok(None); - } +fn script_arguments_match( + process_identity: &platform::ProcessIdentity, + command: &Utf8Path, + arguments: &[String], +) -> bool { + process_identity + .arguments + .split_first() + .is_some_and(|(script, live_arguments)| { + script == command.as_str() && live_arguments == arguments + }) +} - Ok(Some(command_line)) +fn executable_matches(process_identity: &platform::ProcessIdentity, command: &Utf8Path) -> bool { + process_identity.executable == command + || (command == Utf8Path::new("/bin/sh") + && process_identity.executable == Utf8Path::new("/bin/bash") + && process_identity.argument_zero == command.as_str()) } #[cfg(target_os = "macos")] diff --git a/crates/daemon/tests/snapshots/supervisor_foundation__supervisor_captures_logs_and_runtime_metadata_then_stops_child.snap b/crates/daemon/tests/snapshots/supervisor_foundation__supervisor_captures_logs_and_runtime_metadata_then_stops_child.snap index d83b7fe8..a8c54a25 100644 --- a/crates/daemon/tests/snapshots/supervisor_foundation__supervisor_captures_logs_and_runtime_metadata_then_stops_child.snap +++ b/crates/daemon/tests/snapshots/supervisor_foundation__supervisor_captures_logs_and_runtime_metadata_then_stops_child.snap @@ -15,6 +15,7 @@ expression: "(\"\", log, metadata)" "log_path": String("/.pv/logs/test-runtime.log"), "name": String("test-runtime"), "pid": String(""), + "process_start_identity": String(""), "resource_name": String("test-runtime"), "started_at": String(""), "track": String("test"), diff --git a/crates/daemon/tests/supervisor_foundation.rs b/crates/daemon/tests/supervisor_foundation.rs index 1ad327de..1f5ab641 100644 --- a/crates/daemon/tests/supervisor_foundation.rs +++ b/crates/daemon/tests/supervisor_foundation.rs @@ -364,6 +364,7 @@ async fn supervisor_captures_logs_and_runtime_metadata_then_stops_child() -> Res metadata["config_path"] = json!("/.pv/config/test-runtime.json"); metadata["log_path"] = json!("/.pv/logs/test-runtime.log"); metadata["started_at"] = json!(""); + metadata["process_start_identity"] = json!(""); process.stop(Duration::from_secs(1)).await?; @@ -672,6 +673,7 @@ async fn supervisor_rejects_metadata_for_a_reused_pid_with_a_different_command() vec!["-c".to_string(), "sleep 30".to_string()], )) .await?; + let process_start_identity = runtime_process_start_identity(actual.metadata_path())?; let forged = process_spec( &paths, "forged-runtime", @@ -691,6 +693,7 @@ async fn supervisor_rejects_metadata_for_a_reused_pid_with_a_different_command() "track": "test", "log_path": forged.log_path.as_str(), "started_at": "2026-05-25T00:00:00Z", + "process_start_identity": process_start_identity, }))?, )?; @@ -701,6 +704,97 @@ async fn supervisor_rejects_metadata_for_a_reused_pid_with_a_different_command() Ok(()) } +#[cfg(target_os = "macos")] +#[tokio::test] +async fn supervisor_rejects_spoofed_argument_zero_for_wrong_executable() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + state::fs::ensure_layout(&paths)?; + let supervisor = ProcessSupervisor::new(paths.clone()); + let actual = supervisor + .start(process_spec( + &paths, + "spoofed-argument-zero-runtime", + "/bin/bash", + vec![ + "-c".to_string(), + "exec -a /bin/sh /bin/sleep 30".to_string(), + ], + )) + .await?; + let pid = actual.pid(); + let process_start_identity = runtime_process_start_identity(actual.metadata_path())?; + let live_identity = timeout(Duration::from_secs(1), async { + loop { + if let Some(identity) = platform::inspect_process_identity(pid)? + && identity.executable == Utf8Path::new("/bin/sleep") + { + return Ok::<_, platform::PlatformError>(identity); + } + + sleep(Duration::from_millis(10)).await; + } + }) + .await??; + let forged = process_spec( + &paths, + "forged-spoofed-argument-zero-runtime", + "/bin/sh", + vec!["30".to_string()], + ); + write_forged_runtime_files(&forged, pid, process_start_identity)?; + + assert_eq!(live_identity.argument_zero, "/bin/sh"); + assert_eq!(live_identity.arguments, ["30"]); + assert!(supervisor.verify_ownership(&forged)?.is_none()); + + actual.stop(Duration::from_secs(1)).await?; + + Ok(()) +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn supervisor_rejects_shebang_identity_with_wrong_interpreter() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + state::fs::ensure_layout(&paths)?; + let supervisor = ProcessSupervisor::new(paths.clone()); + let runtime = paths.run().join("expected-script-runtime"); + let ready = paths.run().join("wrong-interpreter-ready"); + state::fs::write_sensitive_file( + &runtime, + &format!( + "#!/usr/bin/false\ntrap 'exit 0' TERM; touch \"{ready}\"; while true; do sleep 1; done\n" + ), + )?; + set_executable(&runtime)?; + let actual = supervisor + .start(process_spec( + &paths, + "wrong-interpreter-runtime", + "/bin/sh", + vec![runtime.to_string()], + )) + .await?; + wait_for_path(&ready).await?; + let process_start_identity = runtime_process_start_identity(actual.metadata_path())?; + let forged = process_spec(&paths, "forged-script-runtime", runtime, Vec::new()); + write_forged_runtime_files(&forged, actual.pid(), process_start_identity)?; + let mut metadata = runtime_metadata(&forged.metadata_path)?; + metadata["process_executable_identity"] = json!({ + "executable": "/usr/bin/false", + "argument_zero": "/usr/bin/false", + }); + state::fs::write_sensitive_file(&forged.metadata_path, &serde_json::to_string(&metadata)?)?; + + assert!(supervisor.verify_ownership(&forged)?.is_none()); + + actual.stop(Duration::from_secs(1)).await?; + + Ok(()) +} + #[tokio::test] async fn supervisor_rejects_reused_pid_when_expected_command_only_appears_in_arguments() -> Result<()> { @@ -717,6 +811,7 @@ async fn supervisor_rejects_reused_pid_when_expected_command_only_appears_in_arg vec!["-c".to_string(), format!("sleep 30 # {fake_command}")], )) .await?; + let process_start_identity = runtime_process_start_identity(actual.metadata_path())?; let forged = process_spec( &paths, "forged-argument-runtime", @@ -736,6 +831,7 @@ async fn supervisor_rejects_reused_pid_when_expected_command_only_appears_in_arg "track": "test", "log_path": forged.log_path.as_str(), "started_at": "2026-05-25T00:00:00Z", + "process_start_identity": process_start_identity, }))?, )?; @@ -760,6 +856,7 @@ async fn supervisor_rejects_reused_pid_with_same_binary_but_different_arguments( vec!["-c".to_string(), "while true; do sleep 1; done".to_string()], )) .await?; + let process_start_identity = runtime_process_start_identity(actual.metadata_path())?; let forged = process_spec( &paths, "forged-argument-runtime", @@ -779,6 +876,7 @@ async fn supervisor_rejects_reused_pid_with_same_binary_but_different_arguments( "track": "test", "log_path": forged.log_path.as_str(), "started_at": "2026-05-25T00:00:00Z", + "process_start_identity": process_start_identity, }))?, )?; @@ -808,6 +906,7 @@ async fn supervisor_rejects_reused_pid_with_same_binary_and_argument_prefix() -> ], )) .await?; + let process_start_identity = runtime_process_start_identity(actual.metadata_path())?; let forged = process_spec( &paths, "forged-prefix-runtime", @@ -827,6 +926,7 @@ async fn supervisor_rejects_reused_pid_with_same_binary_and_argument_prefix() -> "track": "test", "log_path": forged.log_path.as_str(), "started_at": "2026-05-25T00:00:00Z", + "process_start_identity": process_start_identity, }))?, )?; @@ -856,6 +956,7 @@ async fn supervisor_rejects_reused_pid_with_spaced_argument_prefix() -> Result<( vec![actual_config.to_string()], )) .await?; + let process_start_identity = runtime_process_start_identity(actual.metadata_path())?; let forged = process_spec( &paths, "forged-spaced-prefix-runtime", @@ -875,6 +976,7 @@ async fn supervisor_rejects_reused_pid_with_spaced_argument_prefix() -> Result<( "track": "test", "log_path": forged.log_path.as_str(), "started_at": "2026-05-25T00:00:00Z", + "process_start_identity": process_start_identity, }))?, )?; @@ -885,6 +987,143 @@ async fn supervisor_rejects_reused_pid_with_spaced_argument_prefix() -> Result<( Ok(()) } +#[tokio::test] +async fn supervisor_rejects_reordered_missing_and_duplicated_arguments() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + state::fs::ensure_layout(&paths)?; + let supervisor = ProcessSupervisor::new(paths.clone()); + let command = "while true; do sleep 1; done".to_string(); + let actual = supervisor + .start(process_spec( + &paths, + "ordered-argument-runtime", + "/bin/sh", + vec![ + "-c".to_string(), + command.clone(), + "alpha".to_string(), + "beta".to_string(), + ], + )) + .await?; + let process_start_identity = runtime_process_start_identity(actual.metadata_path())?; + let forged_arguments = [ + ( + "reordered-argument-runtime", + vec![ + "-c".to_string(), + command.clone(), + "beta".to_string(), + "alpha".to_string(), + ], + ), + ( + "missing-argument-runtime", + vec!["-c".to_string(), command.clone(), "alpha".to_string()], + ), + ( + "duplicated-argument-runtime", + vec![ + "-c".to_string(), + command, + "alpha".to_string(), + "alpha".to_string(), + "beta".to_string(), + ], + ), + ]; + + for (name, arguments) in forged_arguments { + let forged = process_spec(&paths, name, "/bin/sh", arguments); + write_forged_runtime_files(&forged, actual.pid(), process_start_identity.clone())?; + + assert!(supervisor.verify_ownership(&forged)?.is_none()); + } + + actual.stop(Duration::from_secs(1)).await?; + + Ok(()) +} + +#[tokio::test] +async fn supervisor_fails_closed_for_missing_and_malformed_process_start_identity() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + state::fs::ensure_layout(&paths)?; + let supervisor = ProcessSupervisor::new(paths.clone()); + let spec = process_spec( + &paths, + "invalid-start-identity-runtime", + "/bin/sleep", + vec!["30".to_string()], + ); + let process = supervisor.start(spec.clone()).await?; + let mut metadata = runtime_metadata(process.metadata_path())?; + let Some(metadata_object) = metadata.as_object_mut() else { + return Err(anyhow!("runtime metadata was not an object")); + }; + let _removed_identity = metadata_object.remove("process_start_identity"); + state::fs::write_sensitive_file(&spec.metadata_path, &serde_json::to_string(&metadata)?)?; + + assert!(supervisor.verify_ownership(&spec)?.is_none()); + + metadata["process_start_identity"] = json!({ + "seconds": "malformed", + "microseconds": 0, + }); + state::fs::write_sensitive_file(&spec.metadata_path, &serde_json::to_string(&metadata)?)?; + + assert!(matches!( + supervisor.verify_ownership(&spec), + Err(daemon::DaemonError::Json(_)) + )); + + process.stop(Duration::from_secs(1)).await?; + + Ok(()) +} + +#[tokio::test] +async fn supervisor_rejects_forged_process_start_identity_before_signalling() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + state::fs::ensure_layout(&paths)?; + let supervisor = ProcessSupervisor::new(paths.clone()); + let marker = paths.run().join("forged-start-signal-marker"); + let ready = paths.run().join("forged-start-signal-ready"); + let spec = process_spec( + &paths, + "forged-start-identity-runtime", + "/bin/sh", + vec![ + "-c".to_string(), + format!( + "trap 'touch \"{marker}\"' USR1; touch \"{ready}\"; while true; do sleep 1; done" + ), + ], + ); + let process = supervisor.start(spec.clone()).await?; + wait_for_path(&ready).await?; + let mut metadata = runtime_metadata(process.metadata_path())?; + let seconds = metadata["process_start_identity"]["seconds"] + .as_u64() + .ok_or_else(|| anyhow!("process-start seconds were missing"))?; + let forged_seconds = seconds + .checked_add(1) + .ok_or_else(|| anyhow!("process-start seconds could not be incremented"))?; + metadata["process_start_identity"]["seconds"] = json!(forged_seconds); + state::fs::write_sensitive_file(&spec.metadata_path, &serde_json::to_string(&metadata)?)?; + + assert!(!supervisor.reload(&spec)?); + sleep(Duration::from_millis(50)).await; + assert!(!marker.exists()); + + process.stop(Duration::from_secs(1)).await?; + + Ok(()) +} + async fn wait_for_file_contains(path: &camino::Utf8Path, needle: &str) -> Result { for _attempt in 0..50 { let content = state::testing::read_to_string(path)?; @@ -941,6 +1180,44 @@ async fn wait_for_path(path: &camino::Utf8Path) -> Result<()> { Err(anyhow!("file {path} did not exist")) } +fn runtime_metadata(path: &Utf8Path) -> Result { + Ok(serde_json::from_str(&state::testing::read_to_string( + path, + )?)?) +} + +fn runtime_process_start_identity(path: &Utf8Path) -> Result { + runtime_metadata(path)? + .get("process_start_identity") + .cloned() + .ok_or_else(|| anyhow!("runtime metadata did not contain process_start_identity")) +} + +fn write_forged_runtime_files( + spec: &ProcessSpec, + pid: u32, + process_start_identity: serde_json::Value, +) -> Result<()> { + state::fs::write_sensitive_file(&spec.pid_path, &format!("{pid}\n"))?; + state::fs::write_sensitive_file( + &spec.metadata_path, + &serde_json::to_string(&json!({ + "name": spec.name, + "pid": pid, + "command": spec.command.as_str(), + "arguments": spec.arguments, + "config_path": spec.config_path.as_str(), + "resource_name": spec.resource_name, + "track": spec.track, + "log_path": spec.log_path.as_str(), + "started_at": "2026-05-25T00:00:00Z", + "process_start_identity": process_start_identity, + }))?, + )?; + + Ok(()) +} + fn with_normalized_process_values(assertion: impl FnOnce() -> Result<()>) -> Result<()> { Settings::clone_current().bind(assertion) } diff --git a/crates/platform/src/ca.rs b/crates/platform/src/ca.rs index 8fbc2ad4..25e3988f 100644 --- a/crates/platform/src/ca.rs +++ b/crates/platform/src/ca.rs @@ -424,6 +424,7 @@ fn repair_reason_from_ca_error(error: PlatformError) -> CaRepairReason { | PlatformError::SystemIntegrationCommand { .. } | PlatformError::SystemIntegrationCommandStatus { .. } => CaRepairReason::InvalidCaShape, #[cfg(target_os = "macos")] - PlatformError::ListenerInspection { .. } => CaRepairReason::InvalidCaShape, + PlatformError::ListenerInspection { .. } + | PlatformError::ProcessIdentityInspection { .. } => CaRepairReason::InvalidCaShape, } } diff --git a/crates/platform/src/capability.rs b/crates/platform/src/capability.rs index 22fcf586..c9f0b052 100644 --- a/crates/platform/src/capability.rs +++ b/crates/platform/src/capability.rs @@ -10,6 +10,7 @@ pub enum PlatformCapability { ListenerInspection, LowPortFrontend, ProcessContainment, + ProcessInspection, ResolverIntegration, TrustStore, } @@ -23,6 +24,7 @@ impl PlatformCapability { Self::ListenerInspection => "listener inspection", Self::LowPortFrontend => "low-port frontend", Self::ProcessContainment => "process containment", + Self::ProcessInspection => "process inspection", Self::ResolverIntegration => "resolver integration", Self::TrustStore => "trust store", } diff --git a/crates/platform/src/error.rs b/crates/platform/src/error.rs index 86e8f507..d5f475b6 100644 --- a/crates/platform/src/error.rs +++ b/crates/platform/src/error.rs @@ -87,4 +87,11 @@ pub enum PlatformError { #[source] source: Box, }, + + #[cfg(target_os = "macos")] + #[error("could not inspect process identity: {source}")] + ProcessIdentityInspection { + #[source] + source: Box, + }, } diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 6e85a1d8..c4ae3cfa 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -30,7 +30,10 @@ pub use pf::{ active_pf_redirect_config, active_pf_redirect_config_with_privilege_mode, inspect_pf_anchor_file, inspect_pf_conf_reference, install_pf_redirects, remove_pf_redirects, }; -pub use process::{exec_replace, exec_replace_with_env}; +pub use process::{ + ProcessIdentity, ProcessStartIdentity, exec_replace, exec_replace_with_env, + inspect_process_identity, +}; pub use resolver::{ ResolverConfig, ResolverFileState, SYSTEM_RESOLVER_TEST_PATH, inspect_resolver_file, install_resolver_config, remove_resolver_config, diff --git a/crates/platform/src/process.rs b/crates/platform/src/process.rs index f85deed8..b2a48d66 100644 --- a/crates/platform/src/process.rs +++ b/crates/platform/src/process.rs @@ -3,15 +3,43 @@ use std::io; use std::path::Path; use std::process::ExitCode; +use camino::Utf8PathBuf; +use serde::{Deserialize, Serialize}; + #[cfg(unix)] use std::os::unix::process::CommandExt; +#[cfg(target_os = "macos")] +#[path = "process/macos.rs"] +mod implementation; +#[cfg(not(target_os = "macos"))] +#[path = "process/unsupported.rs"] +mod implementation; + #[expect( clippy::disallowed_types, reason = "platform process helper owns shim process replacement" )] type StdCommand = std::process::Command; +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProcessIdentity { + pub executable: Utf8PathBuf, + pub argument_zero: String, + pub arguments: Vec, + pub start_identity: ProcessStartIdentity, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ProcessStartIdentity { + pub seconds: u64, + pub microseconds: u64, +} + +pub fn inspect_process_identity(pid: u32) -> Result, crate::PlatformError> { + implementation::inspect_process_identity(pid) +} + #[cfg(unix)] pub fn exec_replace(program: &Path, args: &[String]) -> io::Result { exec_replace_with_env(program, args, &[]) diff --git a/crates/platform/src/process/macos.rs b/crates/platform/src/process/macos.rs new file mode 100644 index 00000000..8ed8770a --- /dev/null +++ b/crates/platform/src/process/macos.rs @@ -0,0 +1,350 @@ +use std::io; +use std::mem::{self, MaybeUninit}; +use std::ptr; +use std::str; +use std::thread; +use std::time::Duration; + +use camino::Utf8PathBuf; +use thiserror::Error; + +use super::{ProcessIdentity, ProcessStartIdentity}; +use crate::PlatformError; + +const MAX_SNAPSHOT_ATTEMPTS: usize = 5; +const MICROSECONDS_PER_SECOND: u64 = 1_000_000; +const SNAPSHOT_RETRY_DELAY: Duration = Duration::from_millis(1); + +pub(super) fn inspect_process_identity(pid: u32) -> Result, PlatformError> { + inspect_process_identity_inner(pid).map_err(|source| PlatformError::ProcessIdentityInspection { + source: Box::new(source), + }) +} + +fn inspect_process_identity_inner(pid: u32) -> Result, InspectionError> { + let native_pid = i32::try_from(pid).map_err(|_source| InspectionError::InvalidPid { pid })?; + + for _attempt in 1..=MAX_SNAPSHOT_ATTEMPTS { + let Some(start_identity) = process_start_identity(native_pid)? else { + return Ok(None); + }; + let Some((executable, argument_zero, arguments)) = process_arguments(native_pid)? else { + return Ok(None); + }; + let Some(confirmed_start_identity) = process_start_identity(native_pid)? else { + return Ok(None); + }; + + if start_identity == confirmed_start_identity { + return Ok(Some(ProcessIdentity { + executable, + argument_zero, + arguments, + start_identity, + })); + } + } + + Err(InspectionError::UnstableIdentity { + pid, + attempts: MAX_SNAPSHOT_ATTEMPTS, + }) +} + +fn process_start_identity( + pid: libc::pid_t, +) -> Result, InspectionError> { + let expected = mem::size_of::(); + let buffer_size = i32::try_from(expected) + .map_err(|_source| InspectionError::ProcessInfoTooLarge { size: expected })?; + let mut process_info = MaybeUninit::::uninit(); + + // SAFETY: `process_info` points to uninitialized storage large enough for one + // `proc_bsdinfo`, and `buffer_size` describes that exact writable region. + // The value is read only after macOS reports that it initialized every byte. + let actual = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + process_info.as_mut_ptr().cast(), + buffer_size, + ) + }; + + if actual <= 0 { + let source = io::Error::last_os_error(); + if process_not_found(&source) { + return Ok(None); + } + + return Err(InspectionError::ProcessInfo { pid, source }); + } + let actual = usize::try_from(actual) + .map_err(|_source| InspectionError::InvalidProcessInfoSize { expected, actual })?; + if actual != expected { + return Err(InspectionError::IncompleteProcessInfo { expected, actual }); + } + + // SAFETY: `proc_pidinfo` returned the exact `proc_bsdinfo` byte size above, + // proving that macOS initialized the complete value. + let process_info = unsafe { process_info.assume_init() }; + let expected_pid = + u32::try_from(pid).map_err(|_source| InspectionError::InvalidNativePid { pid })?; + if process_info.pbi_pid != expected_pid { + return Err(InspectionError::ProcessIdMismatch { + expected: expected_pid, + actual: process_info.pbi_pid, + }); + } + if process_info.pbi_start_tvsec == 0 || process_info.pbi_start_tvusec >= MICROSECONDS_PER_SECOND + { + return Err(InspectionError::InvalidStartIdentity { + seconds: process_info.pbi_start_tvsec, + microseconds: process_info.pbi_start_tvusec, + }); + } + + Ok(Some(ProcessStartIdentity { + seconds: process_info.pbi_start_tvsec, + microseconds: process_info.pbi_start_tvusec, + })) +} + +fn process_arguments( + pid: libc::pid_t, +) -> Result)>, InspectionError> { + for _attempt in 1..=MAX_SNAPSHOT_ATTEMPTS { + let capacity = match query_process_arguments(pid, None) { + Ok(capacity) => capacity, + Err(source) if process_not_found(&source) || argument_query_is_unavailable(&source) => { + return Ok(None); + } + Err(source) if argument_query_is_transient(&source) => { + thread::sleep(SNAPSHOT_RETRY_DELAY); + continue; + } + Err(source) => return Err(InspectionError::ArgumentSize { pid, source }), + }; + let mut buffer = vec![0; capacity]; + + match query_process_arguments(pid, Some(&mut buffer)) { + Ok(actual) if actual <= capacity => { + buffer.truncate(actual); + return parse_process_arguments(&buffer).map(Some); + } + Ok(actual) => { + return Err(InspectionError::InvalidArgumentSize { capacity, actual }); + } + Err(source) if process_not_found(&source) || argument_query_is_unavailable(&source) => { + return Ok(None); + } + Err(source) if argument_query_is_transient(&source) => { + thread::sleep(SNAPSHOT_RETRY_DELAY); + } + Err(source) => return Err(InspectionError::ArgumentRead { pid, source }), + } + } + + Err(InspectionError::ArgumentSnapshotUnstable { + pid, + attempts: MAX_SNAPSHOT_ATTEMPTS, + }) +} + +fn query_process_arguments(pid: libc::pid_t, buffer: Option<&mut [u8]>) -> io::Result { + let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid]; + let mut length = buffer.as_ref().map_or(0, |bytes| bytes.len()); + let pointer = buffer.map_or(ptr::null_mut(), |bytes| bytes.as_mut_ptr().cast()); + + // SAFETY: `mib` contains the documented read-only KERN_PROCARGS2 query, + // `length` points to valid writable storage, and `pointer` is either null for + // the size query or covers `length` writable bytes for the data query. Both + // new-value arguments are null/zero because this query does not mutate state. + let status = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + mib.len() as libc::c_uint, + pointer, + &mut length, + ptr::null_mut(), + 0, + ) + }; + + if status == 0 { + Ok(length) + } else { + Err(io::Error::last_os_error()) + } +} + +fn parse_process_arguments( + buffer: &[u8], +) -> Result<(Utf8PathBuf, String, Vec), InspectionError> { + let argument_count_size = mem::size_of::(); + let Some(argument_count_bytes) = buffer.get(..argument_count_size) else { + return Err(InspectionError::ArgumentBufferTooShort { + minimum: argument_count_size, + actual: buffer.len(), + }); + }; + let mut encoded_argument_count = [0; mem::size_of::()]; + encoded_argument_count.copy_from_slice(argument_count_bytes); + let argument_count = libc::c_int::from_ne_bytes(encoded_argument_count); + let argument_count = usize::try_from(argument_count) + .map_err(|_source| InspectionError::InvalidArgumentCount { argument_count })?; + if argument_count == 0 { + return Err(InspectionError::InvalidArgumentCount { argument_count: 0 }); + } + + let encoded = &buffer[argument_count_size..]; + let Some(executable_end) = encoded.iter().position(|byte| *byte == 0) else { + return Err(InspectionError::MissingExecutableTerminator); + }; + if executable_end == 0 { + return Err(InspectionError::MissingExecutable); + } + let executable = + str::from_utf8(&encoded[..executable_end]).map_err(InspectionError::NonUtf8Executable)?; + let mut encoded_arguments = &encoded[executable_end + 1..]; + while encoded_arguments.first() == Some(&0) { + encoded_arguments = &encoded_arguments[1..]; + } + + let mut arguments = Vec::with_capacity(argument_count); + for index in 0..argument_count { + let Some(argument_end) = encoded_arguments.iter().position(|byte| *byte == 0) else { + return Err(InspectionError::MissingArgumentTerminator { index }); + }; + let argument = str::from_utf8(&encoded_arguments[..argument_end]) + .map_err(|source| InspectionError::NonUtf8Argument { index, source })?; + arguments.push(argument.to_string()); + encoded_arguments = &encoded_arguments[argument_end + 1..]; + } + + let mut arguments = arguments.into_iter(); + let Some(argument_zero) = arguments.next() else { + return Err(InspectionError::InvalidArgumentCount { argument_count: 0 }); + }; + + Ok(( + Utf8PathBuf::from(executable), + argument_zero, + arguments.collect(), + )) +} + +fn process_not_found(error: &io::Error) -> bool { + error.kind() == io::ErrorKind::NotFound || error.raw_os_error() == Some(libc::ESRCH) +} + +fn argument_query_is_unavailable(error: &io::Error) -> bool { + // KERN_PROCARGS2 reports EINVAL when the target vanished or has no user stack. + error.raw_os_error() == Some(libc::EINVAL) +} + +fn argument_query_is_transient(error: &io::Error) -> bool { + matches!(error.raw_os_error(), Some(libc::EIO) | Some(libc::ENOMEM)) +} + +#[cfg(test)] +mod tests { + use std::io; + + use super::argument_query_is_unavailable; + + #[test] + fn argument_query_einval_means_process_identity_is_unavailable() { + assert!(argument_query_is_unavailable( + &io::Error::from_raw_os_error(libc::EINVAL) + )); + assert!(!argument_query_is_unavailable( + &io::Error::from_raw_os_error(libc::EACCES) + )); + } +} + +#[derive(Debug, Error)] +enum InspectionError { + #[error("process id {pid} exceeds the macOS process id range")] + InvalidPid { pid: u32 }, + + #[error("native process id {pid} cannot be represented as an unsigned process id")] + InvalidNativePid { pid: libc::pid_t }, + + #[error("macOS process information size {size} exceeds the native API limit")] + ProcessInfoTooLarge { size: usize }, + + #[error("could not read macOS process information for pid {pid}: {source}")] + ProcessInfo { + pid: libc::pid_t, + #[source] + source: io::Error, + }, + + #[error("macOS returned invalid process information size {actual}; expected {expected}")] + InvalidProcessInfoSize { + expected: usize, + actual: libc::c_int, + }, + + #[error("macOS returned {actual} process information bytes; expected {expected}")] + IncompleteProcessInfo { expected: usize, actual: usize }, + + #[error("macOS returned process id {actual} while inspecting pid {expected}")] + ProcessIdMismatch { expected: u32, actual: u32 }, + + #[error( + "macOS returned invalid process-start identity {seconds} seconds and {microseconds} microseconds" + )] + InvalidStartIdentity { seconds: u64, microseconds: u64 }, + + #[error("could not query argument size for pid {pid}: {source}")] + ArgumentSize { + pid: libc::pid_t, + #[source] + source: io::Error, + }, + + #[error("could not read arguments for pid {pid}: {source}")] + ArgumentRead { + pid: libc::pid_t, + #[source] + source: io::Error, + }, + + #[error("macOS returned {actual} argument bytes for a {capacity}-byte buffer")] + InvalidArgumentSize { capacity: usize, actual: usize }, + + #[error("macOS process arguments changed during {attempts} consecutive reads for pid {pid}")] + ArgumentSnapshotUnstable { pid: libc::pid_t, attempts: usize }, + + #[error("process identity changed during {attempts} consecutive reads for pid {pid}")] + UnstableIdentity { pid: u32, attempts: usize }, + + #[error("process argument buffer is too short: expected at least {minimum}, received {actual}")] + ArgumentBufferTooShort { minimum: usize, actual: usize }, + + #[error("process argument buffer reported invalid argument count {argument_count}")] + InvalidArgumentCount { argument_count: libc::c_int }, + + #[error("process argument buffer is missing the executable path terminator")] + MissingExecutableTerminator, + + #[error("process argument buffer contains an empty executable path")] + MissingExecutable, + + #[error("process executable path is not valid UTF-8: {0}")] + NonUtf8Executable(#[source] str::Utf8Error), + + #[error("process argument {index} is missing its terminator")] + MissingArgumentTerminator { index: usize }, + + #[error("process argument {index} is not valid UTF-8: {source}")] + NonUtf8Argument { + index: usize, + #[source] + source: str::Utf8Error, + }, +} diff --git a/crates/platform/src/process/unsupported.rs b/crates/platform/src/process/unsupported.rs new file mode 100644 index 00000000..72fd7cc7 --- /dev/null +++ b/crates/platform/src/process/unsupported.rs @@ -0,0 +1,8 @@ +use crate::capability::unsupported; +use crate::{PlatformCapability, PlatformError, ProcessIdentity}; + +pub(super) fn inspect_process_identity( + _pid: u32, +) -> Result, PlatformError> { + Err(unsupported(PlatformCapability::ProcessInspection)?) +} diff --git a/crates/platform/tests/process_identity.rs b/crates/platform/tests/process_identity.rs new file mode 100644 index 00000000..dc2e749c --- /dev/null +++ b/crates/platform/tests/process_identity.rs @@ -0,0 +1,93 @@ +#![cfg(target_os = "macos")] + +use std::process::Child; +use std::thread; +use std::time::Duration; + +use anyhow::{Result, anyhow}; +use camino::Utf8Path; +use camino_tempfile::tempdir; +use platform::inspect_process_identity; + +#[expect( + clippy::disallowed_types, + reason = "platform integration tests spawn controlled processes for native inspection" +)] +type TestCommand = std::process::Command; + +#[test] +fn native_process_identity_reports_direct_executable_and_ordered_arguments() -> Result<()> { + let mut child = ChildGuard(TestCommand::new("/bin/sleep").arg("30").spawn()?); + let identity = inspect_child(&mut child)?; + + assert_eq!(identity.executable, Utf8Path::new("/bin/sleep")); + assert_eq!(identity.argument_zero, "/bin/sleep"); + assert_eq!(identity.arguments, ["30"]); + assert!(identity.start_identity.seconds > 0); + assert!(identity.start_identity.microseconds < 1_000_000); + + Ok(()) +} + +#[test] +fn native_process_identity_preserves_shell_command_as_one_argument() -> Result<()> { + let command = "kill -STOP $$"; + let mut child = ChildGuard(TestCommand::new("/bin/sh").args(["-c", command]).spawn()?); + thread::sleep(Duration::from_millis(50)); + let identity = inspect_child(&mut child)?; + + assert!(identity.executable.is_absolute()); + assert_eq!(identity.argument_zero, "/bin/sh"); + assert_eq!(identity.arguments, ["-c", command]); + + Ok(()) +} + +#[test] +fn native_process_identity_reports_shebang_script_and_ordered_arguments() -> Result<()> { + let tempdir = tempdir()?; + let script = tempdir.path().join("owned-runtime"); + state::fs::write_sensitive_file(&script, "#!/bin/sh\nkill -STOP $$\n")?; + set_executable(&script)?; + let mut child = ChildGuard(TestCommand::new(&script).args(["one", "two"]).spawn()?); + thread::sleep(Duration::from_millis(50)); + let identity = inspect_child(&mut child)?; + + assert!(identity.executable.is_absolute()); + assert_eq!(identity.argument_zero, "/bin/sh"); + assert_eq!( + identity.arguments, + [script.to_string(), "one".to_string(), "two".to_string()] + ); + + Ok(()) +} + +fn inspect_child(child: &mut ChildGuard) -> Result { + let pid = child.0.id(); + + inspect_process_identity(pid)?.ok_or_else(|| anyhow!("process {pid} had no native identity")) +} + +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _kill_result = self.0.kill(); + let _wait_result = self.0.wait(); + } +} + +#[expect( + clippy::disallowed_methods, + reason = "platform integration test marks a controlled shebang fixture executable" +)] +fn set_executable(path: &Utf8Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = std::fs::metadata(path)?.permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(path, permissions)?; + + Ok(()) +} diff --git a/crates/platform/tests/unsupported_process_identity.rs b/crates/platform/tests/unsupported_process_identity.rs new file mode 100644 index 00000000..a3c7baaf --- /dev/null +++ b/crates/platform/tests/unsupported_process_identity.rs @@ -0,0 +1,19 @@ +#![cfg(not(target_os = "macos"))] + +use platform::{PlatformCapability, PlatformError, PlatformTarget, inspect_process_identity}; + +#[test] +fn public_process_identity_inspection_rejects_unsupported_platform() -> anyhow::Result<()> { + let target = PlatformTarget::current()?; + let result = inspect_process_identity(1); + + assert!(matches!( + result, + Err(PlatformError::Unsupported { + capability: PlatformCapability::ProcessInspection, + target: error_target, + }) if error_target == target + )); + + Ok(()) +}