use std::ffi::{CStr, OsStr};
use std::io::ErrorKind;
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use arcbox_constants::dns::LOCAL_CA_COMMON_NAME;
use arcbox_constants::paths::{DOCKER_CLI_TOOLS, HostLayout, is_arcbox_owned, labels};
use super::host::{Host, run_checked, sudo};
use super::inventory::{HOSTS_MARKER, Roots};
use crate::commands::daemon;
pub(super) enum Outcome {
Done,
Skipped(String),
}
fn skipped(reason: impl Into<String>) -> Outcome {
Outcome::Skipped(reason.into())
}
pub(super) async fn unlink_cli_tools_through_helper(roots: &Roots) {
let Ok(client) = arcbox_helper::client::Client::connect().await else {
return;
};
let bin = roots.system_path("/usr/local/bin");
for name in DOCKER_CLI_TOOLS {
if std::fs::read_link(bin.join(name)).is_ok_and(|target| is_arcbox_owned(&target)) {
let _ = client.cli_unlink(name).await;
}
}
}
const QUIT_TIMEOUT: Duration = Duration::from_secs(60);
pub(super) fn quit_app(host: &dyn Host, bundle_id: &str) -> Result<Outcome> {
if !app_is_running(host, bundle_id)? {
return Ok(skipped("not running"));
}
let script = format!("tell application id \"{bundle_id}\" to quit");
run_checked(host, "osascript", &[OsStr::new("-e"), OsStr::new(&script)])?;
let deadline = Instant::now() + QUIT_TIMEOUT;
while app_is_running(host, bundle_id)? {
if Instant::now() >= deadline {
bail!("the app did not quit within {}s", QUIT_TIMEOUT.as_secs());
}
std::thread::sleep(Duration::from_millis(500));
}
Ok(Outcome::Done)
}
fn app_is_running(host: &dyn Host, bundle_id: &str) -> Result<bool> {
let script = format!("application id \"{bundle_id}\" is running");
let output = host.run("osascript", &[OsStr::new("-e"), OsStr::new(&script)])?;
Ok(output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true")
}
pub(super) async fn stop_daemon(
host: &dyn Host,
layout: &HostLayout,
labels: &[&str],
) -> Result<Outcome> {
let uid = unsafe { libc::getuid() };
let mut booted_out = false;
for label in labels {
booted_out |= bootout(host, &format!("gui/{uid}/{label}"), false)?;
}
if booted_out {
if let Some(pid) = daemon::locked_pid(layout)? {
daemon::await_exit(layout, pid).await?;
}
return Ok(Outcome::Done);
}
if daemon::daemon_is_alive(&layout.lock_file) {
daemon::stop(layout).await?;
return Ok(Outcome::Done);
}
Ok(skipped("not running"))
}
fn bootout(host: &dyn Host, target: &str, privileged: bool) -> Result<bool> {
let args = [
OsStr::new("launchctl"),
OsStr::new("bootout"),
OsStr::new(target),
];
let output = if privileged {
host.run("sudo", &args)?
} else {
host.run("launchctl", &args[1..])?
};
if output.status.success() {
return Ok(true);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.code() == Some(3)
|| stderr.contains("No such process")
|| stderr.contains("Could not find service")
{
return Ok(false);
}
bail!(
"launchctl bootout {target} failed ({}): {}",
output.status,
stderr.trim()
);
}
pub(super) fn bootout_helper(host: &dyn Host) -> Result<Outcome> {
if bootout(host, &format!("system/{}", labels::HELPER), true)? {
Ok(Outcome::Done)
} else {
Ok(skipped("not loaded"))
}
}
pub(super) fn remove_path(host: &dyn Host, path: &Path) -> Result<()> {
let metadata = match path.symlink_metadata() {
Ok(metadata) => metadata,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e).with_context(|| format!("could not inspect {}", path.display())),
};
let is_dir = metadata.is_dir();
let direct = if is_dir {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
match direct {
Ok(()) => Ok(()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) if e.kind() == ErrorKind::PermissionDenied => {
let flags = if is_dir { "-rf" } else { "-f" };
sudo(
host,
&[OsStr::new("rm"), OsStr::new(flags), path.as_os_str()],
)
}
Err(e) => Err(e).with_context(|| format!("could not remove {}", path.display())),
}
}
pub(super) fn remove_hosts_alias(host: &dyn Host, hosts: &Path) -> Result<Outcome> {
let content = std::fs::read_to_string(hosts)
.with_context(|| format!("could not read {}", hosts.display()))?;
if !content.lines().any(|line| line.contains(HOSTS_MARKER)) {
return Ok(skipped("no alias line"));
}
let mut kept: String = content
.lines()
.filter(|line| !line.contains(HOSTS_MARKER))
.collect::<Vec<_>>()
.join("\n");
if !kept.is_empty() {
kept.push('\n');
}
match std::fs::write(hosts, &kept) {
Ok(()) => Ok(Outcome::Done),
Err(e) if e.kind() == ErrorKind::PermissionDenied => {
let staged =
tempfile::NamedTempFile::new().context("could not stage the new hosts file")?;
std::fs::write(staged.path(), &kept).context("could not stage the new hosts file")?;
sudo(
host,
&[
OsStr::new("cp"),
staged.path().as_os_str(),
hosts.as_os_str(),
],
)?;
Ok(Outcome::Done)
}
Err(e) => Err(e).with_context(|| format!("could not write {}", hosts.display())),
}
}
pub(super) fn remove_data_export(host: &dyn Host, mount_point: &Path) -> Result<Outcome> {
if !mount_point.exists() {
return Ok(skipped("absent"));
}
match mount_at(mount_point) {
Some(mount) if mount.is_arcbox_export() => {
run_checked(host, "/sbin/umount", &[mount_point.as_os_str()])?;
}
Some(mount) => {
return Ok(skipped(format!(
"left alone: {} ({}) is mounted there",
mount.source, mount.fstype
)));
}
None => {}
}
let empty = std::fs::read_dir(mount_point)
.with_context(|| format!("could not read {}", mount_point.display()))?
.next()
.is_none();
if !empty {
return Ok(skipped("left alone: not empty"));
}
std::fs::remove_dir(mount_point)
.with_context(|| format!("could not remove {}", mount_point.display()))?;
Ok(Outcome::Done)
}
struct Mount {
source: String,
fstype: String,
}
impl Mount {
fn is_arcbox_export(&self) -> bool {
self.fstype == "nfs"
&& (self.source == "127.0.0.1:/"
|| self.source == format!("{}:/", arcbox_helper::HOSTS_ALIAS_NAME))
}
}
fn mount_at(path: &Path) -> Option<Mount> {
use std::os::unix::ffi::OsStrExt as _;
let canonical = std::fs::canonicalize(path).ok()?;
let c_path = std::ffi::CString::new(canonical.as_os_str().as_bytes()).ok()?;
let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statfs(c_path.as_ptr(), &raw mut stat) } != 0 {
return None;
}
let field = |bytes: &[libc::c_char]| {
unsafe { CStr::from_ptr(bytes.as_ptr()) }
.to_string_lossy()
.into_owned()
};
(Path::new(&field(&stat.f_mntonname)) == canonical).then(|| Mount {
source: field(&stat.f_mntfromname),
fstype: field(&stat.f_fstypename),
})
}
pub(super) fn untrust_local_ca(host: &dyn Host, roots: &Roots) -> Result<Outcome> {
let keychain = roots.login_keychain();
let found = host.run(
"security",
&[
OsStr::new("find-certificate"),
OsStr::new("-c"),
OsStr::new(LOCAL_CA_COMMON_NAME),
keychain.as_os_str(),
],
)?;
if !found.status.success() {
return Ok(skipped("not trusted"));
}
let ca = roots.local_ca();
if ca.is_file() {
run_checked(
host,
"security",
&[OsStr::new("remove-trusted-cert"), ca.as_os_str()],
)?;
}
run_checked(
host,
"security",
&[
OsStr::new("delete-certificate"),
OsStr::new("-c"),
OsStr::new(LOCAL_CA_COMMON_NAME),
keychain.as_os_str(),
],
)?;
Ok(Outcome::Done)
}
pub(super) fn delete_preferences(host: &dyn Host, roots: &Roots) -> Result<Outcome> {
let plist = roots.preferences();
if !plist.exists() {
return Ok(skipped("absent"));
}
run_checked(
host,
"defaults",
&[
OsStr::new("delete"),
OsStr::new(roots.profile.app_bundle_id()),
],
)?;
remove_path(host, &plist)?;
Ok(Outcome::Done)
}