bob 0.99.6

Fast, robust, powerful, user-friendly pkgsrc package builder
Documentation
/*
 * Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
 *
 * Permission to use, copy, modify, and distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

use std::fmt::Write as _;
use std::fs;
use std::process::{Command, Stdio};
use std::time::Instant;

use anyhow::{Context, Result, bail};
use clap::{ArgGroup, Args, Subcommand};

use bob::config::{Config, Pkgsrc, PkgsrcEnv};
use bob::logging;
use bob::sandbox::Sandbox;

#[derive(Debug, Subcommand)]
pub enum SandboxCmd {
    /// Create one or more sandboxes
    Create(CreateArgs),
    /// Destroy sandboxes
    Destroy(DestroyArgs),
    /// Create a sandbox and start an interactive shell
    Exec,
    /// List currently created sandboxes
    List,
}

#[derive(Debug, Args)]
pub struct CreateArgs {
    /// Number of sandboxes to create.
    #[arg(short = 'n', long, value_name = "N", default_value_t = 1)]
    pub count: usize,
}

#[derive(Debug, Args)]
#[command(group = ArgGroup::new("target").required(true).multiple(false))]
pub struct DestroyArgs {
    /// Destroy every discovered sandbox.
    #[arg(short = 'a', long, group = "target")]
    pub all: bool,
    /// Sandbox IDs to destroy.  IDs that are not currently allocated
    /// are silently skipped.
    #[arg(value_name = "ID", group = "target", num_args = 1..)]
    pub ids: Vec<usize>,
}

pub fn run(config: &Config, pkgsrc: Option<&Pkgsrc>, cmd: SandboxCmd) -> Result<()> {
    match cmd {
        SandboxCmd::Create(args) => {
            logging::init_stderr_if_enabled();
            if args.count == 0 {
                bail!("--count must be at least 1");
            }
            let sandbox = Sandbox::new(config, pkgsrc);
            if !sandbox.enabled() {
                bail!("No sandboxes configured");
            }
            let ids = sandbox.create_all(args.count)?;
            for id in ids {
                if !bob::try_println(&format!("{}", sandbox.path(id).display())) {
                    break;
                }
            }
        }
        SandboxCmd::Destroy(args) => {
            logging::init_stderr_if_enabled();
            let sandbox = Sandbox::new(config, pkgsrc);
            if !sandbox.enabled() {
                bail!("No sandboxes configured");
            }
            if pkgsrc.is_some() {
                match bob::Database::open(config.dbdir()).and_then(|db| db.load_pkgsrc_env()) {
                    Ok(env) => sandbox.set_pkgsrc_env(env),
                    Err(_) => eprintln!(
                        "Warning: No database available, unable to remove pkgsrc directories."
                    ),
                }
            }
            if args.all {
                sandbox.destroy_all()?;
            } else {
                sandbox.destroy_ids(&args.ids)?;
            }
        }
        SandboxCmd::Exec => {
            logging::init(config.dbdir(), config.log_level())?;
            exec(config, pkgsrc)?;
        }
        SandboxCmd::List => {
            let sandbox = Sandbox::new(config, pkgsrc);
            if !sandbox.enabled() {
                bail!("No sandboxes configured");
            }
            sandbox.list_all()?;
        }
    }
    Ok(())
}

fn exec(config: &Config, pkgsrc: Option<&Pkgsrc>) -> Result<()> {
    let sandbox = Sandbox::new_dev(config, pkgsrc);
    if !sandbox.enabled() {
        bail!("No sandboxes configured");
    }
    bob::print_status("Creating sandbox");
    let start = Instant::now();
    let id = match sandbox.claim_id() {
        Ok(id) => id,
        Err(e) => {
            bob::print_failed("Creating sandbox", start.elapsed());
            return Err(e);
        }
    };
    let result = (|| -> Result<()> {
        match sandbox.run_pre_build(Some(id)) {
            Ok(()) => bob::print_elapsed("Creating sandbox", start.elapsed()),
            Err(e) => {
                bob::print_failed("Creating sandbox", start.elapsed());
                return Err(e.context("pre-build failed"));
            }
        }
        if let Some(pkgsrc) = pkgsrc {
            let pkgsrc_env = PkgsrcEnv::fetch(pkgsrc, &sandbox, Some(id))?;
            sandbox.set_pkgsrc_env(pkgsrc_env);
        }
        let init_path = write_shell_init(config, &sandbox, id)?;
        println!("Entering sandbox {}...", sandbox.path(id).display());
        let mut cmd = Command::new("/usr/sbin/chroot");
        cmd.arg(sandbox.path(id)).arg("/bin/sh").arg(&init_path);
        sandbox.apply_dev_environment(&mut cmd);
        cmd.stdin(Stdio::inherit())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit());
        let status = cmd.status().context("Failed to run chroot shell")?;
        if !status.success() {
            bail!("Shell exited with {}", status);
        }
        Ok(())
    })();
    if let Err(e) = sandbox.run_post_build(Some(id)) {
        eprintln!("Warning: post-build error: {e:#}");
    }
    bob::print_status("Destroying sandbox");
    let start = Instant::now();
    sandbox.destroy(id)?;
    bob::print_elapsed("Destroying sandbox", start.elapsed());
    result
}

/**
 * Write the shell init wrapper script to `<sandbox>/.bob/shell-init`.
 *
 * The wrapper exports all `bob_*` variables (defensively double-quoted
 * by bob, since they may contain whitespace), then exports each variable
 * from the `environment.dev.vars` config table verbatim -- the user is
 * responsible for any shell quoting.  Finally it removes itself and
 * execs the configured interactive shell (`environment.dev.shell`,
 * defaulting to `/bin/sh`).  Returning the path inside the chroot lets
 * the caller invoke `chroot <path> /bin/sh /.bob/shell-init`.
 */
