use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use archon_aur::AurClient;
use archon_distro::Distro;
use archon_monitor::{BuildReport, EventStore, FlaggedEvent, Monitor, Policy};
use archon_repo::OfficialRepos;
use archon_resolver::{PlannedPackage, ResolveError, Resolver};
use archon_sandbox::{BuildStage, SandboxRunner};
use archon_types::PackageProvider;
use super::subprocess::{self, OutputCapture};
use crate::spinner::Spinner;
use crate::{color, srcinfo, ui};
const SYNC_DIR: &str = "/var/lib/pacman/sync";
const LOCAL_DIR: &str = "/var/lib/pacman/local";
pub(crate) fn sync_dir() -> String {
std::env::var("ARCHON_SYNC_DIR").unwrap_or_else(|_| SYNC_DIR.to_string())
}
pub(crate) fn aur_url() -> String {
std::env::var("ARCHON_AUR_URL").unwrap_or_else(|_| archon_aur::DEFAULT_AUR_URL.to_string())
}
pub(crate) fn local_dir() -> String {
std::env::var("ARCHON_LOCAL_DIR").unwrap_or_else(|_| LOCAL_DIR.to_string())
}
pub(crate) fn cache_dir() -> PathBuf {
if let Ok(dir) = std::env::var("ARCHON_CACHE_DIR") {
return PathBuf::from(dir);
}
std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
.map(|base| base.join("archon"))
.unwrap_or_else(|| PathBuf::from("/tmp/archon-cache"))
}
fn load_local_db() -> Option<archon_repo::LocalDb> {
archon_repo::LocalDb::load_cached(Path::new(&local_dir()), &cache_dir().join("local-db.json")).ok()
}
fn run_foreign_distro(distro: &Distro, packages: &[String], dry_run: bool, yes: bool, report: bool) -> Result<()> {
if report {
println!(
"{}",
color::yellow(
"note: --report (sandboxed, eBPF-monitored builds) needs a PKGBUILD-equivalent \
build recipe, which only Arch/AUR has today — installing normally instead."
)
);
}
let Some(backend) = distro.backend() else {
bail!(
"no supported package manager detected for {distro}. archon currently supports \
Arch (pacman + AUR), Debian/Ubuntu (apt), Fedora/RHEL (dnf), openSUSE (zypper), \
Alpine (apk), NixOS (nix-env), Void (xbps), and Gentoo (emerge) — if you'd like \
{distro} supported, a backend is just an `archon_distro::Backend` impl (see \
crates/archon-distro/src/backend.rs)."
);
};
let argv = backend.install_argv(packages, yes);
println!(
"{}",
color::dim(&format!(
"Detected {distro} — delegating to {} (its own dependency resolution and confirmation apply, not archon's)",
backend.name()
))
);
run_native_tool(backend.as_ref(), &argv, dry_run)
}
pub(crate) fn run_native_tool(backend: &dyn archon_distro::Backend, argv: &[String], dry_run: bool) -> Result<()> {
let display = if backend.needs_root() {
format!("sudo {}", argv.join(" "))
} else {
argv.join(" ")
};
println!("{}", color::dim(&display));
if dry_run {
return Ok(());
}
let mut cmd = if backend.needs_root() {
let mut c = Command::new("sudo");
c.args(argv);
c
} else {
let mut c = Command::new(&argv[0]);
c.args(&argv[1..]);
c
};
let status = cmd
.status()
.with_context(|| format!("failed to execute {} (is it installed and in PATH?)", backend.name()))?;
if !status.success() {
bail!("{} exited with {status}", backend.name());
}
Ok(())
}
pub fn run(packages: &[String], dry_run: bool, yes: bool, report: bool) -> Result<()> {
let distro = Distro::detect();
if !distro.is_arch() {
return run_foreign_distro(&distro, packages, dry_run, yes, report);
}
let spinner = Spinner::start(format!("Resolving {}", packages.join(", ")));
let sync_dir = sync_dir();
let repo_cache = cache_dir().join("sync-db.json");
let official = match OfficialRepos::load_cached(Path::new(&sync_dir), archon_repo::DEFAULT_REPO_ORDER, &repo_cache)
.with_context(|| {
format!(
"failed to load official repo databases from {sync_dir} \
(run `sudo pacman -Sy` if you've never synced)"
)
}) {
Ok(v) => v,
Err(e) => {
spinner.fail("resolution failed");
return Err(e);
}
};
let aur = AurClient::new(aur_url())?;
let local_db = load_local_db();
let installed_names: std::collections::HashSet<String> = local_db
.as_ref()
.map(|db| db.names().map(str::to_string).collect())
.unwrap_or_default();
let providers: Vec<&dyn PackageProvider> = vec![&official, &aur];
let resolve_result = Resolver::new(providers).prefer_installed(&installed_names).resolve(packages);
let plan = match resolve_result {
Ok(v) => v,
Err(ResolveError::NotFound { name, required_by }) => {
spinner.fail("resolution failed");
return Err(not_found_with_suggestions(&official, &aur, &name, &required_by));
}
Err(e) => {
spinner.fail("resolution failed");
return Err(anyhow::Error::new(e).context("dependency resolution failed"));
}
};
if plan.is_empty() {
spinner.success("nothing to do — already resolved");
return Ok(());
}
spinner.success(format!("resolved {} package(s)", plan.package_count()));
ui::print_plan(&plan, local_db.as_ref());
if dry_run {
return Ok(());
}
if report {
require_monitoring_privileges()?;
}
if !yes && !ui::confirm("\nProceed with installation?")? {
println!("aborted — nothing was changed.");
return Ok(());
}
if !plan.official.is_empty() {
install_official(&plan.official, local_db.as_ref())?;
}
if !plan.aur.is_empty() {
if report {
build_aur_packages_monitored(&aur, &plan.aur)?;
} else {
build_aur_packages(&aur, &plan.aur)?;
}
}
Ok(())
}
fn not_found_with_suggestions(official: &OfficialRepos, aur: &AurClient, name: &str, required_by: &str) -> anyhow::Error {
let hits = crate::search::find(official, aur, name);
let mut msg = format!("package '{name}' not found in official repos or the AUR (required by {required_by})");
if !hits.is_empty() {
msg.push_str("\n\nDid you mean:\n");
for hit in hits.iter().take(8) {
msg.push_str(&format!(" [{}] {}", hit.source, hit.name));
if let Some(desc) = &hit.description {
msg.push_str(&format!(" — {desc}"));
}
msg.push('\n');
}
msg.push_str(&format!("\nRun `archon search {name}` to see all matches."));
} else {
msg.push_str(&format!("\n\nNo similarly-named packages found either — try `archon search {name}`."));
}
anyhow::anyhow!(msg)
}
fn install_official(pkgs: &[PlannedPackage], local: Option<&archon_repo::LocalDb>) -> Result<()> {
let names: Vec<&str> = pkgs
.iter()
.filter(|p| !local.is_some_and(|db| db.satisfies(&p.meta.name, &p.meta.version)))
.map(|p| p.meta.name.as_str())
.collect();
if names.is_empty() {
println!("{}", color::dim("all official packages already up to date"));
return Ok(());
}
println!("{}", color::dim(&format!("pacman -S --needed {}", names.join(" "))));
let status = Command::new("sudo")
.arg("pacman")
.arg("-S")
.arg("--needed")
.args(&names)
.status()
.context("failed to execute pacman (is it installed and in PATH?)")?;
if !status.success() {
bail!("pacman exited with {status}");
}
Ok(())
}
fn ensure_bwrap() -> Result<()> {
if !SandboxRunner::bwrap_available() {
bail!(
"bubblewrap (bwrap) is not installed — required to build AUR packages safely.\n\
Install it with: sudo pacman -S bubblewrap"
);
}
Ok(())
}
fn clone_if_needed(aur: &AurClient, pkg: &PlannedPackage, build_root: &Path) -> Result<PathBuf> {
let dest = build_root.join(&pkg.meta.name);
if dest.exists() {
println!("{} {} {}", color::yellow("↺"), pkg.meta.name, color::dim("already cloned"));
return Ok(dest);
}
let url = aur.clone_url_for(&pkg.meta.name).with_context(|| {
format!(
"no clone URL cached for {} (internal error: resolver should have queried it)",
pkg.meta.name
)
})?;
let spinner = Spinner::start(format!("Cloning {}", pkg.meta.name));
let child = Command::new("git")
.arg("clone")
.arg("--quiet")
.arg(&url)
.arg(&dest)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("failed to execute git (is it installed and in PATH?)")?;
let (status, output) = subprocess::wait_capturing(child)?;
if !status.success() {
spinner.fail(format!("clone failed: {}", pkg.meta.name));
subprocess::print_if_nonempty(&output);
bail!("git clone failed for {}", pkg.meta.name);
}
spinner.success(format!("cloned {}", pkg.meta.name));
Ok(dest)
}
pub(crate) fn build_in_sandbox(runner: &SandboxRunner, pkg_dir: &Path, pkg_name: &str) -> Result<()> {
crate::pgp::import_build_keys(pkg_dir);
for stage in [BuildStage::Fetch, BuildStage::Build] {
let label = match stage {
BuildStage::Fetch => format!("Fetching {pkg_name}"),
BuildStage::Build => format!("Building {pkg_name}"),
};
let spinner = Spinner::start(label);
let mut child = runner
.spawn_stage(stage)
.with_context(|| format!("failed to start {stage:?} stage for {pkg_name}"))?;
let capture = OutputCapture::start(&mut child);
let status = child.wait().context("failed to wait on sandboxed build")?;
let output = capture.finish();
if !status.success() {
spinner.fail(format!("{pkg_name} failed ({stage:?})"));
subprocess::print_if_nonempty(&output);
bail!("sandboxed build failed for {pkg_name} ({stage:?} stage)");
}
spinner.success(format!("{pkg_name} ({stage:?})"));
}
println!("{} {} built", color::bold_green("✓"), pkg_name);
Ok(())
}
fn build_aur_packages(aur: &AurClient, pkgs: &[PlannedPackage]) -> Result<()> {
ensure_bwrap()?;
let build_root = build_dir()?;
std::fs::create_dir_all(&build_root)
.with_context(|| format!("failed to create build dir {}", build_root.display()))?;
println!();
let mut built = Vec::new();
for pkg in pkgs {
let dest = clone_if_needed(aur, pkg, &build_root)?;
let runner = SandboxRunner::new(&dest).capture_output(true);
build_in_sandbox(&runner, &dest, &pkg.meta.name)?;
built.push(dest);
}
println!("\nBuilt {} AUR package(s):", built.len());
for dest in &built {
println!(" {}", dest.display());
}
println!(
"\nNot installed. Re-run with `--report` to have archon monitor the build with \
eBPF and offer to install once it confirms nothing was flagged."
);
Ok(())
}
pub(crate) struct Privileges {
pub uid: u32,
pub gid: u32,
}
pub(crate) fn require_monitoring_privileges() -> Result<Privileges> {
if unsafe { libc::geteuid() } != 0 {
bail!(
"--report needs root to load the eBPF monitor. Re-run as:\n\
\n sudo archon install {} --report\n\
\nThe build itself still runs as your normal user — archon drops privileges \
for the sandboxed build automatically.",
"<packages>"
);
}
let uid: u32 = std::env::var("SUDO_UID")
.ok()
.and_then(|s| s.parse().ok())
.context(
"running as root but $SUDO_UID is unset — invoke via `sudo`, not a direct root \
login, so archon knows which user to drop back to for the build",
)?;
let gid: u32 = std::env::var("SUDO_GID")
.ok()
.and_then(|s| s.parse().ok())
.context("running as root but $SUDO_GID is unset (see $SUDO_UID note above)")?;
Ok(Privileges { uid, gid })
}
fn build_aur_packages_monitored(aur: &AurClient, pkgs: &[PlannedPackage]) -> Result<()> {
ensure_bwrap()?;
let privileges = require_monitoring_privileges()?;
let mut monitor = load_monitor()?;
let user_home = home_dir_for_uid(privileges.uid)?;
let build_root = build_dir_under(&user_home);
std::fs::create_dir_all(&build_root)
.with_context(|| format!("failed to create build dir {}", build_root.display()))?;
if let Some(cache_root) = build_root.parent() {
ensure_owned(cache_root, privileges.uid, privileges.gid)
.with_context(|| format!("failed to chown {} to the target user", cache_root.display()))?;
}
ensure_owned(&build_root, privileges.uid, privileges.gid)
.with_context(|| format!("failed to chown {} to the target user", build_root.display()))?;
let store = EventStore::open(&build_root.join("monitor.db")).context("failed to open event store")?;
for pkg in pkgs {
let dest = clone_if_needed(aur, pkg, &build_root)?;
ensure_owned_recursive(&dest, privileges.uid, privileges.gid)
.with_context(|| format!("failed to chown {} to the target user", dest.display()))?;
let report = run_monitored_build(&mut monitor, &store, &dest, &pkg.meta.name, &privileges)?;
ui::print_report(&report);
if !report.is_clean() {
println!("{} not installed — see flags above", color::red(&pkg.meta.name));
continue;
}
let pkg_files = find_package_files(&dest, &pkg.meta.name)?;
if pkg_files.is_empty() {
println!("{} built clean but no package file was found", color::yellow(&pkg.meta.name));
continue;
}
if !ui::confirm(&format!("Install {} now via pacman -U?", pkg.meta.name))? {
println!("skipped — package left at {}", dest.display());
continue;
}
println!("==> pacman -U {}", pkg_files.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(" "));
let status = Command::new("pacman")
.arg("-U")
.args(&pkg_files)
.status()
.context("failed to execute pacman -U")?;
if !status.success() {
bail!("pacman -U exited with {status} for {}", pkg.meta.name);
}
}
Ok(())
}
struct BuildCgroup {
path: PathBuf,
}
const CGROUP_ROOT: &str = "/sys/fs/cgroup/archon";
impl BuildCgroup {
fn create(pkg_name: &str) -> Result<Self> {
std::fs::create_dir_all(CGROUP_ROOT)
.context("failed to create /sys/fs/cgroup/archon (cgroups v2 required)")?;
let path = PathBuf::from(CGROUP_ROOT).join(format!("build-{pkg_name}-{}", std::process::id()));
std::fs::create_dir(&path)
.with_context(|| format!("failed to create cgroup {}", path.display()))?;
Ok(Self { path })
}
fn id(&self) -> Result<u64> {
use std::os::unix::fs::MetadataExt;
Ok(std::fs::metadata(&self.path)?.ino())
}
}
impl Drop for BuildCgroup {
fn drop(&mut self) {
let _ = std::fs::remove_dir(&self.path);
}
}
pub(crate) fn run_monitored_build(
monitor: &mut Monitor,
store: &EventStore,
dest: &Path,
pkg_name: &str,
privileges: &Privileges,
) -> Result<BuildReport> {
crate::pgp::import_build_keys(dest);
let gnupg_home = dest.join(".gnupg");
if gnupg_home.exists() {
ensure_owned_recursive(&gnupg_home, privileges.uid, privileges.gid)
.with_context(|| format!("failed to chown {} to the target user", gnupg_home.display()))?;
}
let allowed_ips = srcinfo::resolve_allowed_ips(dest);
let policy = Policy::new(dest, allowed_ips);
let build_id = store
.start_build(pkg_name, unix_now())
.context("failed to record build start")?;
let cgroup = BuildCgroup::create(pkg_name)?;
monitor
.set_target_cgroup(cgroup.id()?)
.context("failed to point the monitor at the build's cgroup")?;
let runner = SandboxRunner::new(dest)
.run_as(privileges.uid, privileges.gid)
.join_cgroup(&cgroup.path)
.capture_output(true);
let mut total_events = 0usize;
let mut flagged = Vec::new();
for stage in [BuildStage::Fetch, BuildStage::Build] {
let label = match stage {
BuildStage::Fetch => format!("Fetching {pkg_name}"),
BuildStage::Build => format!("Building {pkg_name} (watched)"),
};
let spinner = Spinner::start(label);
let mut child = runner
.spawn_stage(stage)
.with_context(|| format!("failed to start {stage:?} stage for {pkg_name}"))?;
let capture = OutputCapture::start(&mut child);
let status = loop {
if let Some(status) = child.try_wait().context("failed to poll build process")? {
break status;
}
drain_and_record(monitor, store, &policy, build_id, &mut total_events, &mut flagged)?;
monitor.flush_logs();
std::thread::sleep(Duration::from_millis(20));
};
std::thread::sleep(Duration::from_millis(100));
drain_and_record(monitor, store, &policy, build_id, &mut total_events, &mut flagged)?;
let output = capture.finish();
if !status.success() {
spinner.fail(format!("{pkg_name} failed ({stage:?})"));
subprocess::print_if_nonempty(&output);
bail!("{stage:?} stage failed for {pkg_name}: makepkg exited with {status}");
}
spinner.success(format!("{pkg_name} ({stage:?}) — {total_events} events so far"));
}
Ok(BuildReport { package: pkg_name.to_string(), total_events, flagged })
}
fn drain_and_record(
monitor: &mut Monitor,
store: &EventStore,
policy: &Policy,
build_id: i64,
total_events: &mut usize,
flagged: &mut Vec<FlaggedEvent>,
) -> Result<()> {
for event in monitor.drain_events().context("failed to read monitor events")? {
*total_events += 1;
let reason = policy.evaluate(&event);
store
.record_event(build_id, &event, reason.as_ref())
.context("failed to record event")?;
if let Some(reason) = reason {
flagged.push(FlaggedEvent { event, reason });
}
}
Ok(())
}
pub(crate) fn find_package_files(dest: &Path, pkg_name: &str) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in std::fs::read_dir(dest).with_context(|| format!("failed to read {}", dest.display()))? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(&format!("{pkg_name}-")) && name.contains(".pkg.tar") && !name.contains("-debug-") {
files.push(entry.path());
}
}
Ok(files)
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn build_dir() -> Result<PathBuf> {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
.context("could not determine cache directory (set XDG_CACHE_HOME or HOME)")?;
Ok(base.join("archon").join("build"))
}
pub(crate) fn build_dir_under(home: &Path) -> PathBuf {
home.join(".cache").join("archon").join("build")
}
pub(crate) fn load_monitor() -> Result<Monitor> {
let bytes = archon_monitor::loader::resolve_ebpf_bytes()
.context("failed to read $ARCHON_EBPF_OBJECT (unset it to use the built-in monitor)")?;
let spinner = Spinner::start("Loading eBPF monitor");
let monitor = Monitor::load_and_attach(&bytes).context("failed to load eBPF monitor");
match &monitor {
Ok(_) => spinner.success("eBPF monitor attached"),
Err(_) => spinner.fail("eBPF monitor failed to load"),
}
monitor
}
pub(crate) fn home_dir_for_uid(uid: u32) -> Result<PathBuf> {
let output = Command::new("getent")
.arg("passwd")
.arg(uid.to_string())
.output()
.context("failed to execute getent (needed to resolve the invoking user's home directory)")?;
if !output.status.success() {
bail!("getent passwd {uid} found no matching user — cannot resolve a home directory");
}
let line = String::from_utf8_lossy(&output.stdout);
let home = line
.trim()
.split(':')
.nth(5)
.filter(|s| !s.is_empty())
.with_context(|| format!("unexpected `getent passwd {uid}` output: {line:?}"))?;
Ok(PathBuf::from(home))
}
pub(crate) fn ensure_owned(path: &Path, uid: u32, gid: u32) -> Result<()> {
ensure_owned_impl(path, uid, gid, false)
}
pub(crate) fn ensure_owned_recursive(path: &Path, uid: u32, gid: u32) -> Result<()> {
ensure_owned_impl(path, uid, gid, true)
}
fn ensure_owned_impl(path: &Path, uid: u32, gid: u32, recursive: bool) -> Result<()> {
let mut cmd = Command::new("chown");
if recursive {
cmd.arg("-R");
}
let status = cmd
.arg(format!("{uid}:{gid}"))
.arg(path)
.status()
.context("failed to execute chown")?;
if !status.success() {
bail!("chown exited with {status}");
}
Ok(())
}