capulus 0.5.1

Shared support for Rust command-line tools.
Documentation
use std::ffi::OsString;
use std::fs::{self, File};
use std::io::{Read, Seek};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::Stdio;

use anyhow::{Context, Result, anyhow, bail};

use super::account::{
    SupplementaryGroups, UnixAccount, ensure_owned_directory, ensure_root_directory, require_root,
};
use super::build::run_with_deadline;
use super::product::is_valid_identifier;
use super::{CargoRegistry, JobId, ManagedProduct, ResolvedRelease, UserInstallContext};

const USER_INSTALL_ROOT: &str = "/run/capulus/user-installs";

pub fn reinstall_user_cli(
    product: &ManagedProduct,
    job: JobId,
    release: &ResolvedRelease,
    context: &UserInstallContext,
) -> Result<()> {
    require_root()?;
    let account = context.revalidate(&product.user_binary().cargo_name)?;
    let root = Path::new(USER_INSTALL_ROOT).join(format!("{}-{job}", product.name()));
    let ephemeral = EphemeralCargoHome::create(&root, &account, &release.registry)?;
    let cargo_home = ephemeral.path();
    let mut command = account.command(&context.cargo, SupplementaryGroups::Initialize);
    command
        .env("CARGO_HOME", cargo_home)
        .env("RUSTUP_HOME", &context.rustup_home)
        .env("CARGO_TARGET_DIR", cargo_home.join("target"))
        .env("PATH", user_path(&context.cargo_home))
        .args(["install", "--locked", "--force", "--root"])
        .arg(&context.cargo_home)
        .args(["--version", &release.version.to_string()])
        .arg(product.package())
        .args(["--bin", &product.user_binary().cargo_name]);
    if let Some(registry) = release.registry.cargo_registry_name() {
        command.args(["--registry", registry]);
    }
    command
        .stdin(Stdio::null())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit());
    run_with_deadline(
        &mut command,
        product.build_timeout(),
        "reinstall the requesting user's Cargo CLI",
    )?;
    let account = context.revalidate(&product.user_binary().cargo_name)?;
    verify_installed_version(release, context, cargo_home, &account)?;
    Ok(())
}

fn verify_installed_version(
    release: &ResolvedRelease,
    context: &UserInstallContext,
    ephemeral_cargo_home: &Path,
    account: &UnixAccount,
) -> Result<()> {
    let output = ephemeral_cargo_home.join("installed-version.txt");
    let mut output_file = account.create_file(&output, 0o600)?;
    let mut command = account.command(&context.installed_binary, SupplementaryGroups::Initialize);
    command
        .arg("--version")
        .stdout(Stdio::from(output_file.try_clone()?))
        .stderr(Stdio::inherit());
    run_with_deadline(
        &mut command,
        std::time::Duration::from_secs(30),
        "verify the reinstalled user CLI version",
    )?;
    if read_bounded_output(&mut output_file, 4096)?
        .split_whitespace()
        .last()
        != Some(release.version.to_string().as_str())
    {
        bail!(
            "requesting user's reinstalled CLI did not report version {}",
            release.version
        );
    }
    Ok(())
}

struct EphemeralCargoHome {
    path: PathBuf,
}

impl EphemeralCargoHome {
    fn create(path: &Path, account: &UnixAccount, registry: &CargoRegistry) -> Result<Self> {
        ensure_root_directory(Path::new(USER_INSTALL_ROOT), 0o711)?;
        ensure_owned_directory(path, account, 0o700)?;
        let ephemeral = Self {
            path: path.to_path_buf(),
        };
        let ca_path = path.join("registry-ca.pem");
        let configuration = registry.configuration(&ca_path)?;
        if let Some(credentials) = configuration.credentials {
            account.write_file(
                &path.join("credentials.toml"),
                credentials.as_bytes(),
                0o600,
            )?;
            account.write_file(
                &ca_path,
                configuration
                    .ca_pem
                    .expect("private Cargo credentials always have a CA bundle")
                    .as_bytes(),
                0o600,
            )?;
        }
        account.write_file(
            &path.join("config.toml"),
            configuration.config.as_bytes(),
            0o600,
        )?;
        Ok(ephemeral)
    }

    fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for EphemeralCargoHome {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}

fn user_path(cargo_home: &Path) -> OsString {
    let mut value = cargo_home.join("bin").into_os_string();
    value.push(":/usr/local/bin:/usr/bin:/bin");
    value
}

fn read_bounded_output(file: &mut File, limit: u64) -> Result<String> {
    if file.metadata()?.len() > limit {
        bail!("installed version output exceeds the {limit}-byte safety limit");
    }
    file.rewind()?;
    let mut value = String::new();
    file.take(limit + 1).read_to_string(&mut value)?;
    if value.len() as u64 > limit {
        bail!("installed version output exceeds the {limit}-byte safety limit");
    }
    Ok(value)
}

pub(super) fn remove_orphaned_user_installations() -> Result<()> {
    let root = Path::new(USER_INSTALL_ROOT);
    ensure_root_directory(root, 0o711)?;
    for entry in fs::read_dir(root).context("failed to inspect ephemeral user installations")? {
        let entry = entry?;
        let name = entry.file_name();
        let name = name
            .to_str()
            .ok_or_else(|| anyhow!("ephemeral user installation has a non-UTF-8 name"))?;
        let Some((product, job)) = name.rsplit_once('-') else {
            bail!("ephemeral user installation has an invalid name: {name:?}");
        };
        if !is_valid_identifier(product) || JobId::parse(job).is_err() {
            bail!("ephemeral user installation has an invalid name: {name:?}");
        }
        let metadata = fs::symlink_metadata(entry.path())?;
        if !metadata.file_type().is_dir() || metadata.uid() == 0 {
            bail!("ephemeral user installation is not a user-owned real directory: {name:?}");
        }
        fs::remove_dir_all(entry.path())
            .with_context(|| format!("failed to remove orphaned user installation {name}"))?;
    }
    File::open(root)?.sync_all().map_err(Into::into)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn user_path_prefixes_the_callers_cargo_bin() {
        assert_eq!(
            user_path(Path::new("/home/example/.cargo")),
            OsString::from("/home/example/.cargo/bin:/usr/local/bin:/usr/bin:/bin")
        );
    }
}