fn write_shell_init(config: &Config, sandbox: &Sandbox, id: usize) -> Result<String> {
    let dev_ctx = config.environment().and_then(|e| e.dev.as_ref());
    let interactive_shell = dev_ctx
        .and_then(|c| c.shell.as_ref())
        .map(|p| p.display().to_string())
        .unwrap_or_else(|| "/bin/sh".to_string());

    let mut script = String::new();
    script.push_str("#!/bin/sh\n");

    let mut bob_vars = sandbox.script_env();
    bob_vars.push(("bob_sandbox_id".to_string(), id.to_string()));
    bob_vars.sort_by(|a, b| a.0.cmp(&b.0));
    for (name, value) in &bob_vars {
        let _ = writeln!(script, "export {}={}", name, bob_dquote(value));
    }

    if let Some(ctx) = dev_ctx {
        let mut dev_vars: Vec<(&String, &String)> = ctx.vars.iter().collect();
        dev_vars.sort_by(|a, b| a.0.cmp(b.0));
        for (name, value) in dev_vars {
            let _ = writeln!(script, "export {}={}", name, value);
        }
    }

    script.push_str("rm -f /.bob/shell-init\n");
    let _ = writeln!(script, "exec {} -i", interactive_shell);

    let host_path = sandbox.path(id).join(".bob/shell-init");
    fs::write(&host_path, &script)
        .with_context(|| format!("Failed to write {}", host_path.display()))?;
    Ok("/.bob/shell-init".to_string())
}

/**
 * Wrap a `bob_*` value in POSIX shell double quotes with full escaping.
 *
 * Used only for bob's own values, not user-supplied `environment.shell`
 * entries.  These are paths and identifiers that bob constructs, so
 * `$`, backtick, `\`, and `"` are all escaped to make the assignment
 * safe regardless of any unusual characters in the path.
 */
fn bob_dquote(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        if matches!(c, '"' | '\\' | '`' | '$') {
            out.push('\\');
        }
        out.push(c);
    }
    out.push('"');
    out
}