use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use clap::Args;
use crate::ui;
use super::common::{
validate_mount_dir_spec, validate_mount_disk_spec, validate_mount_file_spec,
validate_mount_named_spec, validate_volume_spec,
};
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(long = "mount-dir", value_name = "SOURCE:DEST[:OPTIONS]")]
pub mount_dir: Vec<String>,
#[arg(long = "mount-file", value_name = "SOURCE:DEST[:OPTIONS]")]
pub mount_file: Vec<String>,
#[arg(long = "mount-disk", value_name = "SOURCE:DEST[:OPTIONS]")]
pub mount_disk: Vec<String>,
#[arg(long = "mount-named", value_name = "NAME:DEST[:OPTIONS]")]
pub mount_named: 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)?;
for volume in &args.volume {
validate_volume_spec(volume)?;
}
for mount in &args.mount_dir {
validate_mount_dir_spec(mount)?;
}
for mount in &args.mount_file {
validate_mount_file_spec(mount)?;
}
for mount in &args.mount_disk {
validate_mount_disk_spec(mount)?;
}
for mount in &args.mount_named {
validate_mount_named_spec(mount)?;
}
let alias_path = alias_path(&bin_dir, alias_name);
prepare_alias_path(&bin_dir, alias_name, args.force)?;
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)?;
#[cfg(unix)]
fs::set_permissions(&alias_path, fs::Permissions::from_mode(0o755))?;
ui::success("Installed", alias_name);
if !is_in_path(&bin_dir) {
#[cfg(windows)]
eprintln!(" Add to your user PATH:\n {}", bin_dir.display());
#[cfg(not(windows))]
eprintln!(
" Add to your shell profile:\n export PATH=\"{}:$PATH\"",
bin_dir.display()
);
}
Ok(())
}
fn resolve_bin_dir() -> PathBuf {
let backend = microsandbox::backend::default_backend();
let home = match backend.as_local() {
Some(local) => local.config().home(),
None => microsandbox_utils::resolve_home(),
};
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('\\') || 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)
}
#[cfg(not(windows))]
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('\'', "'\\''"))
}
}
#[cfg(windows)]
fn cmd_quote(s: &str) -> String {
if s.is_empty() {
return "\"\"".to_string();
}
let escaped = s.replace('%', "%%").replace('"', "\"\"");
format!("\"{escaped}\"")
}
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 {
#[cfg(windows)]
{
build_cmd_script(image, alias_name, args)
}
#[cfg(not(windows))]
{
if args.tmp {
build_tmp_script(image, args)
} else {
build_persisted_script(image, alias_name, args)
}
}
}
#[cfg(not(windows))]
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, shell_quote);
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
}
#[cfg(not(windows))]
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, shell_quote);
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
}
#[cfg(windows)]
fn build_cmd_script(image: &str, alias_name: &str, args: &InstallArgs) -> String {
let mut parts = vec!["\"%~dp0msb.exe\"".to_string(), "run".to_string()];
if !args.tmp {
parts.push("-n".to_string());
parts.push(cmd_quote(alias_name));
}
parts.push(cmd_quote(image));
append_resource_options(&mut parts, args, cmd_quote);
if !args.command.is_empty() {
parts.push("--".into());
for c in &args.command {
parts.push(cmd_quote(c));
}
}
let mut script = "@echo off\r\n".to_string();
script.push_str(&format!("rem {MARKER}\r\n"));
script.push_str(&format!("rem # image: {}\r\n", sanitize_comment(image)));
script.push_str(if args.tmp {
"rem # mode: tmp\r\n"
} else {
"rem # mode: persisted\r\n"
});
if !args.command.is_empty() {
script.push_str(&format!(
"rem # command: {}\r\n",
sanitize_comment(&args.command.join(" "))
));
}
script.push_str(&parts.join(" "));
script.push_str("\r\nexit /b %ERRORLEVEL%\r\n");
script
}
fn append_resource_options(parts: &mut Vec<String>, args: &InstallArgs, quote: fn(&str) -> String) {
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(quote(mem));
}
for vol in &args.volume {
parts.push("-v".into());
parts.push(quote(vol));
}
for mount in &args.mount_dir {
parts.push("--mount-dir".into());
parts.push(quote(mount));
}
for mount in &args.mount_file {
parts.push("--mount-file".into());
parts.push(quote(mount));
}
for mount in &args.mount_disk {
parts.push("--mount-disk".into());
parts.push(quote(mount));
}
for mount in &args.mount_named {
parts.push("--mount-named".into());
parts.push(quote(mount));
}
if let Some(ref workdir) = args.workdir {
parts.push("-w".into());
parts.push(quote(workdir));
}
if let Some(ref shell) = args.shell {
parts.push("--shell".into());
parts.push(quote(shell));
}
for env_str in &args.env {
parts.push("-e".into());
parts.push(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 !is_generated_alias(&content) {
continue;
}
let name = display_alias_name(&entry.file_name().to_string_lossy());
let image = alias_comment_value(&content, "image: ")
.unwrap_or("-")
.to_string();
let mode = alias_comment_value(&content, "mode: ")
.unwrap_or("-")
.to_string();
let command = alias_comment_value(&content, "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)
}
pub(super) fn alias_path(bin_dir: &Path, alias_name: &str) -> PathBuf {
#[cfg(windows)]
{
bin_dir.join(format!("{alias_name}.cmd"))
}
#[cfg(not(windows))]
{
bin_dir.join(alias_name)
}
}
pub(super) fn alias_candidates(bin_dir: &Path, alias_name: &str) -> Vec<PathBuf> {
#[cfg(windows)]
{
vec![
bin_dir.join(format!("{alias_name}.cmd")),
bin_dir.join(format!("{alias_name}.ps1")),
bin_dir.join(alias_name),
]
}
#[cfg(not(windows))]
{
vec![bin_dir.join(alias_name)]
}
}
fn prepare_alias_path(bin_dir: &Path, alias_name: &str, force: bool) -> anyhow::Result<()> {
for path in alias_candidates(bin_dir, alias_name) {
if !path.exists() {
continue;
}
if !force {
anyhow::bail!("alias '{alias_name}' already exists (use --force to overwrite)");
}
let content = fs::read_to_string(&path)?;
if !is_generated_alias(&content) {
anyhow::bail!("refusing to overwrite non-msb alias {}", path.display());
}
fs::remove_file(path)?;
}
Ok(())
}
pub(super) fn is_generated_alias(content: &str) -> bool {
content.lines().nth(1).is_some_and(is_marker_line)
}
fn alias_comment_value<'a>(content: &'a str, key: &str) -> Option<&'a str> {
content.lines().find_map(|line| {
let comment = line
.strip_prefix("# ")
.or_else(|| line.strip_prefix("rem # "))
.or_else(|| line.strip_prefix("REM # "))?;
comment.strip_prefix(key)
})
}
fn is_marker_line(line: &str) -> bool {
line == MARKER
|| line
.strip_prefix("rem ")
.or_else(|| line.strip_prefix("REM "))
.is_some_and(|line| line == MARKER)
}
fn display_alias_name(file_name: &str) -> String {
#[cfg(windows)]
{
file_name
.strip_suffix(".cmd")
.or_else(|| file_name.strip_suffix(".ps1"))
.unwrap_or(file_name)
.to_string()
}
#[cfg(not(windows))]
{
file_name.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
fn args(tmp: bool) -> InstallArgs {
InstallArgs {
image: Some("alpine".to_string()),
name: Some("hello".to_string()),
cpus: None,
memory: None,
volume: Vec::new(),
mount_dir: Vec::new(),
mount_file: Vec::new(),
mount_disk: Vec::new(),
mount_named: Vec::new(),
workdir: None,
shell: None,
env: Vec::new(),
force: false,
no_pull: true,
tmp,
list: false,
command: vec!["echo".to_string(), "alias-ok".to_string()],
}
}
#[test]
fn detects_posix_generated_alias_marker() {
let script = "#!/bin/sh\n# generated by msb install\n# image: alpine\n";
assert!(is_generated_alias(script));
}
#[test]
fn detects_windows_generated_alias_marker() {
let script = "@echo off\r\nrem # generated by msb install\r\nrem # image: alpine\r\n";
assert!(is_generated_alias(script));
}
#[test]
#[cfg(windows)]
fn windows_alias_path_uses_cmd_extension() {
let path = alias_path(Path::new(r"C:\Users\Stephen\.microsandbox\bin"), "hello");
assert_eq!(
path,
PathBuf::from(r"C:\Users\Stephen\.microsandbox\bin\hello.cmd")
);
}
#[test]
#[cfg(windows)]
fn windows_build_script_invokes_adjacent_msb_exe() {
let args = args(true);
let script = build_script("alpine", "hello", &args);
assert!(is_generated_alias(&script));
assert!(script.contains(r#""%~dp0msb.exe" run "alpine" -- "echo" "alias-ok""#));
assert!(script.contains("rem # mode: tmp"));
}
}