diff --git a/crates/tracedecay-daemon-control/src/service/runner.rs b/crates/tracedecay-daemon-control/src/service/runner.rs index f9178b7773..93e471ece5 100644 --- a/crates/tracedecay-daemon-control/src/service/runner.rs +++ b/crates/tracedecay-daemon-control/src/service/runner.rs @@ -393,11 +393,16 @@ fn required_service_program(program: &str, lifecycle: &str, candidate: &Path) -> ), }); } - canonical_service_program_candidate(program, lifecycle, candidate)? - .ok_or_else(|| service_program_unavailable(program, lifecycle)) + canonical_service_program_candidate( + program, + lifecycle, + candidate, + NonExecutableCandidate::Reject, + )? + .ok_or_else(|| service_program_unavailable(program, lifecycle)) } -fn require_service_program_on_path( +pub(super) fn require_service_program_on_path( program: &str, lifecycle: &str, path_var: Option<&std::ffi::OsStr>, @@ -407,19 +412,29 @@ fn require_service_program_on_path( }; for directory in std::env::split_paths(path_var) { let candidate = directory.join(program); - if let Some(canonical) = - canonical_service_program_candidate(program, lifecycle, &candidate)? - { + if let Some(canonical) = canonical_service_program_candidate( + program, + lifecycle, + &candidate, + NonExecutableCandidate::Skip, + )? { return Ok(canonical); } } Err(service_program_unavailable(program, lifecycle)) } +#[derive(Clone, Copy)] +enum NonExecutableCandidate { + Reject, + Skip, +} + fn canonical_service_program_candidate( program: &str, lifecycle: &str, candidate: &Path, + non_executable: NonExecutableCandidate, ) -> Result> { let metadata = match std::fs::metadata(candidate) { Ok(metadata) if !metadata.is_file() => return Ok(None), @@ -428,12 +443,15 @@ fn canonical_service_program_candidate( Err(error) => return Err(TraceDecayError::Io(error)), }; if !service_program_is_executable(&metadata) { - return Err(TraceDecayError::Config { - message: format!( - "{program} candidate '{}' exists but is not executable for {lifecycle}", - candidate.display() - ), - }); + return match non_executable { + NonExecutableCandidate::Reject => Err(TraceDecayError::Config { + message: format!( + "{program} candidate '{}' exists but is not executable for {lifecycle}", + candidate.display() + ), + }), + NonExecutableCandidate::Skip => Ok(None), + }; } match std::fs::canonicalize(candidate) { Ok(canonical) => Ok(Some(canonical)), diff --git a/crates/tracedecay-daemon-control/src/service/tests.rs b/crates/tracedecay-daemon-control/src/service/tests.rs index 494c8cfde4..2b911db02e 100644 --- a/crates/tracedecay-daemon-control/src/service/tests.rs +++ b/crates/tracedecay-daemon-control/src/service/tests.rs @@ -1,9 +1,11 @@ use std::ffi::{OsStr, OsString}; -use std::path::{Path, PathBuf}; +#[cfg(target_os = "linux")] +use std::path::Path; +use std::path::PathBuf; #[cfg(unix)] use std::io::{BufRead, Write}; -#[cfg(target_os = "linux")] +#[cfg(unix)] use std::os::unix::fs::PermissionsExt; #[cfg(unix)] use std::os::unix::net::UnixListener; @@ -15,7 +17,6 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use tempfile::TempDir; -#[cfg(target_os = "linux")] use super::runner::ServiceRunner; use super::runner::{LaunchctlFailureMode, LaunchdCommand}; use super::{DaemonServiceSpec, DaemonServiceState}; @@ -249,12 +250,16 @@ fn unavailable_socket_advice_names_stopped_disabled_enable_now() { ); } +#[cfg(unix)] #[test] fn unavailable_socket_advice_without_state_uses_unit_file_presence() { let _env_lock = lock_user_data_dir_test_env(); let dir = TempDir::new().expect("temp dir"); let config_home = dir.path().join("config"); + let home = dir.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); let _config_guard = EnvVarGuard::set("XDG_CONFIG_HOME", &config_home); + let _home_guard = EnvVarGuard::set("HOME", &home); let socket = PathBuf::from("/tmp/tracedecay.sock"); let missing = super::unavailable_daemon_socket_advice(&socket, None); @@ -267,7 +272,7 @@ fn unavailable_socket_advice_without_state_uses_unit_file_presence() { "absent unit keeps generic ensure-running text, got: {missing}" ); - let service_path = config_home.join("systemd/user").join(crate::SERVICE_NAME); + let service_path = super::service_unit_path().expect("service unit path"); std::fs::create_dir_all(service_path.parent().expect("service parent")).expect("service dir"); std::fs::write( &service_path, @@ -1097,6 +1102,76 @@ fn launchd_disabled_output_matches_only_the_tracedecay_label() { )); } +#[cfg(unix)] +fn assert_path_lookup_skips_non_executable_shadow(program: &str, lifecycle: &str) { + let dir = TempDir::new().expect("temp dir"); + let shadow_dir = dir.path().join("shadow"); + let executable_dir = dir.path().join("executable"); + std::fs::create_dir_all(&shadow_dir).expect("shadow dir"); + std::fs::create_dir_all(&executable_dir).expect("executable dir"); + + let shadow = shadow_dir.join(program); + std::fs::write(&shadow, "#!/bin/sh\nexit 1\n").expect("shadow program"); + std::fs::set_permissions(&shadow, std::fs::Permissions::from_mode(0o644)) + .expect("shadow permissions"); + + let executable = executable_dir.join(program); + std::fs::write(&executable, "#!/bin/sh\nexit 0\n").expect("executable program"); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)) + .expect("executable permissions"); + + let path_var = std::env::join_paths([shadow_dir, executable_dir]).expect("fixture PATH"); + let resolved = super::runner::require_service_program_on_path( + program, + lifecycle, + Some(path_var.as_os_str()), + ) + .expect("later executable PATH candidate"); + + assert_eq!( + resolved, + executable.canonicalize().expect("canonical executable") + ); +} + +#[cfg(unix)] +#[test] +fn launchctl_path_lookup_skips_non_executable_shadow() { + assert_path_lookup_skips_non_executable_shadow("launchctl", "launchd agent management"); +} + +#[cfg(unix)] +#[test] +fn id_path_lookup_skips_non_executable_shadow() { + assert_path_lookup_skips_non_executable_shadow("id", "launchd user-domain resolution"); +} + +#[cfg(unix)] +#[test] +fn explicitly_injected_launchd_programs_reject_non_executable_paths() { + let dir = TempDir::new().expect("temp dir"); + let launchctl = dir.path().join("launchctl"); + let id = dir.path().join("id"); + std::fs::write(&launchctl, "#!/bin/sh\nexit 0\n").expect("launchctl program"); + std::fs::write(&id, "#!/bin/sh\nexit 0\n").expect("id program"); + + std::fs::set_permissions(&launchctl, std::fs::Permissions::from_mode(0o644)) + .expect("launchctl permissions"); + std::fs::set_permissions(&id, std::fs::Permissions::from_mode(0o755)).expect("id permissions"); + let launchctl_error = ServiceRunner::launchd(&launchctl, &id) + .expect_err("explicit launchctl path must remain strict"); + assert!(launchctl_error.to_string().contains("launchctl candidate")); + assert!(launchctl_error.to_string().contains("not executable")); + + std::fs::set_permissions(&launchctl, std::fs::Permissions::from_mode(0o755)) + .expect("launchctl permissions"); + std::fs::set_permissions(&id, std::fs::Permissions::from_mode(0o644)).expect("id permissions"); + let id_error = + ServiceRunner::launchd(&launchctl, &id).expect_err("explicit id path must remain strict"); + assert!(id_error.to_string().contains("id candidate")); + assert!(id_error.to_string().contains("not executable")); +} + #[cfg(target_os = "linux")] #[test] fn atomic_service_write_faults_preserve_the_forward_boundary() {