use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use arcbox_constants::paths::{
ArcboxProfile, DOCKER_CLI_TOOLS, HostLayout, guest, is_arcbox_owned, labels, privileged,
privileged_log,
};
use arcbox_helper::validate::SocketTarget;
const RESOLVER_MARKER: &str = "# managed by arcbox";
pub(super) const HOSTS_MARKER: &str = "# managed by arcbox-helper";
pub(super) struct Roots {
pub(super) profile: ArcboxProfile,
pub(super) home: PathBuf,
pub(super) data_dir: PathBuf,
pub(super) docker_config: PathBuf,
pub(super) docker_context: String,
pub(super) system: PathBuf,
}
impl Roots {
pub(super) fn from_env() -> Result<Self> {
let profile = ArcboxProfile::from_env_or_default();
let docker_context = std::env::var(arcbox_constants::env::DOCKER_CONTEXT)
.ok()
.filter(|context| !context.is_empty())
.unwrap_or_else(|| profile.docker_context_name().to_owned());
Ok(Self {
profile,
home: dirs::home_dir().context("cannot determine home directory")?,
data_dir: HostLayout::from_env_or_default().data_dir,
docker_config: crate::commands::cli_plugins::default_docker_config_dir()?,
docker_context,
system: PathBuf::from("/"),
})
}
pub(super) fn system_path(&self, absolute: &str) -> PathBuf {
self.system.join(absolute.trim_start_matches('/'))
}
pub(super) fn launch_agent(&self, label: &str) -> PathBuf {
self.home
.join("Library/LaunchAgents")
.join(format!("{label}.plist"))
}
pub(super) fn app_bundle(&self) -> PathBuf {
self.system_path("/Applications")
.join(format!("{}.app", self.profile.app_name()))
}
pub(super) fn hosts(&self) -> PathBuf {
self.system_path("/etc/hosts")
}
pub(super) fn data_export_mount(&self) -> PathBuf {
self.home.join("ArcBox")
}
pub(super) fn login_keychain(&self) -> PathBuf {
self.home.join("Library/Keychains/login.keychain-db")
}
pub(super) fn local_ca(&self) -> PathBuf {
self.data_dir.join(guest::TLS).join(guest::TLS_CA_CERT)
}
pub(super) fn preferences(&self) -> PathBuf {
self.home
.join("Library/Preferences")
.join(format!("{}.plist", self.profile.app_bundle_id()))
}
pub(super) fn homebrew_manages_the_app(&self) -> bool {
["/opt/homebrew/Caskroom", "/usr/local/Caskroom"]
.into_iter()
.map(|caskroom| self.system_path(caskroom))
.any(|caskroom| {
["arcbox", "arcbox@latest"]
.into_iter()
.any(|cask| caskroom.join(cask).is_dir())
})
}
}
pub(super) struct Found {
pub(super) what: &'static str,
pub(super) path: PathBuf,
pub(super) needs_root: bool,
}
pub(super) struct Residue {
pub(super) files: Vec<Found>,
pub(super) hosts_alias: bool,
pub(super) helper_registered: bool,
pub(super) preferences: bool,
pub(super) app_left_to_homebrew: bool,
}
impl Residue {
pub(super) fn needs_root(&self) -> bool {
self.hosts_alias || self.helper_registered || self.files.iter().any(|f| f.needs_root)
}
}
pub(super) fn scan(roots: &Roots, keep_data: bool) -> Residue {
let mut files = Vec::new();
let mut found = |what, path: PathBuf, needs_root| {
if path.symlink_metadata().is_ok() {
files.push(Found {
what,
path,
needs_root,
});
}
};
found(
"helper binary",
roots.system_path(privileged::HELPER_BINARY),
true,
);
found(
"helper LaunchDaemon",
roots.system_path(privileged::HELPER_PLIST),
true,
);
found(
"helper socket",
roots.system_path(privileged::HELPER_SOCKET),
true,
);
found(
"helper log directory",
roots.system_path(privileged_log::HELPER_LOG_DIR),
true,
);
let bin = roots.system_path("/usr/local/bin");
for name in ["abctl", "arcbox-daemon"] {
found("command-line binary", bin.join(name), true);
}
let resolver = roots.system_path(&format!(
"/etc/resolver/{}",
arcbox_constants::dns::LOCAL_DOMAIN
));
if is_managed_resolver(&resolver) {
found("DNS resolver", resolver, true);
}
let docker_socket = roots.system_path(privileged::DOCKER_SOCKET);
if is_arcbox_socket_link(&docker_socket) {
found("Docker socket link", docker_socket, true);
}
for name in DOCKER_CLI_TOOLS {
let link = bin.join(name);
if std::fs::read_link(&link).is_ok_and(|target| is_arcbox_owned(&target)) {
found("Docker CLI link", link, true);
}
}
let hosts_alias = std::fs::read_to_string(roots.hosts())
.is_ok_and(|content| content.lines().any(|line| line.contains(HOSTS_MARKER)));
let helper_registered = roots.system_path(privileged::HELPER_PLIST).exists();
for label in [roots.profile.daemon_label(), labels::LEGACY_SCRIPT_DAEMON] {
found("daemon LaunchAgent", roots.launch_agent(label), false);
}
let library = roots.home.join("Library");
let bundle_id = roots.profile.app_bundle_id();
for (what, path) in [
(
"Desktop app support",
library.join("Application Support").join(bundle_id),
),
("Desktop app cache", library.join("Caches").join(bundle_id)),
(
"Desktop app HTTP storage",
library.join("HTTPStorages").join(bundle_id),
),
(
"Desktop app window state",
library
.join("Saved Application State")
.join(format!("{bundle_id}.savedState")),
),
("log directory", library.join("Logs").join("arcbox")),
] {
found(what, path, false);
}
let preferences = roots.preferences().exists();
if keep_data {
if let Ok(entries) = std::fs::read_dir(&roots.data_dir) {
for entry in entries.flatten() {
if entry.file_name() != "data" {
found("data directory entry", entry.path(), false);
}
}
}
} else {
found("data directory", roots.data_dir.clone(), false);
}
let app = roots.app_bundle();
let app_left_to_homebrew = app.exists() && roots.homebrew_manages_the_app();
if !app_left_to_homebrew {
found("app bundle", app, false);
}
Residue {
files,
hosts_alias,
helper_registered,
preferences,
app_left_to_homebrew,
}
}
fn is_managed_resolver(path: &Path) -> bool {
std::fs::read_to_string(path).is_ok_and(|content| {
content
.lines()
.any(|line| line.trim_start().starts_with(RESOLVER_MARKER))
})
}
fn is_arcbox_socket_link(path: &Path) -> bool {
std::fs::read_link(path).is_ok_and(|target| {
target
.to_str()
.is_some_and(|target| target.parse::<SocketTarget>().is_ok())
})
}