#![cfg(target_os = "macos")]
use std::ffi::{CStr, c_char};
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use kanade_shared::wire::RunAs;
use tokio::process::Command as ProcessCommand;
use tracing::{info, warn};
const LAUNCHCTL: &str = "/bin/launchctl";
const SUDO: &str = "/usr/bin/sudo";
const ENV: &str = "/usr/bin/env";
const PATH_PREFIX: [&str; 3] = ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin"];
const FALLBACK_ETC_PATHS: &str = "/usr/local/bin\n/usr/bin\n/bin\n/usr/sbin\n/sbin\n";
const FALLBACK_LANG: &str = "en_US.UTF-8";
const STAGING_CATEGORY: &str = "/Library/Application Support/Kanade/agent-scripts";
#[derive(Debug)]
struct Account {
name: String,
home: String,
shell: String,
}
pub(crate) fn session_command(
run_as: RunAs,
program: &str,
args: &[&str],
cwd: Option<&str>,
) -> Result<ProcessCommand> {
debug_assert!(matches!(run_as, RunAs::User | RunAs::SystemGui));
let console_owner = std::fs::metadata("/dev/console").map(|m| m.uid()).ok();
let Some(console_uid) = interactive_uid(console_owner) else {
bail!(
"no console user logged in (/dev/console is owned by root or unreadable) — \
run_as: user / system_gui needs a logged-in user"
);
};
let target_uid = match run_as {
RunAs::User => console_uid,
_ => current_euid(),
};
let target = lookup_account(target_uid)?;
let path = job_path();
let lang = std::env::var("LANG")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| FALLBACK_LANG.to_string());
let env = job_env(&target, &lang, &path);
let argv = launch_argv(run_as, console_uid, &target, &env, program, args);
let mut builder = ProcessCommand::new(LAUNCHCTL);
builder.args(&argv);
if let Some(dir) = launch_cwd(run_as, cwd, &target.home) {
builder.current_dir(dir);
}
info!(
run_as = ?run_as,
console_uid,
account = %target.name,
program,
"launching in the console user's GUI session via launchctl asuser",
);
Ok(builder)
}
pub(crate) fn expand_agent_cwd(raw: &str) -> String {
if split_tilde(raw).is_none() {
return raw.to_string();
}
match lookup_account(current_euid()) {
Ok(account) => expand_tilde(raw, &account.home),
Err(e) => {
warn!(error = %e, raw_cwd = %raw, "cwd expansion failed; using raw value");
raw.to_string()
}
}
}
pub(crate) fn create_staging_dir(uuid: &str) -> Result<PathBuf> {
let dir = if current_euid() == 0 {
let category = Path::new(STAGING_CATEGORY);
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o755)
.create(category)
.with_context(|| format!("create_dir_all {}", category.display()))?;
for d in [category.parent().unwrap_or(category), category] {
set_mode(d, 0o755)?;
}
category.join(uuid)
} else {
std::env::temp_dir().join(format!("kanade-agent-{uuid}"))
};
std::fs::DirBuilder::new()
.mode(0o755)
.create(&dir)
.with_context(|| format!("create_dir {}", dir.display()))?;
set_mode(&dir, 0o755)?;
Ok(dir)
}
fn set_mode(path: &Path, mode: u32) -> Result<()> {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.with_context(|| format!("chmod {mode:o} {}", path.display()))
}
fn interactive_uid(console_owner: Option<u32>) -> Option<u32> {
console_owner.filter(|&uid| uid != 0)
}
fn current_euid() -> u32 {
unsafe { libc::geteuid() }
}
fn lookup_account(uid: u32) -> Result<Account> {
const MAX_BUF: usize = 1 << 20;
let mut buf: Vec<c_char> = vec![0; 4096];
loop {
let mut pwd = std::mem::MaybeUninit::<libc::passwd>::uninit();
let mut result: *mut libc::passwd = std::ptr::null_mut();
let rc = unsafe {
libc::getpwuid_r(
uid,
pwd.as_mut_ptr(),
buf.as_mut_ptr(),
buf.len(),
&mut result,
)
};
if rc == libc::ERANGE && buf.len() < MAX_BUF {
buf.resize(buf.len() * 2, 0);
continue;
}
if rc != 0 {
return Err(std::io::Error::from_raw_os_error(rc))
.with_context(|| format!("getpwuid_r({uid})"));
}
if result.is_null() {
bail!("uid {uid} has no passwd entry");
}
let pwd = unsafe { pwd.assume_init_ref() };
let name = passwd_field(pwd.pw_name, uid, "pw_name")?;
if name.is_empty() {
bail!("uid {uid} has an empty passwd name");
}
let home = passwd_field(pwd.pw_dir, uid, "pw_dir")?;
let shell = passwd_field(pwd.pw_shell, uid, "pw_shell")?;
return Ok(Account {
name,
home: if home.is_empty() { "/".into() } else { home },
shell: if shell.is_empty() {
"/bin/sh".into()
} else {
shell
},
});
}
}
fn passwd_field(ptr: *const c_char, uid: u32, field: &str) -> Result<String> {
if ptr.is_null() {
return Ok(String::new());
}
let s = unsafe { CStr::from_ptr(ptr) };
s.to_str()
.map(str::to_owned)
.with_context(|| format!("uid {uid}: {field} is not UTF-8"))
}
pub(crate) fn job_path() -> String {
let etc_paths = std::fs::read_to_string("/etc/paths").unwrap_or_else(|e| {
warn!(error = %e, "read /etc/paths failed; using the stock entries");
FALLBACK_ETC_PATHS.to_string()
});
let mut files: Vec<PathBuf> = std::fs::read_dir("/etc/paths.d")
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.collect();
files.sort();
let paths_d: Vec<String> = files
.iter()
.filter_map(|p| std::fs::read_to_string(p).ok())
.collect();
build_path(&etc_paths, &paths_d)
}
fn build_path(etc_paths: &str, paths_d: &[String]) -> String {
let files = std::iter::once(etc_paths).chain(paths_d.iter().map(String::as_str));
let mut entries: Vec<&str> = Vec::new();
for entry in PATH_PREFIX
.into_iter()
.chain(files.flat_map(str::lines).map(str::trim))
{
if !entry.is_empty() && !entries.contains(&entry) {
entries.push(entry);
}
}
entries.join(":")
}
fn job_env(account: &Account, lang: &str, path: &str) -> Vec<String> {
vec![
format!("HOME={}", account.home),
format!("USER={}", account.name),
format!("LOGNAME={}", account.name),
format!("SHELL={}", account.shell),
format!("LANG={lang}"),
format!("PATH={path}"),
]
}
fn launch_argv(
run_as: RunAs,
console_uid: u32,
target: &Account,
env: &[String],
program: &str,
args: &[&str],
) -> Vec<String> {
let mut argv = vec!["asuser".to_string(), console_uid.to_string()];
if run_as == RunAs::User {
argv.extend(
[SUDO, "-n", "-u", &target.name, "-H", "--"]
.into_iter()
.map(String::from),
);
}
argv.extend([ENV.to_string(), "-i".to_string()]);
argv.extend(env.iter().cloned());
argv.push(program.to_string());
argv.extend(args.iter().map(|a| a.to_string()));
argv
}
fn launch_cwd(run_as: RunAs, raw: Option<&str>, target_home: &str) -> Option<PathBuf> {
match raw.filter(|s| !s.is_empty()) {
Some(dir) => Some(PathBuf::from(expand_tilde(dir, target_home))),
None if run_as == RunAs::User && Path::new(target_home).is_dir() => {
Some(PathBuf::from(target_home))
}
None => None,
}
}
fn split_tilde(raw: &str) -> Option<&str> {
if raw == "~" {
Some("")
} else {
raw.strip_prefix("~/")
}
}
fn expand_tilde(raw: &str, home: &str) -> String {
match split_tilde(raw) {
None => raw.to_string(),
Some("") => home.to_string(),
Some(rest) => format!("{}/{rest}", home.trim_end_matches('/')),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn user() -> Account {
Account {
name: "alice".into(),
home: "/Users/alice".into(),
shell: "/bin/zsh".into(),
}
}
#[test]
fn login_window_or_unknown_console_owner_means_no_user() {
assert_eq!(interactive_uid(Some(0)), None);
assert_eq!(interactive_uid(None), None);
assert_eq!(interactive_uid(Some(501)), Some(501));
}
#[test]
fn path_follows_path_helper_order_and_dedupes() {
let etc_paths = "/usr/local/bin\n/usr/bin\n\n /bin \n/usr/sbin\n/sbin\n";
let paths_d = vec![
"/var/run/cryptex/usr/bin\n".to_string(),
"/opt/homebrew/bin\n/usr/bin\n".to_string(),
"/Library/Apple/usr/bin".to_string(),
];
assert_eq!(
build_path(etc_paths, &paths_d),
"/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:\
/usr/sbin:/sbin:/var/run/cryptex/usr/bin:/Library/Apple/usr/bin"
);
}
#[test]
fn path_without_etc_paths_entries_is_just_the_prefix() {
assert_eq!(
build_path("", &[]),
"/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin"
);
}
#[test]
fn user_argv_drops_to_the_user_then_replaces_the_environment() {
let env = job_env(&user(), "ja_JP.UTF-8", "/usr/bin:/bin");
let argv = launch_argv(
RunAs::User,
501,
&user(),
&env,
"pwsh",
&["-NoProfile", "-File", "/staged/launcher.ps1"],
);
assert_eq!(
argv,
[
"asuser",
"501",
"/usr/bin/sudo",
"-n",
"-u",
"alice",
"-H",
"--",
"/usr/bin/env",
"-i",
"HOME=/Users/alice",
"USER=alice",
"LOGNAME=alice",
"SHELL=/bin/zsh",
"LANG=ja_JP.UTF-8",
"PATH=/usr/bin:/bin",
"pwsh",
"-NoProfile",
"-File",
"/staged/launcher.ps1",
]
);
}
#[test]
fn system_gui_argv_joins_the_user_session_but_stays_root() {
let root = Account {
name: "root".into(),
home: "/var/root".into(),
shell: "/bin/sh".into(),
};
let env = job_env(&root, "en_US.UTF-8", "/usr/bin");
let argv = launch_argv(RunAs::SystemGui, 501, &root, &env, "sh", &["-c", "id"]);
assert_eq!(
argv,
[
"asuser",
"501",
"/usr/bin/env",
"-i",
"HOME=/var/root",
"USER=root",
"LOGNAME=root",
"SHELL=/bin/sh",
"LANG=en_US.UTF-8",
"PATH=/usr/bin",
"sh",
"-c",
"id",
]
);
assert!(!argv.iter().any(|a| a == "/usr/bin/sudo"));
}
#[test]
fn values_are_single_argv_elements_never_reparsed() {
let odd = Account {
name: "bob".into(),
home: "/Users/bob smith/$(id)".into(),
shell: "/bin/zsh".into(),
};
let script = "echo \"$HOME\"; rm -rf ~ `id` $(whoami)";
let env = job_env(&odd, "en_US.UTF-8", "/usr/bin");
let argv = launch_argv(RunAs::User, 502, &odd, &env, "sh", &["-c", script]);
assert!(argv.contains(&"HOME=/Users/bob smith/$(id)".to_string()));
assert_eq!(argv[argv.len() - 3..], ["sh", "-c", script]);
}
#[test]
fn tilde_expands_against_the_given_home() {
assert_eq!(expand_tilde("~", "/Users/alice"), "/Users/alice");
assert_eq!(
expand_tilde("~/src/x", "/Users/alice"),
"/Users/alice/src/x"
);
assert_eq!(expand_tilde("~/src", "/Users/alice/"), "/Users/alice/src");
assert_eq!(expand_tilde("~/x", "/"), "/x");
assert_eq!(expand_tilde("~bob/x", "/Users/alice"), "~bob/x");
assert_eq!(expand_tilde("~\\x", "/Users/alice"), "~\\x");
assert_eq!(expand_tilde("/tmp/~", "/Users/alice"), "/tmp/~");
}
#[test]
fn explicit_cwd_wins_and_system_gui_inherits_when_unset() {
assert_eq!(
launch_cwd(RunAs::SystemGui, Some("~/work"), "/var/root"),
Some(PathBuf::from("/var/root/work"))
);
assert_eq!(
launch_cwd(RunAs::User, Some("/does/not/exist"), "/"),
Some(PathBuf::from("/does/not/exist"))
);
assert_eq!(launch_cwd(RunAs::SystemGui, None, "/var/root"), None);
assert_eq!(
launch_cwd(RunAs::User, Some(""), "/"),
Some(PathBuf::from("/"))
);
}
}