use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use clap::Args;
use microsandbox::config;
use crate::ui;
pub(super) const MARKER: &str = "# generated by msb install";
#[derive(Debug, Args)]
pub struct InstallArgs {
#[arg(required_unless_present = "list")]
pub image: Option<String>,
#[arg(short, long)]
pub name: Option<String>,
#[arg(short = 'c', long)]
pub cpus: Option<u8>,
#[arg(short, long)]
pub memory: Option<String>,
#[arg(short, long)]
pub volume: Vec<String>,
#[arg(short, long)]
pub workdir: Option<String>,
#[arg(long)]
pub shell: Option<String>,
#[arg(short, long)]
pub env: Vec<String>,
#[arg(short, long)]
pub force: bool,
#[arg(long)]
pub no_pull: bool,
#[arg(long)]
pub tmp: bool,
#[arg(short, long)]
pub list: bool,
#[arg(last = true)]
pub command: Vec<String>,
}
pub async fn run(args: InstallArgs) -> anyhow::Result<()> {
let bin_dir = resolve_bin_dir();
if args.list {
return list_aliases(&bin_dir);
}
let image = args.image.as_deref().unwrap();
let no_pull = args.no_pull;
let alias_name = args.name.as_deref().unwrap_or_else(|| derive_name(image));
validate_alias_name(alias_name)?;
let alias_path = bin_dir.join(alias_name);
if alias_path.exists() && !args.force {
anyhow::bail!("alias '{alias_name}' already exists (use --force to overwrite)");
}
if !no_pull {
super::image::pull_if_missing(image, false).await?;
}
fs::create_dir_all(&bin_dir)?;
let script = build_script(image, alias_name, &args);
fs::write(&alias_path, &script)?;
fs::set_permissions(&alias_path, fs::Permissions::from_mode(0o755))?;
ui::success("Installed", alias_name);
if !is_in_path(&bin_dir) {
eprintln!(
" Add to your shell profile:\n export PATH=\"{}:$PATH\"",
bin_dir.display()
);
}
Ok(())
}
fn resolve_bin_dir() -> PathBuf {
config::config().home().join("bin")
}
fn validate_alias_name(name: &str) -> anyhow::Result<()> {
if name.is_empty() {
anyhow::bail!("alias name cannot be empty");
}
if name.contains('/') || name.contains("..") {
anyhow::bail!("alias name must not contain '/' or '..'");
}
const RESERVED: &[&str] = &["msb", "agentd"];
if RESERVED.contains(&name) {
anyhow::bail!("alias name '{name}' would shadow a microsandbox binary");
}
Ok(())
}
fn derive_name(image: &str) -> &str {
let without_digest = image.split('@').next().unwrap_or(image);
let without_tag = without_digest.split(':').next().unwrap_or(without_digest);
without_tag.rsplit('/').next().unwrap_or(without_tag)
}
fn shell_quote(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
if s.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '=' | '+'))
{
s.to_string()
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}
fn sanitize_comment(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
fn build_script(image: &str, alias_name: &str, args: &InstallArgs) -> String {
if args.tmp {
build_tmp_script(image, args)
} else {
build_persisted_script(image, alias_name, args)
}
}
fn build_tmp_script(image: &str, args: &InstallArgs) -> String {
let mut parts = vec!["exec".to_string(), "msb".to_string(), "run".to_string()];
parts.push(shell_quote(image));
append_resource_options(&mut parts, args);
if !args.command.is_empty() {
parts.push("--".into());
for c in &args.command {
parts.push(shell_quote(c));
}
}
let mut script = format!("#!/bin/sh\n{MARKER}\n");
script.push_str(&format!("# image: {}\n", sanitize_comment(image)));
script.push_str("# mode: tmp\n");
if !args.command.is_empty() {
script.push_str(&format!(
"# command: {}\n",
sanitize_comment(&args.command.join(" "))
));
}
script.push_str(&parts.join(" "));
script.push('\n');
script
}
fn build_persisted_script(image: &str, sandbox_name: &str, args: &InstallArgs) -> String {
let mut parts = vec![
"exec".to_string(),
"msb".to_string(),
"run".to_string(),
"-n".to_string(),
shell_quote(sandbox_name),
shell_quote(image),
];
append_resource_options(&mut parts, args);
if !args.command.is_empty() {
parts.push("--".into());
for c in &args.command {
parts.push(shell_quote(c));
}
}
let mut script = format!("#!/bin/sh\n{MARKER}\n");
script.push_str(&format!("# image: {}\n", sanitize_comment(image)));
script.push_str("# mode: persisted\n");
if !args.command.is_empty() {
script.push_str(&format!(
"# command: {}\n",
sanitize_comment(&args.command.join(" "))
));
}
script.push_str(&parts.join(" "));
script.push('\n');
script
}
fn append_resource_options(parts: &mut Vec<String>, args: &InstallArgs) {
if let Some(cpus) = args.cpus {
parts.push("-c".into());
parts.push(cpus.to_string());
}
if let Some(ref mem) = args.memory {
parts.push("-m".into());
parts.push(shell_quote(mem));
}
for vol in &args.volume {
parts.push("-v".into());
parts.push(shell_quote(vol));
}
if let Some(ref workdir) = args.workdir {
parts.push("-w".into());
parts.push(shell_quote(workdir));
}
if let Some(ref shell) = args.shell {
parts.push("--shell".into());
parts.push(shell_quote(shell));
}
for env_str in &args.env {
parts.push("-e".into());
parts.push(shell_quote(env_str));
}
}
fn list_aliases(bin_dir: &Path) -> anyhow::Result<()> {
let mut table = ui::Table::new(&["NAME", "IMAGE", "MODE", "COMMAND"]);
if bin_dir.is_dir() {
let mut entries: Vec<_> = fs::read_dir(bin_dir)?.filter_map(|e| e.ok()).collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
if !path.is_file() {
continue;
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
if content.lines().nth(1) != Some(MARKER) {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let image = content
.lines()
.find_map(|l| l.strip_prefix("# image: "))
.unwrap_or("-")
.to_string();
let mode = content
.lines()
.find_map(|l| l.strip_prefix("# mode: "))
.unwrap_or("-")
.to_string();
let command = content
.lines()
.find_map(|l| l.strip_prefix("# command: "))
.unwrap_or("")
.to_string();
table.add_row(vec![name, image, mode, command]);
}
}
table.print();
Ok(())
}
fn is_in_path(dir: &Path) -> bool {
std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).any(|p| p == dir))
.unwrap_or(false)
}