use std::cmp::Ordering;
use std::fmt;
use std::fs::{self, File, OpenOptions};
use std::future::Future;
use std::io::{IsTerminal, Write};
#[cfg(unix)]
use std::os::fd::AsRawFd;
#[cfg(windows)]
use std::os::windows::io::AsRawHandle;
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(windows)]
use std::process::Stdio;
use std::time::Duration;
use clap::{Args, Subcommand};
use console::{Key, Term, style};
use microsandbox_migration::schema_metadata;
use microsandbox_migration::{Migrator, MigratorTrait};
use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, DbErr, Statement};
use serde::Deserialize;
use tokio::process::Command as TokioCommand;
#[cfg(windows)]
use windows_sys::Win32::Foundation::HANDLE;
#[cfg(windows)]
use windows_sys::Win32::Storage::FileSystem::{LOCKFILE_EXCLUSIVE_LOCK, LockFileEx, UnlockFileEx};
#[cfg(windows)]
use windows_sys::Win32::System::IO::OVERLAPPED;
use super::install::is_generated_alias;
use crate::ui;
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const MIN_DOWNGRADE_VERSION: Version = Version {
major: 0,
minor: 6,
patch: 0,
};
#[cfg(unix)]
const MARKER_START: &str = "# >>> microsandbox >>>";
#[cfg(unix)]
const MARKER_END: &str = "# <<< microsandbox <<<";
#[derive(Debug, Args)]
pub struct SelfArgs {
#[command(subcommand)]
pub command: SelfCommand,
}
#[derive(Debug, Subcommand)]
pub enum SelfCommand {
#[command(visible_alias = "check")]
Doctor(DoctorArgs),
#[command(visible_alias = "upgrade")]
Update(SelfUpdateArgs),
Downgrade(SelfDowngradeArgs),
Uninstall(SelfUninstallArgs),
}
#[derive(Debug, Args)]
pub struct SelfUpdateArgs {
#[arg(short, long)]
pub force: bool,
}
#[derive(Debug, Args)]
pub struct SelfDowngradeArgs {
pub version: String,
#[arg(short, long)]
pub yes: bool,
#[arg(short, long)]
pub force: bool,
#[arg(long)]
pub keep_cache: bool,
#[arg(long)]
pub no_backup: bool,
}
#[derive(Debug, Args)]
pub struct SchemaBaselineArgs {
#[arg(long)]
pub json: bool,
}
#[cfg(windows)]
#[derive(Debug, Args)]
pub struct WindowsSelfDowngradeSwapArgs {
#[arg(long)]
pub parent_pid: i32,
#[arg(long)]
pub base_dir: PathBuf,
#[arg(long)]
pub staged_dir: PathBuf,
#[arg(long)]
pub target_version: String,
#[arg(long)]
pub lease_holder_pid: Option<i32>,
#[arg(long)]
pub lease_expires_at: Option<String>,
#[arg(long)]
pub log_path: PathBuf,
}
#[derive(Debug, Args, Clone, Copy)]
pub struct DoctorArgs {
#[arg(long)]
pub fix: bool,
#[arg(long, short = 'y')]
pub yes: bool,
}
#[derive(Debug, Args)]
pub struct SelfUninstallArgs {
#[arg(long, short)]
pub yes: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UninstallCategory {
All,
Sandboxes,
Volumes,
Cache,
Installs,
Database,
Logs,
Secrets,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Version {
major: u64,
minor: u64,
patch: u64,
}
#[derive(Debug, Deserialize)]
struct SchemaBaseline {
#[serde(alias = "schema_version")]
schema_baseline_version: u32,
#[allow(dead_code)]
downgrade_floor: String,
migrations: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
struct RollbackPlan<'a> {
rollback: &'a [schema_metadata::MigrationMetadata],
affects_cache: bool,
affects_user_data: bool,
}
struct MigrationLock {
file: File,
}
#[derive(Debug)]
enum DowngradeRunOutcome {
#[cfg(not(windows))]
Complete,
#[cfg(windows)]
WindowsSwapScheduled,
}
struct DowngradeRunContext<'a> {
db: &'a microsandbox_db::connection::DbWriteConnection,
base_dir: &'a Path,
db_path: &'a Path,
backup_path: Option<&'a Path>,
target_version: Version,
target_baseline: &'a SchemaBaseline,
planned_applied_migrations: &'a [String],
rollback_plan: &'a RollbackPlan<'static>,
install_lease: Option<&'a mut microsandbox_runtime::maintenance::InstallExclusiveLease>,
args: &'a SelfDowngradeArgs,
}
impl UninstallCategory {
const ITEMS: &[Self] = &[
Self::All,
Self::Sandboxes,
Self::Volumes,
Self::Cache,
Self::Installs,
Self::Database,
Self::Logs,
Self::Secrets,
];
fn label(&self) -> &'static str {
match self {
Self::All => "All — remove everything and command links",
Self::Sandboxes => "Sandboxes — sandbox state and rootfs",
Self::Volumes => "Volumes — named volumes",
Self::Cache => "Cache — OCI image layers",
Self::Installs => "Installs — installed command aliases",
Self::Database => "Database — metadata store",
Self::Logs => "Logs — log files",
Self::Secrets => "Secrets — secrets, TLS certs, and SSH keys",
}
}
fn short_name(&self) -> &'static str {
match self {
Self::All => "all",
Self::Sandboxes => "sandboxes",
Self::Volumes => "volumes",
Self::Cache => "cache",
Self::Installs => "installs",
Self::Database => "database",
Self::Logs => "logs",
Self::Secrets => "secrets",
}
}
}
impl Version {
fn parse(input: &str) -> anyhow::Result<Self> {
let clean = input.trim().strip_prefix('v').unwrap_or(input.trim());
let mut parts = clean.split('.');
let Some(major) = parts.next() else {
anyhow::bail!("invalid version {input:?}");
};
let Some(minor) = parts.next() else {
anyhow::bail!("invalid version {input:?}; expected MAJOR.MINOR.PATCH");
};
let Some(patch) = parts.next() else {
anyhow::bail!("invalid version {input:?}; expected MAJOR.MINOR.PATCH");
};
if parts.next().is_some() {
anyhow::bail!("invalid version {input:?}; expected MAJOR.MINOR.PATCH");
}
Ok(Self {
major: major.parse()?,
minor: minor.parse()?,
patch: patch.parse()?,
})
}
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> Ordering {
(self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl RollbackPlan<'_> {
fn steps(&self) -> usize {
self.rollback.len()
}
fn has_destructive_steps(&self, keep_cache: bool) -> bool {
self.steps() > 0 || (self.affects_cache && !keep_cache) || self.affects_user_data
}
}
impl DowngradeRunOutcome {
#[cfg(not(windows))]
fn clear_lease_in_parent(&self) -> bool {
let _ = self;
true
}
#[cfg(windows)]
fn clear_lease_in_parent(&self) -> bool {
let _ = self;
false
}
}
impl MigrationLock {
fn acquire(path: PathBuf) -> anyhow::Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
lock_migration_file(&file, &path)?;
Ok(Self { file })
}
}
impl Drop for MigrationLock {
fn drop(&mut self) {
let _ = unlock_migration_file(&self.file);
}
}
pub async fn run(args: SelfArgs) -> anyhow::Result<()> {
match args.command {
SelfCommand::Doctor(args) => run_doctor(args),
SelfCommand::Update(args) => run_update(args).await,
SelfCommand::Downgrade(args) => run_downgrade(args).await,
SelfCommand::Uninstall(args) => run_uninstall(args).await,
}
}
pub fn run_schema_baseline(args: SchemaBaselineArgs) -> anyhow::Result<()> {
if !args.json {
anyhow::bail!("__schema-baseline requires --json");
}
let baseline = serde_json::json!({
"schema_baseline_version": schema_metadata::SCHEMA_BASELINE_FORMAT_VERSION,
"msb_version": CURRENT_VERSION,
"downgrade_floor": schema_metadata::DOWNGRADE_FLOOR,
"migrations": schema_metadata::migration_ids().collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&baseline)?);
Ok(())
}
pub fn run_doctor(args: DoctorArgs) -> anyhow::Result<()> {
let diagnosis = diagnose_host();
render_diagnosis(&diagnosis);
if diagnosis.is_healthy() {
done("Host setup is ready.");
return Ok(());
}
let mut applied_any = false;
let mut offered_any_fix = false;
let mut relogin_pending = false;
for problem in &diagnosis.problems {
render_problem(problem, !args.fix);
if args.fix
&& let Some(fix) = &problem.fix
{
offered_any_fix = true;
if apply_fix(fix, args.yes)? {
applied_any = true;
relogin_pending |= fix.requires_relogin;
}
}
}
#[cfg(windows)]
if args.fix && has_windows_hypervisor_problem(&diagnosis) {
offered_any_fix = true;
if apply_windows_fix(args.yes)? {
applied_any = true;
}
}
if applied_any {
let recheck = diagnose_host();
render_diagnosis(&recheck);
if recheck.is_healthy() {
done("Host setup is ready.");
return Ok(());
}
for problem in &recheck.problems {
render_problem(problem, false);
}
if relogin_pending {
ui::warn_with_lines(
"some fixes apply fully only after you log out and back in",
&[ui::ErrorLine::Hint(
"start a new shell (or re-login) to pick up group changes",
)],
);
}
} else if args.fix && offered_any_fix {
ui::warn("no fixes were applied.");
} else if args.fix {
ui::warn("no automatic fixes are available for the problems above.");
}
std::process::exit(1);
}
fn diagnose_host() -> microsandbox::setup::Diagnosis {
let spinner = ui::Spinner::start("Checking", "host");
let diagnosis = microsandbox::setup::diagnose();
spinner.finish_clear();
diagnosis
}
fn render_diagnosis(diagnosis: µsandbox::setup::Diagnosis) {
use microsandbox::setup::CheckState;
let checks: Vec<µsandbox::setup::Check> =
diagnosis.sections.iter().flat_map(|s| &s.checks).collect();
let (mut facts, rows): (Vec<_>, Vec<_>) = checks
.into_iter()
.partition(|check| matches!(check.state, CheckState::Info));
facts.sort_by_key(|check| check.label != "Platform");
for check in facts.into_iter().chain(rows) {
render_check(check);
}
}
fn render_check(check: µsandbox::setup::Check) {
use microsandbox::setup::CheckState;
match check.state {
CheckState::Pass => ui::success(&check.label, &check.value),
CheckState::Fail => ui::failure(&check.label, &check.value),
CheckState::Warn => {
eprintln!(
" {} {:<12} {}",
style("!").yellow(),
check.label,
check.value
);
}
CheckState::Info => info(&format!("{}: {}", check.label, check.value)),
}
}
fn render_problem(problem: µsandbox::setup::Problem, offer_fix_flag: bool) {
let mut lines: Vec<String> = problem.hints.clone();
if let Some(fix) = &problem.fix {
lines.push(format!("fix: {}", fix.description));
for command in &fix.commands {
lines.push(format!(" {}", command.display()));
}
if offer_fix_flag {
lines.push("apply automatically: msb doctor --fix".to_string());
}
}
let error_lines: Vec<ui::ErrorLine<'_>> =
lines.iter().map(|line| ui::ErrorLine::Hint(line)).collect();
ui::error_with_lines(&problem.headline, &error_lines);
}
fn apply_fix(fix: µsandbox::setup::Fix, assume_yes: bool) -> anyhow::Result<bool> {
if !assume_yes && !confirm(&format!("Apply fix — {}? [y/N] ", fix.description))? {
info("Skipped.");
return Ok(false);
}
if fix.commands.iter().any(|command| command.program == "sudo") {
let status = Command::new("sudo")
.arg("-v")
.status()
.map_err(|e| anyhow::anyhow!("could not pre-authenticate sudo: {e}"))?;
if !status.success() {
anyhow::bail!("sudo authentication failed ({status}); no fix commands were run");
}
}
for command in &fix.commands {
let spinner = ui::Spinner::start("Applying", &command.display());
match Command::new(&command.program).args(&command.args).status() {
Ok(status) if status.success() => spinner.finish_success("Applied"),
Ok(status) => {
spinner.finish_fail("Failed");
ui::warn(&format!("`{}` exited with {status}", command.display()));
}
Err(e) => {
spinner.finish_fail("Failed");
ui::warn(&format!("could not run `{}`: {e}", command.display()));
}
}
}
Ok(true)
}
fn confirm(prompt: &str) -> anyhow::Result<bool> {
if !std::io::stdin().is_terminal() {
anyhow::bail!("non-interactive terminal; pass --yes to apply fixes");
}
eprint!("{prompt}");
std::io::stderr().flush().ok();
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
Ok(input.trim().eq_ignore_ascii_case("y"))
}
#[cfg(windows)]
fn has_windows_hypervisor_problem(diagnosis: µsandbox::setup::Diagnosis) -> bool {
diagnosis
.problems
.iter()
.any(|problem| problem.headline == "Windows Hypervisor Platform is not available")
}
#[cfg(windows)]
fn apply_windows_fix(assume_yes: bool) -> anyhow::Result<bool> {
if !assume_yes && !confirm("Apply fix — enable Windows Hypervisor Platform? [y/N] ")? {
info("Skipped.");
return Ok(false);
}
eprintln!();
enable_windows_hypervisor_platform()?;
Ok(true)
}
#[cfg(windows)]
fn enable_windows_hypervisor_platform() -> anyhow::Result<()> {
let command = microsandbox::setup::ENABLE_HYPERVISOR_PLATFORM_COMMAND;
let script = format!(
"$p = Start-Process -FilePath powershell.exe -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-Command','{}') -Verb RunAs -Wait -PassThru; exit $p.ExitCode",
command.replace('\'', "''")
);
info("Opening elevated PowerShell to enable Windows Hypervisor Platform.");
let status = Command::new("powershell.exe")
.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
&script,
])
.status()?;
if !status.success() {
anyhow::bail!(
"failed to enable Windows Hypervisor Platform (status: {status}); rerun without --fix for manual instructions"
);
}
done("Windows Hypervisor Platform enable command completed.");
Ok(())
}
async fn run_update(args: SelfUpdateArgs) -> anyhow::Result<()> {
info(&format!("Current version: v{CURRENT_VERSION}"));
let spinner = ui::Spinner::start("Checking", "latest release");
let latest = fetch_latest_version().await?;
spinner.finish_clear();
info(&format!("Latest version: {latest}"));
let latest_clean = latest.strip_prefix('v').unwrap_or(&latest);
if !args.force && latest_clean == CURRENT_VERSION {
done("Already up to date.");
link_public_commands(&resolve_base_dir()?)?;
return Ok(());
}
let base_dir = resolve_base_dir()?;
let bin_dir = base_dir.join(microsandbox_utils::BIN_SUBDIR);
let lib_dir = base_dir.join(microsandbox_utils::LIB_SUBDIR);
let spinner = ui::Spinner::start("Updating", &format!("to {latest}"));
let result = microsandbox::setup::Setup::builder()
.base_dir(base_dir.clone())
.version(latest_clean.to_string())
.force(true)
.build()
.install()
.await;
match result {
Ok(()) => {
spinner.finish_clear();
done(&format!("Updated msb in {}", bin_dir.display()));
done(&format!("Updated libkrunfw in {}/", lib_dir.display()));
link_public_commands(&base_dir)?;
}
Err(e) => {
spinner.finish_clear();
anyhow::bail!("update failed: {e}");
}
}
Ok(())
}
async fn run_downgrade(args: SelfDowngradeArgs) -> anyhow::Result<()> {
run_downgrade_local(args).await
}
async fn run_downgrade_local(args: SelfDowngradeArgs) -> anyhow::Result<()> {
let current_version = Version::parse(CURRENT_VERSION)?;
let target_version = Version::parse(&args.version)?;
info(&format!("Current version: v{current_version}"));
info(&format!("Target version: v{target_version}"));
if target_version >= current_version {
return refuse_static(
&format!("v{target_version} is not older than the current version"),
&["use `msb self update` to move to the latest release"],
);
}
if target_version < MIN_DOWNGRADE_VERSION {
return refuse_static(
&format!("v{target_version} is below the supported downgrade floor"),
&[
"minimum supported downgrade target: 0.6.0",
"V1 only supports forward-looking downgrades",
],
);
}
let spinner = ui::Spinner::start("Checking", &format!("release {target_version}"));
let target_baseline = match async {
fetch_release_tag(target_version).await?;
load_target_schema_baseline(target_version).await
}
.await
{
Ok(baseline) => {
spinner.finish_clear();
baseline
}
Err(err) => {
spinner.finish_clear();
return Err(err);
}
};
let base_dir = resolve_base_dir()?;
let db_dir = base_dir.join(microsandbox_utils::DB_SUBDIR);
let db_path = db_dir.join(microsandbox_utils::DB_FILENAME);
let db = open_downgrade_db(&db_path).await?;
let applied_migrations = applied_migrations(db.inner()).await?;
let rollback_plan = build_rollback_plan(&target_baseline, &applied_migrations)?;
refuse_irreversible_rollback(&rollback_plan)?;
let user_data_warnings = if rollback_plan.affects_user_data {
user_data_warnings(db.inner()).await?
} else {
Vec::new()
};
let backup_path = if rollback_plan.steps() > 0 && !args.no_backup {
Some(next_backup_path(&db_dir, current_version, target_version)?)
} else {
None
};
if rollback_plan.has_destructive_steps(args.keep_cache) {
warn_downgrade_plan(
target_version,
&rollback_plan,
backup_path.as_deref(),
&user_data_warnings,
&args,
);
if !args.yes && !confirm_downgrade("Proceed?")? {
info("Aborted.");
return Ok(());
}
}
let mut install_lease = if maintenance_lease_available(&applied_migrations) {
Some(microsandbox_runtime::maintenance::acquire_install_exclusive_lease(&db).await?)
} else {
None
};
let result = run_downgrade_with_db(DowngradeRunContext {
db: &db,
base_dir: &base_dir,
db_path: &db_path,
backup_path: backup_path.as_deref(),
target_version,
target_baseline: &target_baseline,
planned_applied_migrations: &applied_migrations,
rollback_plan: &rollback_plan,
install_lease: install_lease.as_mut(),
args: &args,
})
.await;
let clear_lease_in_parent = result
.as_ref()
.map(DowngradeRunOutcome::clear_lease_in_parent)
.unwrap_or(true);
if clear_lease_in_parent && let Some(lease) = install_lease.as_ref() {
let clear_result =
microsandbox_runtime::maintenance::clear_install_exclusive_lease(&db, lease).await;
if let Err(err) = clear_result {
ui::warn(&format!("failed to clear downgrade lease: {err}"));
}
}
result.map(|_| ())
}
async fn run_downgrade_with_db(
mut ctx: DowngradeRunContext<'_>,
) -> anyhow::Result<DowngradeRunOutcome> {
let db_dir = ctx
.db_path
.parent()
.ok_or_else(|| anyhow::anyhow!("database path has no parent: {}", ctx.db_path.display()))?;
{
let _migration_lock = acquire_migration_lock(db_dir)?;
let fresh_applied = applied_migrations(ctx.db.inner()).await?;
ensure_applied_unchanged(ctx.planned_applied_migrations, &fresh_applied)?;
let fresh_plan = build_rollback_plan(ctx.target_baseline, &fresh_applied)?;
refuse_irreversible_rollback(&fresh_plan)?;
ensure_plan_unchanged(ctx.rollback_plan, &fresh_plan)?;
renew_install_lease_if_present(ctx.db, &mut ctx.install_lease).await?;
if !maintenance_lease_available(&fresh_applied) && fresh_plan.steps() > 0 {
refuse_static(
"database schema rollback requires the maintenance lease table",
&["retry after starting msb once with the current version"],
)?;
unreachable!("refuse_static always returns an error");
}
if fresh_plan.steps() > 0 || (cfg!(windows) && !fresh_applied.is_empty()) {
refuse_if_active_sandboxes(ctx.db.inner()).await?;
}
if let Some(path) = ctx.backup_path {
let spinner =
ui::Spinner::start("Backing up", &relative_or_display(ctx.base_dir, path));
match run_with_install_lease_renewal(ctx.db, &mut ctx.install_lease, async {
vacuum_into(ctx.db.inner(), path).await
})
.await
{
Ok(()) => spinner.finish_success("Backed up"),
Err(err) => {
spinner.finish_clear();
return Err(err);
}
}
renew_install_lease_if_present(ctx.db, &mut ctx.install_lease).await?;
}
if fresh_plan.steps() > 0 {
let spinner = ui::Spinner::start("Rolling back", "schema changes");
match run_with_install_lease_renewal(ctx.db, &mut ctx.install_lease, async {
rollback_schema(ctx.db.inner(), fresh_plan.steps()).await
})
.await
{
Ok(()) => spinner.finish_success("Rolled back"),
Err(err) => {
spinner.finish_clear();
let _ = Migrator::up(ctx.db.inner(), None).await;
return Err(err);
}
}
renew_install_lease_if_present(ctx.db, &mut ctx.install_lease).await?;
}
}
if ctx.rollback_plan.affects_cache && !ctx.args.keep_cache {
let spinner = ui::Spinner::start("Purging", "cache");
let base_dir = ctx.base_dir.to_path_buf();
match run_with_install_lease_renewal(ctx.db, &mut ctx.install_lease, async move {
tokio::task::spawn_blocking(move || purge_cache(&base_dir)).await?
})
.await
{
Ok(()) => spinner.finish_success("Purged"),
Err(err) => {
spinner.finish_clear();
return Err(err);
}
}
}
renew_install_lease_if_present(ctx.db, &mut ctx.install_lease).await?;
install_target_release(&mut ctx).await
}
#[cfg(not(windows))]
async fn install_target_release(
ctx: &mut DowngradeRunContext<'_>,
) -> anyhow::Result<DowngradeRunOutcome> {
let spinner = ui::Spinner::start("Installing", &format!("msb v{}", ctx.target_version));
let result = run_with_install_lease_renewal(ctx.db, &mut ctx.install_lease, async {
microsandbox::setup::Setup::builder()
.base_dir(ctx.base_dir.to_path_buf())
.version(ctx.target_version.to_string())
.allow_ci_local_bundle(false)
.force(true)
.build()
.install()
.await
.map_err(anyhow::Error::from)
})
.await;
match result {
Ok(()) => spinner.finish_success("Installed"),
Err(err) => {
spinner.finish_clear();
return Err(err);
}
}
verify_installed_msb_version(ctx.base_dir, ctx.target_version).await?;
link_public_commands(ctx.base_dir)?;
done(&format!("Downgraded to v{}", ctx.target_version));
Ok(DowngradeRunOutcome::Complete)
}
#[cfg(windows)]
async fn install_target_release(
ctx: &mut DowngradeRunContext<'_>,
) -> anyhow::Result<DowngradeRunOutcome> {
let staged_dir = stage_windows_target_release(ctx).await?;
let log_path = schedule_windows_downgrade_swap(ctx, &staged_dir)?;
ui::success(
"Scheduled",
&format!(
"Windows swap after this msb process exits; log: {}",
log_path.display()
),
);
done(&format!(
"Downgrade to v{} will complete after exit",
ctx.target_version
));
Ok(DowngradeRunOutcome::WindowsSwapScheduled)
}
#[cfg(windows)]
async fn stage_windows_target_release(
ctx: &mut DowngradeRunContext<'_>,
) -> anyhow::Result<PathBuf> {
let temp = tempfile::Builder::new()
.prefix("msb-downgrade-stage-")
.tempdir()?;
let staged_dir = temp.keep();
let spinner = ui::Spinner::start("Staging", &format!("msb v{}", ctx.target_version));
let install_dir = staged_dir.clone();
let target_version = ctx.target_version;
let target_version_string = target_version.to_string();
let result = run_with_install_lease_renewal(ctx.db, &mut ctx.install_lease, async move {
microsandbox::setup::Setup::builder()
.base_dir(install_dir)
.version(target_version_string)
.allow_ci_local_bundle(false)
.force(true)
.build()
.install()
.await
.map_err(anyhow::Error::from)
})
.await;
match result {
Ok(()) => spinner.finish_success("Staged"),
Err(err) => {
spinner.finish_clear();
return Err(err);
}
}
verify_installed_msb_version(&staged_dir, target_version).await?;
Ok(staged_dir)
}
#[cfg(windows)]
fn schedule_windows_downgrade_swap(
ctx: &DowngradeRunContext<'_>,
staged_dir: &Path,
) -> anyhow::Result<PathBuf> {
let helper_dir = tempfile::Builder::new()
.prefix("msb-downgrade-helper-")
.tempdir()?
.keep();
let helper_path = helper_dir.join(format!("msb-downgrade-helper-{}.exe", std::process::id()));
fs::copy(std::env::current_exe()?, &helper_path)?;
let log_dir = ctx.base_dir.join(microsandbox_utils::LOGS_SUBDIR);
fs::create_dir_all(&log_dir)?;
let log_path = log_dir.join(format!(
"self-downgrade-{CURRENT_VERSION}-to-{}-{}.log",
ctx.target_version,
std::process::id()
));
let mut command = Command::new(&helper_path);
command
.arg("__windows-self-downgrade-swap")
.arg("--parent-pid")
.arg(std::process::id().to_string())
.arg("--base-dir")
.arg(ctx.base_dir)
.arg("--staged-dir")
.arg(staged_dir)
.arg("--target-version")
.arg(ctx.target_version.to_string())
.arg("--log-path")
.arg(&log_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Some(lease) = ctx.install_lease.as_ref() {
let lease = **lease;
command
.arg("--lease-holder-pid")
.arg(lease.holder_pid.to_string())
.arg("--lease-expires-at")
.arg(
lease
.lease_expires_at
.and_utc()
.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true),
);
}
command.spawn()?;
Ok(log_path)
}
#[cfg(windows)]
pub async fn run_windows_self_downgrade_swap(
args: WindowsSelfDowngradeSwapArgs,
) -> anyhow::Result<()> {
if let Some(parent) = args.log_path.parent() {
fs::create_dir_all(parent)?;
}
let mut log = OpenOptions::new()
.create(true)
.append(true)
.open(&args.log_path)?;
writeln!(
log,
"starting deferred downgrade swap to v{}",
args.target_version
)?;
let swap_result = perform_windows_downgrade_swap(&args, &mut log).await;
if let Err(err) = &swap_result {
let _ = writeln!(log, "swap failed: {err:#}");
}
if let Err(err) = clear_windows_downgrade_lease(&args, &mut log).await {
let _ = writeln!(
log,
"warning: failed to clear install-exclusive lease: {err:#}"
);
}
if let Err(err) = remove_windows_swap_staging(&args.staged_dir, &mut log) {
let _ = writeln!(log, "warning: failed to remove staged release: {err:#}");
}
if let Err(err) = schedule_windows_helper_cleanup(&mut log) {
let _ = writeln!(log, "warning: failed to schedule helper cleanup: {err:#}");
}
if swap_result.is_ok() {
writeln!(log, "deferred downgrade swap completed")?;
}
swap_result
}
#[cfg(windows)]
async fn perform_windows_downgrade_swap(
args: &WindowsSelfDowngradeSwapArgs,
log: &mut File,
) -> anyhow::Result<()> {
wait_for_parent_process_exit(args.parent_pid, log)?;
let target_version = Version::parse(&args.target_version)?;
let msb_name = microsandbox_utils::msb_binary_filename("windows");
let libkrunfw_name = microsandbox_utils::libkrunfw_filename("windows");
let staged_bin = args.staged_dir.join(microsandbox_utils::BIN_SUBDIR);
let staged_lib = args.staged_dir.join(microsandbox_utils::LIB_SUBDIR);
let target_bin = args.base_dir.join(microsandbox_utils::BIN_SUBDIR);
let target_lib = args.base_dir.join(microsandbox_utils::LIB_SUBDIR);
copy_windows_swap_file_with_retries(
&staged_bin.join(&msb_name),
&target_bin.join(&msb_name),
"msb.exe",
log,
)?;
copy_windows_swap_file_with_retries(
&staged_lib.join(&libkrunfw_name),
&target_lib.join(&libkrunfw_name),
"libkrunfw.dll",
log,
)?;
verify_installed_msb_version(&args.base_dir, target_version).await?;
Ok(())
}
#[cfg(windows)]
async fn clear_windows_downgrade_lease(
args: &WindowsSelfDowngradeSwapArgs,
log: &mut File,
) -> anyhow::Result<()> {
let (Some(holder_pid), Some(expires_at)) =
(args.lease_holder_pid, args.lease_expires_at.as_deref())
else {
writeln!(log, "no install-exclusive lease was passed to helper")?;
return Ok(());
};
let lease_expires_at = chrono::DateTime::parse_from_rfc3339(expires_at)?.naive_utc();
let lease = microsandbox_runtime::maintenance::InstallExclusiveLease {
holder_pid,
lease_expires_at,
};
let db_path = args
.base_dir
.join(microsandbox_utils::DB_SUBDIR)
.join(microsandbox_utils::DB_FILENAME);
let db = open_downgrade_db(&db_path).await?;
microsandbox_runtime::maintenance::clear_install_exclusive_lease(&db, &lease).await?;
writeln!(log, "cleared install-exclusive lease")?;
Ok(())
}
#[cfg(windows)]
fn wait_for_parent_process_exit(parent_pid: i32, log: &mut File) -> anyhow::Result<()> {
writeln!(log, "waiting for parent process {parent_pid} to exit")?;
let deadline = std::time::Instant::now() + Duration::from_secs(30);
while microsandbox_utils::process::pid_is_alive(parent_pid)
&& std::time::Instant::now() < deadline
{
std::thread::sleep(Duration::from_millis(250));
}
Ok(())
}
#[cfg(windows)]
fn copy_windows_swap_file_with_retries(
src: &Path,
dest: &Path,
label: &str,
log: &mut File,
) -> anyhow::Result<()> {
let Some(parent) = dest.parent() else {
anyhow::bail!("target path has no parent: {}", dest.display());
};
fs::create_dir_all(parent)?;
let mut last_err = None;
for attempt in 1..=80 {
match fs::copy(src, dest) {
Ok(_) => {
writeln!(log, "replaced {label} on attempt {attempt}")?;
return Ok(());
}
Err(err) => {
last_err = Some(err);
std::thread::sleep(Duration::from_millis(250));
}
}
}
let err = last_err
.map(|err| err.to_string())
.unwrap_or_else(|| "unknown error".to_string());
anyhow::bail!("failed to replace {label} after waiting for file locks: {err}");
}
#[cfg(windows)]
fn remove_windows_swap_staging(staged_dir: &Path, log: &mut File) -> anyhow::Result<()> {
match fs::remove_dir_all(staged_dir) {
Ok(()) => writeln!(log, "removed staged release {}", staged_dir.display())?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
Ok(())
}
#[cfg(windows)]
fn schedule_windows_helper_cleanup(log: &mut File) -> anyhow::Result<()> {
let helper_exe = std::env::current_exe()?;
let Some(helper_dir) = helper_exe.parent() else {
return Ok(());
};
let helper_dir_script = powershell_single_quote(&helper_dir.display().to_string());
let parent_pid = std::process::id();
let script = format!(
r#"
$ErrorActionPreference = 'SilentlyContinue'
$helper = {helper_dir_script}
$parent = {parent_pid}
Wait-Process -Id $parent -Timeout 30
for ($i = 0; $i -lt 80; $i++) {{
if (-not (Test-Path -LiteralPath $helper)) {{
exit 0
}}
Remove-Item -LiteralPath $helper -Recurse -Force
if (-not (Test-Path -LiteralPath $helper)) {{
exit 0
}}
Start-Sleep -Milliseconds 250
}}
exit 1
"#
);
Command::new("powershell.exe")
.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
&encode_powershell_command(&script),
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
writeln!(
log,
"scheduled cleanup for helper directory {}",
helper_dir.display()
)?;
Ok(())
}
async fn run_uninstall(args: SelfUninstallArgs) -> anyhow::Result<()> {
let base_dir = resolve_base_dir()?;
if !base_dir.exists() {
info("Nothing to uninstall.");
return Ok(());
}
if args.yes {
return uninstall_all(&base_dir);
}
let term = Term::stderr();
if !term.is_term() {
anyhow::bail!("non-interactive terminal; use --yes to remove everything");
}
ui::warn(&format!(
"this will modify your {} installation",
base_dir.display(),
));
let labels: Vec<&str> = UninstallCategory::ITEMS.iter().map(|c| c.label()).collect();
let selections = multi_select(&term, &labels)?;
if selections.is_empty() {
info("Nothing selected.");
return Ok(());
}
let selected: Vec<UninstallCategory> = selections
.iter()
.map(|&i| UninstallCategory::ITEMS[i])
.collect();
let is_all = selected.contains(&UninstallCategory::All);
let prompt = if is_all {
"Remove everything?".to_string()
} else {
let names: Vec<&str> = selected.iter().map(|c| c.short_name()).collect();
format!("Remove {}?", names.join(", "))
};
eprint!("{prompt} [y/N] ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
info("Aborted.");
return Ok(());
}
if is_all {
uninstall_all(&base_dir)?;
} else {
for category in &selected {
remove_category(&base_dir, *category)?;
}
}
Ok(())
}
fn uninstall_all(base_dir: &Path) -> anyhow::Result<()> {
remove_public_command_links(base_dir)?;
clean_legacy_shell_config()?;
#[cfg(windows)]
{
uninstall_all_windows(base_dir)
}
#[cfg(not(windows))]
{
fs::remove_dir_all(base_dir)?;
ui::success("Removed", &base_dir.display().to_string());
done("Uninstall complete.");
Ok(())
}
}
#[cfg(windows)]
fn uninstall_all_windows(base_dir: &Path) -> anyhow::Result<()> {
let base_dir = fs::canonicalize(base_dir).unwrap_or_else(|_| base_dir.to_path_buf());
let base_dir_script = powershell_single_quote(&base_dir.display().to_string());
let parent_pid = std::process::id();
let script = format!(
r#"
$ErrorActionPreference = 'Stop'
$base = {base_dir_script}
$parent = {parent_pid}
try {{
Wait-Process -Id $parent -Timeout 30 -ErrorAction SilentlyContinue
}} catch {{
Start-Sleep -Milliseconds 500
}}
for ($i = 0; $i -lt 80; $i++) {{
if (-not (Test-Path -LiteralPath $base)) {{
exit 0
}}
try {{
Remove-Item -LiteralPath $base -Recurse -Force -ErrorAction Stop
exit 0
}} catch {{
Start-Sleep -Milliseconds 250
}}
}}
exit 1
"#
);
Command::new("powershell.exe")
.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
&encode_powershell_command(&script),
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
ui::success("Scheduled removal", &base_dir.display().to_string());
done("Uninstall will complete after this msb process exits.");
Ok(())
}
#[cfg(windows)]
fn powershell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
#[cfg(windows)]
fn encode_powershell_command(script: &str) -> String {
use base64::Engine as _;
let mut bytes = Vec::with_capacity(script.len() * 2);
for unit in script.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
base64::engine::general_purpose::STANDARD.encode(bytes)
}
extern "C" fn sigint_show_cursor(_: libc::c_int) {
let _ = std::io::stderr().write_all(b"\x1b[?25h");
unsafe { libc::_exit(130) };
}
struct SigintGuard {
prev: libc::sighandler_t,
}
impl SigintGuard {
fn install() -> Self {
let prev = unsafe {
libc::signal(
libc::SIGINT,
sigint_show_cursor as *const () as libc::sighandler_t,
)
};
Self { prev }
}
}
impl Drop for SigintGuard {
fn drop(&mut self) {
unsafe {
libc::signal(libc::SIGINT, self.prev);
}
}
}
fn multi_select(term: &Term, items: &[&str]) -> anyhow::Result<Vec<usize>> {
let mut selected = vec![false; items.len()];
let mut cursor = 0usize;
let _sigint = SigintGuard::install();
term.hide_cursor()?;
let mut lines = render_select(term, items, &selected, cursor)?;
loop {
match term.read_key()? {
Key::ArrowUp | Key::Char('k') => {
cursor = cursor.saturating_sub(1);
}
Key::ArrowDown | Key::Char('j') => {
cursor = (cursor + 1).min(items.len() - 1);
}
Key::Char(' ') => {
toggle_select(&mut selected, cursor);
}
Key::Enter => break,
Key::Escape => {
selected.fill(false);
break;
}
_ => continue,
}
term.clear_last_lines(lines)?;
lines = render_select(term, items, &selected, cursor)?;
}
term.clear_last_lines(lines)?;
term.show_cursor()?;
Ok(selected
.iter()
.enumerate()
.filter(|&(_, &s)| s)
.map(|(i, _)| i)
.collect())
}
fn render_select(
term: &Term,
items: &[&str],
selected: &[bool],
cursor: usize,
) -> anyhow::Result<usize> {
let mut lines = 0;
for (i, item) in items.iter().enumerate() {
let pointer = if i == cursor { ">" } else { " " };
let check = if selected[i] {
format!("{}", style("[x]").green())
} else {
format!("{}", style("[ ]").dim())
};
let label = if i == cursor {
style(*item).bold().to_string()
} else {
item.to_string()
};
term.write_line(&format!(" {pointer} {check} {label}"))?;
lines += 1;
}
term.write_line(&format!(
" {}",
style("↑↓ navigate · space select · enter confirm · esc cancel").dim(),
))?;
lines += 1;
Ok(lines)
}
fn toggle_select(selected: &mut [bool], cursor: usize) {
selected[cursor] = !selected[cursor];
if cursor == 0 {
let state = selected[0];
for s in selected.iter_mut().skip(1) {
*s = state;
}
} else if !selected[cursor] {
selected[0] = false;
} else if selected[1..].iter().all(|&s| s) {
selected[0] = true;
}
}
async fn load_target_schema_baseline(target: Version) -> anyhow::Result<SchemaBaseline> {
let temp = tempfile::tempdir()?;
microsandbox::setup::Setup::builder()
.base_dir(temp.path().to_path_buf())
.version(target.to_string())
.skip_verify(true)
.allow_ci_local_bundle(false)
.force(true)
.build()
.install()
.await?;
let msb_name = microsandbox_utils::msb_binary_filename(std::env::consts::OS);
let msb_path = temp
.path()
.join(microsandbox_utils::BIN_SUBDIR)
.join(msb_name);
let output = TokioCommand::new(&msb_path)
.arg("__schema-baseline")
.arg("--json")
.output()
.await;
match output {
Ok(output) if output.status.success() => {
let baseline = serde_json::from_slice::<SchemaBaseline>(&output.stdout)?;
validate_schema_baseline(&baseline)?;
Ok(baseline)
}
Ok(_output) if target == MIN_DOWNGRADE_VERSION => Ok(floor_0_6_0_baseline()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"target v{target} did not report a schema baseline: {}",
stderr.trim()
);
}
Err(err) if target == MIN_DOWNGRADE_VERSION => {
tracing::debug!(error = %err, "using built-in 0.6.0 schema baseline");
Ok(floor_0_6_0_baseline())
}
Err(err) => Err(err.into()),
}
}
fn floor_0_6_0_baseline() -> SchemaBaseline {
SchemaBaseline {
schema_baseline_version: schema_metadata::SCHEMA_BASELINE_FORMAT_VERSION,
downgrade_floor: schema_metadata::DOWNGRADE_FLOOR.to_string(),
migrations: schema_metadata::BASELINE_0_6_0_MIGRATIONS
.iter()
.map(|id| (*id).to_string())
.collect(),
}
}
async fn open_downgrade_db(
db_path: &Path,
) -> anyhow::Result<microsandbox_db::connection::DbWriteConnection> {
if let Some(parent) = db_path.parent() {
fs::create_dir_all(parent)?;
}
let config = microsandbox::config::load_persisted_config_or_default()?;
let db = microsandbox_db::connection::DbWriteConnection::open(
db_path,
std::time::Duration::from_secs(config.database.connect_timeout_secs),
std::time::Duration::from_secs(config.database.busy_timeout_secs),
)
.await?;
Ok(db)
}
async fn applied_migrations(db: &DatabaseConnection) -> anyhow::Result<Vec<String>> {
let rows = match db
.query_all(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT version FROM seaql_migrations ORDER BY applied_at ASC, version ASC",
))
.await
{
Ok(rows) => rows,
Err(err) if is_missing_migrations_table(&err) => return Ok(Vec::new()),
Err(err) => return Err(err.into()),
};
rows.iter()
.map(|row| row.try_get_by_index::<String>(0).map_err(Into::into))
.collect()
}
async fn user_data_warnings(db: &DatabaseConnection) -> anyhow::Result<Vec<String>> {
let snapshot_count = optional_count(db, "SELECT COUNT(*) FROM snapshot_index").await?;
let disk_volume_count = optional_count(
db,
"SELECT COUNT(*) FROM volume WHERE kind = 'disk' OR disk_format IS NOT NULL OR disk_fstype IS NOT NULL",
)
.await?;
let mut lines = Vec::new();
if snapshot_count > 0 {
lines.push(format!(
"snapshots left untouched: {snapshot_count} indexed snapshot(s) may require a newer msb"
));
}
if disk_volume_count > 0 {
lines.push(format!(
"disk volumes left untouched: {disk_volume_count} disk-backed named volume(s) may require a newer msb"
));
}
Ok(lines)
}
async fn optional_count(db: &DatabaseConnection, sql: &str) -> anyhow::Result<i64> {
let row = match db
.query_one(Statement::from_string(DatabaseBackend::Sqlite, sql))
.await
{
Ok(row) => row,
Err(err) if is_missing_table_or_column(&err) => return Ok(0),
Err(err) => return Err(err.into()),
};
let Some(row) = row else {
return Ok(0);
};
Ok(row.try_get_by_index::<i64>(0)?)
}
fn build_rollback_plan(
baseline: &SchemaBaseline,
applied: &[String],
) -> anyhow::Result<RollbackPlan<'static>> {
validate_schema_baseline(baseline)?;
let current = schema_metadata::MIGRATION_METADATA;
if baseline.migrations.len() > current.len() {
anyhow::bail!(
"target schema baseline has {} migrations, but this binary only knows {}",
baseline.migrations.len(),
current.len()
);
}
for (index, migration) in baseline.migrations.iter().enumerate() {
let Some(current_metadata) = current.get(index) else {
anyhow::bail!("target schema baseline is longer than the current migration list");
};
if current_metadata.id != migration {
anyhow::bail!(
"target schema baseline is not a prefix of the current schema: expected {}, got {} at index {}",
current_metadata.id,
migration,
index,
);
}
}
if applied.len() > current.len() {
anyhow::bail!(
"database has {} applied migrations, but this binary only knows {}",
applied.len(),
current.len()
);
}
for (index, migration) in applied.iter().enumerate() {
let Some(current_metadata) = current.get(index) else {
anyhow::bail!("database migration list is longer than the current migration list");
};
if current_metadata.id != migration {
anyhow::bail!(
"database schema is newer than this msb binary: expected {}, got {} at index {}",
current_metadata.id,
migration,
index,
);
}
}
let rollback_start = baseline.migrations.len();
let rollback_end = applied.len();
let rollback = if rollback_end > rollback_start {
¤t[rollback_start..rollback_end]
} else {
&[]
};
let affects_cache = rollback.iter().any(|metadata| metadata.affects_cache);
let affects_user_data = rollback.iter().any(|metadata| metadata.affects_user_data);
Ok(RollbackPlan {
rollback,
affects_cache,
affects_user_data,
})
}
fn validate_schema_baseline(baseline: &SchemaBaseline) -> anyhow::Result<()> {
if baseline.schema_baseline_version != schema_metadata::SCHEMA_BASELINE_FORMAT_VERSION {
anyhow::bail!(
"unsupported schema baseline format version {}; expected {}",
baseline.schema_baseline_version,
schema_metadata::SCHEMA_BASELINE_FORMAT_VERSION
);
}
Ok(())
}
fn refuse_irreversible_rollback(plan: &RollbackPlan<'_>) -> anyhow::Result<()> {
let irreversible: Vec<_> = plan
.rollback
.iter()
.filter(|metadata| !metadata.reversible)
.collect();
if irreversible.is_empty() {
return Ok(());
}
let lines = irreversible
.iter()
.map(|metadata| metadata.summary.to_string())
.collect();
refuse_owned("downgrade crosses irreversible schema changes", lines)
}
fn ensure_plan_unchanged(
expected: &RollbackPlan<'_>,
actual: &RollbackPlan<'_>,
) -> anyhow::Result<()> {
let expected_ids: Vec<_> = expected
.rollback
.iter()
.map(|metadata| metadata.id)
.collect();
let actual_ids: Vec<_> = actual.rollback.iter().map(|metadata| metadata.id).collect();
if expected_ids == actual_ids
&& expected.affects_cache == actual.affects_cache
&& expected.affects_user_data == actual.affects_user_data
{
return Ok(());
}
refuse_static(
"database schema changed while downgrade was waiting",
&["retry the downgrade so msb can show the updated rollback plan"],
)
}
fn ensure_applied_unchanged(expected: &[String], actual: &[String]) -> anyhow::Result<()> {
if expected == actual {
return Ok(());
}
refuse_static(
"database migrations changed while downgrade was waiting",
&["retry the downgrade so msb can show the updated rollback plan"],
)
}
fn maintenance_lease_available(applied: &[String]) -> bool {
applied
.iter()
.any(|migration| migration == schema_metadata::MAINTENANCE_LEASE_MIGRATION_ID)
}
fn warn_downgrade_plan(
target: Version,
plan: &RollbackPlan<'_>,
backup_path: Option<&Path>,
user_data_warnings: &[String],
args: &SelfDowngradeArgs,
) {
let mut lines: Vec<String> = plan
.rollback
.iter()
.map(|metadata| metadata.summary.to_string())
.collect();
if plan.affects_cache && !args.keep_cache {
lines.push("cache will be purged".to_string());
}
lines.extend(user_data_warnings.iter().cloned());
if plan.steps() > 0 {
match backup_path {
Some(path) => lines.push(format!("backup: {}", path.display())),
None if args.no_backup => lines.push("backup: disabled by --no-backup".to_string()),
None => {}
}
}
let refs: Vec<ui::ErrorLine<'_>> = lines.iter().map(|line| ui::ErrorLine::Hint(line)).collect();
ui::warn_with_lines(
&format!("Downgrade will roll back schema changes added after {target}"),
&refs,
);
}
async fn refuse_if_active_sandboxes(db: &DatabaseConnection) -> anyhow::Result<()> {
let write = microsandbox_db::connection::DbWriteConnection::new(db.clone());
let active =
microsandbox_runtime::maintenance::active_sandboxes_for_schema_rollback(&write).await?;
if active.is_empty() {
return Ok(());
}
let mut lines: Vec<String> = active
.iter()
.map(|sandbox| match sandbox.pid {
Some(pid) => format!("{} (pid {pid})", sandbox.name),
None => sandbox.name.clone(),
})
.collect();
lines.push("run: msb stop --all, then retry".to_string());
refuse_owned(
&format!(
"this downgrade rolls back schema while {} sandbox{} active",
active.len(),
if active.len() == 1 { " is" } else { "es are" },
),
lines,
)
}
async fn vacuum_into(db: &DatabaseConnection, backup_path: &Path) -> anyhow::Result<()> {
if let Some(parent) = backup_path.parent() {
fs::create_dir_all(parent)?;
}
db.execute(Statement::from_sql_and_values(
DatabaseBackend::Sqlite,
"VACUUM INTO ?",
[backup_path.display().to_string().into()],
))
.await?;
Ok(())
}
async fn rollback_schema(db: &DatabaseConnection, steps: usize) -> anyhow::Result<()> {
db.execute_unprepared("BEGIN EXCLUSIVE").await?;
let down_result = Migrator::down(db, Some(steps as u32)).await;
match down_result {
Ok(()) => {
db.execute_unprepared("COMMIT").await?;
Ok(())
}
Err(err) => {
let _ = db.execute_unprepared("ROLLBACK").await;
Err(err.into())
}
}
}
fn is_missing_migrations_table(err: &DbErr) -> bool {
let message = err.to_string();
message.contains("no such table") && message.contains("seaql_migrations")
}
fn is_missing_table_or_column(err: &DbErr) -> bool {
let message = err.to_string();
message.contains("no such table") || message.contains("no such column")
}
fn purge_cache(base_dir: &Path) -> anyhow::Result<()> {
let path = base_dir.join(microsandbox_utils::CACHE_SUBDIR);
match fs::remove_dir_all(&path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
async fn renew_install_lease_if_present(
db: µsandbox_db::connection::DbWriteConnection,
install_lease: &mut Option<&mut microsandbox_runtime::maintenance::InstallExclusiveLease>,
) -> anyhow::Result<()> {
if let Some(lease) = install_lease.as_deref_mut() {
microsandbox_runtime::maintenance::renew_install_exclusive_lease(db, lease).await?;
}
Ok(())
}
async fn run_with_install_lease_renewal<F, T>(
db: µsandbox_db::connection::DbWriteConnection,
install_lease: &mut Option<&mut microsandbox_runtime::maintenance::InstallExclusiveLease>,
operation: F,
) -> anyhow::Result<T>
where
F: Future<Output = anyhow::Result<T>>,
{
let Some(lease) = install_lease.as_deref_mut() else {
return operation.await;
};
let renew_every = Duration::from_secs(
(microsandbox_runtime::maintenance::INSTALL_EXCLUSIVE_LEASE_SECS as u64 / 3).max(1),
);
let mut interval = tokio::time::interval(renew_every);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
tokio::pin!(operation);
loop {
tokio::select! {
result = &mut operation => return result,
_ = interval.tick() => {
microsandbox_runtime::maintenance::renew_install_exclusive_lease(db, lease).await?;
}
}
}
}
async fn verify_installed_msb_version(base_dir: &Path, target: Version) -> anyhow::Result<()> {
let msb_name = microsandbox_utils::msb_binary_filename(std::env::consts::OS);
let msb_path = base_dir.join(microsandbox_utils::BIN_SUBDIR).join(msb_name);
let output = TokioCommand::new(&msb_path)
.arg("--version")
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"installed msb version check failed with status {}: {}",
output.status,
stderr.trim()
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let installed = stdout
.trim()
.strip_prefix("msb ")
.ok_or_else(|| anyhow::anyhow!("unexpected msb --version output: {}", stdout.trim()))?;
if installed != target.to_string() {
anyhow::bail!("installed msb version is {installed}, expected {target}");
}
Ok(())
}
#[cfg(unix)]
fn lock_migration_file(file: &File, path: &Path) -> anyhow::Result<()> {
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
if rc != 0 {
return Err(anyhow::anyhow!(
"failed to lock migration file {}: {}",
path.display(),
std::io::Error::last_os_error()
));
}
Ok(())
}
#[cfg(windows)]
fn lock_migration_file(file: &File, path: &Path) -> anyhow::Result<()> {
let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
let rc = unsafe {
LockFileEx(
file.as_raw_handle() as HANDLE,
LOCKFILE_EXCLUSIVE_LOCK,
0,
u32::MAX,
u32::MAX,
&mut overlapped,
)
};
if rc == 0 {
return Err(anyhow::anyhow!(
"failed to lock migration file {}: {}",
path.display(),
std::io::Error::last_os_error()
));
}
Ok(())
}
#[cfg(unix)]
fn unlock_migration_file(file: &File) -> anyhow::Result<()> {
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
if rc != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
#[cfg(windows)]
fn unlock_migration_file(file: &File) -> anyhow::Result<()> {
let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
let rc = unsafe {
UnlockFileEx(
file.as_raw_handle() as HANDLE,
0,
u32::MAX,
u32::MAX,
&mut overlapped,
)
};
if rc == 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
fn acquire_migration_lock(db_dir: &Path) -> anyhow::Result<MigrationLock> {
MigrationLock::acquire(db_dir.join(format!(
"{}.migration.lock",
microsandbox_utils::DB_FILENAME
)))
}
fn next_backup_path(
db_dir: &Path,
current_version: Version,
target_version: Version,
) -> anyhow::Result<PathBuf> {
let base_name = format!("msb.db.bak-{current_version}-to-{target_version}");
let base_path = db_dir.join(&base_name);
if !base_path.exists() {
return Ok(base_path);
}
let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
Ok(db_dir.join(format!("{base_name}-{timestamp}")))
}
fn confirm_downgrade(prompt: &str) -> anyhow::Result<bool> {
let term = Term::stderr();
if !term.is_term() || !std::io::stdin().is_terminal() {
anyhow::bail!("non-interactive terminal; use --yes to downgrade");
}
eprint!("{prompt} [y/N] ");
std::io::stderr().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
Ok(input.trim().eq_ignore_ascii_case("y"))
}
fn refuse_static(msg: &str, context: &[&str]) -> anyhow::Result<()> {
ui::error_context(msg, context);
Err(ui::AlreadyRenderedError.into())
}
fn refuse_owned(msg: &str, context: Vec<String>) -> anyhow::Result<()> {
let refs: Vec<&str> = context.iter().map(String::as_str).collect();
refuse_static(msg, &refs)
}
fn relative_or_display(base_dir: &Path, path: &Path) -> String {
path.strip_prefix(base_dir)
.map(|path| path.display().to_string())
.unwrap_or_else(|_| path.display().to_string())
}
async fn fetch_latest_version() -> anyhow::Result<String> {
let url = format!(
"https://api.github.com/repos/{}/{}/releases/latest",
microsandbox_utils::GITHUB_ORG,
microsandbox_utils::MICROSANDBOX_REPO,
);
let client = reqwest::Client::new();
let resp: serde_json::Value = client
.get(&url)
.header("User-Agent", format!("msb/{CURRENT_VERSION}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let tag = resp["tag_name"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("could not parse latest release tag"))?;
Ok(tag.to_string())
}
async fn fetch_release_tag(version: Version) -> anyhow::Result<String> {
let url = format!(
"https://api.github.com/repos/{}/{}/releases/tags/v{}",
microsandbox_utils::GITHUB_ORG,
microsandbox_utils::MICROSANDBOX_REPO,
version,
);
let client = reqwest::Client::new();
let response = client
.get(&url)
.header("User-Agent", format!("msb/{CURRENT_VERSION}"))
.send()
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!("no published microsandbox release found for v{version}");
}
let resp: serde_json::Value = response.error_for_status()?.json().await?;
let tag = resp["tag_name"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("could not parse release tag for v{version}"))?;
Ok(tag.to_string())
}
fn resolve_base_dir() -> anyhow::Result<PathBuf> {
Ok(microsandbox_utils::resolve_home())
}
#[cfg(unix)]
fn local_bin_dir() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".local").join("bin"))
}
#[cfg(unix)]
fn public_command_links(base_dir: &Path) -> Option<Vec<(PathBuf, PathBuf)>> {
let local_bin = local_bin_dir()?;
let bin_dir = base_dir.join(microsandbox_utils::BIN_SUBDIR);
Some(vec![
(local_bin.join("msb"), bin_dir.join("msb")),
(local_bin.join("microsandbox"), bin_dir.join("microsandbox")),
])
}
fn link_public_commands(base_dir: &Path) -> anyhow::Result<()> {
#[cfg(not(unix))]
{
info(&format!(
"Add {} to PATH to run msb from any terminal.",
base_dir.join(microsandbox_utils::BIN_SUBDIR).display()
));
Ok(())
}
#[cfg(unix)]
{
let Some(links) = public_command_links(base_dir) else {
ui::warn("Skipped command links because no home directory was found");
return Ok(());
};
if let Some(parent) = links.first().and_then(|(link, _)| link.parent()) {
fs::create_dir_all(parent)?;
}
for (link, target) in links {
if link.exists() && !link.is_symlink() {
ui::warn(&format!(
"Skipped {} because it already exists",
link.display()
));
continue;
}
if link.is_symlink() {
fs::remove_file(&link)?;
}
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &link)?;
ui::success(
"Linked",
&format!("{} -> {}", link.display(), target.display()),
);
}
Ok(())
}
}
fn remove_public_command_links(base_dir: &Path) -> anyhow::Result<()> {
#[cfg(not(unix))]
{
let _ = base_dir;
Ok(())
}
#[cfg(unix)]
{
let Some(links) = public_command_links(base_dir) else {
return Ok(());
};
for (link, target) in links {
if !link.is_symlink() {
continue;
}
if fs::read_link(&link)? == target {
fs::remove_file(&link)?;
ui::success("Removed", &link.display().to_string());
}
}
Ok(())
}
}
fn info(msg: &str) {
eprintln!("{} {msg}", style("info").cyan().bold());
}
fn done(msg: &str) {
eprintln!("{} {msg}", style("done").green().bold());
}
fn remove_category(base_dir: &Path, category: UninstallCategory) -> anyhow::Result<()> {
match category {
UninstallCategory::All => unreachable!("handled before calling remove_category"),
UninstallCategory::Sandboxes => {
remove_subdir(base_dir, microsandbox_utils::SANDBOXES_SUBDIR, "sandboxes")
}
UninstallCategory::Volumes => {
remove_subdir(base_dir, microsandbox_utils::VOLUMES_SUBDIR, "volumes")
}
UninstallCategory::Cache => {
remove_subdir(base_dir, microsandbox_utils::CACHE_SUBDIR, "cache")
}
UninstallCategory::Installs => remove_installed_aliases(base_dir),
UninstallCategory::Database => {
remove_subdir(base_dir, microsandbox_utils::DB_SUBDIR, "database")
}
UninstallCategory::Logs => remove_subdir(base_dir, microsandbox_utils::LOGS_SUBDIR, "logs"),
UninstallCategory::Secrets => {
remove_subdir(base_dir, microsandbox_utils::SECRETS_SUBDIR, "secrets")?;
remove_subdir(base_dir, microsandbox_utils::TLS_SUBDIR, "tls")?;
remove_subdir(base_dir, microsandbox_utils::SSH_SUBDIR, "ssh")
}
}
}
fn remove_subdir(base_dir: &Path, subdir: &str, label: &str) -> anyhow::Result<()> {
let path = base_dir.join(subdir);
if path.exists() {
std::fs::remove_dir_all(&path)?;
ui::success("Removed", label);
}
Ok(())
}
fn remove_installed_aliases(base_dir: &Path) -> anyhow::Result<()> {
let bin_dir = base_dir.join(microsandbox_utils::BIN_SUBDIR);
if !bin_dir.is_dir() {
return Ok(());
}
for entry in std::fs::read_dir(&bin_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
if let Ok(content) = std::fs::read_to_string(&path)
&& is_generated_alias(&content)
{
fs::remove_file(&path)?;
let name = entry.file_name().to_string_lossy().to_string();
ui::success("Removed", &format!("alias {name}"));
}
}
Ok(())
}
#[cfg(unix)]
fn clean_legacy_shell_config() -> anyhow::Result<()> {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
for rc in [".profile", ".bash_profile", ".bashrc", ".zshrc"] {
let path = home.join(rc);
if path.exists() && remove_marker_block(&path)? {
ui::success("Cleaned legacy shell config", &format!("~/{rc}"));
}
}
let fish_conf = home.join(".config/fish/conf.d/microsandbox.fish");
if fish_conf.exists() {
fs::remove_file(&fish_conf)?;
ui::success(
"Removed legacy shell config",
"~/.config/fish/conf.d/microsandbox.fish",
);
}
Ok(())
}
#[cfg(not(unix))]
fn clean_legacy_shell_config() -> anyhow::Result<()> {
Ok(())
}
#[cfg(unix)]
fn remove_marker_block(path: &Path) -> anyhow::Result<bool> {
let content = std::fs::read_to_string(path)?;
if !content.contains(MARKER_START) {
return Ok(false);
}
let mut result = String::new();
let mut skip = false;
for line in content.lines() {
if line.contains(MARKER_START) {
skip = true;
continue;
}
if line.contains(MARKER_END) {
skip = false;
continue;
}
if !skip {
result.push_str(line);
result.push('\n');
}
}
std::fs::write(path, result)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn vacuum_into_writes_backup_file() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("msb.db");
let db = microsandbox_db::connection::DbWriteConnection::open(
&db_path,
std::time::Duration::from_secs(5),
std::time::Duration::from_secs(5),
)
.await
.unwrap();
db.execute_unprepared("CREATE TABLE sample (id INTEGER PRIMARY KEY, value TEXT NOT NULL)")
.await
.unwrap();
db.execute_unprepared("INSERT INTO sample (id, value) VALUES (1, 'wal-value')")
.await
.unwrap();
let backup_path = dir.path().join("backup").join("msb.db.bak");
vacuum_into(db.inner(), &backup_path).await.unwrap();
assert!(backup_path.exists());
let backup_db = microsandbox_db::connection::DbWriteConnection::open(
&backup_path,
std::time::Duration::from_secs(5),
std::time::Duration::from_secs(5),
)
.await
.unwrap();
let row = backup_db
.query_one(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT value FROM sample WHERE id = 1",
))
.await
.unwrap()
.unwrap();
assert_eq!(row.try_get_by_index::<String>(0).unwrap(), "wal-value");
}
#[tokio::test]
async fn rollback_schema_rolls_back_latest_migration() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("msb.db");
let db = microsandbox_db::connection::DbWriteConnection::open(
&db_path,
std::time::Duration::from_secs(5),
std::time::Duration::from_secs(5),
)
.await
.unwrap();
Migrator::up(db.inner(), None).await.unwrap();
rollback_schema(db.inner(), 1).await.unwrap();
let rows = db
.query_all(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'maintenance_lease'",
))
.await
.unwrap();
assert!(rows.is_empty());
}
#[tokio::test]
async fn user_data_warnings_list_snapshots_and_disk_volumes() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("msb.db");
let db = microsandbox_db::connection::DbWriteConnection::open(
&db_path,
std::time::Duration::from_secs(5),
std::time::Duration::from_secs(5),
)
.await
.unwrap();
db.execute_unprepared("CREATE TABLE snapshot_index (digest TEXT PRIMARY KEY)")
.await
.unwrap();
db.execute_unprepared(
"CREATE TABLE volume (kind TEXT, disk_format TEXT, disk_fstype TEXT)",
)
.await
.unwrap();
db.execute_unprepared("INSERT INTO snapshot_index (digest) VALUES ('sha256:test')")
.await
.unwrap();
db.execute_unprepared(
"INSERT INTO volume (kind, disk_format, disk_fstype) VALUES ('disk', 'raw', 'ext4')",
)
.await
.unwrap();
let warnings = user_data_warnings(db.inner()).await.unwrap();
assert_eq!(warnings.len(), 2);
assert!(warnings[0].contains("snapshots left untouched"));
assert!(warnings[1].contains("disk volumes left untouched"));
}
#[test]
fn version_parse_orders_release_versions() {
assert!(Version::parse("0.6.1").unwrap() > Version::parse("v0.6.0").unwrap());
assert!(Version::parse("0.5.10").unwrap() < MIN_DOWNGRADE_VERSION);
assert!(Version::parse("0.6").is_err());
}
#[test]
fn rollback_plan_uses_target_prefix() {
let baseline = SchemaBaseline {
schema_baseline_version: schema_metadata::SCHEMA_BASELINE_FORMAT_VERSION,
downgrade_floor: schema_metadata::DOWNGRADE_FLOOR.to_string(),
migrations: schema_metadata::BASELINE_0_6_0_MIGRATIONS
.iter()
.map(|id| (*id).to_string())
.collect(),
};
let applied: Vec<String> = schema_metadata::migration_ids()
.map(str::to_string)
.collect();
let plan = build_rollback_plan(&baseline, &applied).unwrap();
assert_eq!(
plan.steps(),
schema_metadata::MIGRATION_METADATA.len()
- schema_metadata::BASELINE_0_6_0_MIGRATIONS.len()
);
}
#[test]
fn rollback_plan_uses_applied_migrations_not_current_binary_length() {
let baseline = SchemaBaseline {
schema_baseline_version: schema_metadata::SCHEMA_BASELINE_FORMAT_VERSION,
downgrade_floor: schema_metadata::DOWNGRADE_FLOOR.to_string(),
migrations: schema_metadata::BASELINE_0_6_0_MIGRATIONS
.iter()
.map(|id| (*id).to_string())
.collect(),
};
let applied: Vec<String> = schema_metadata::BASELINE_0_6_0_MIGRATIONS
.iter()
.map(|id| (*id).to_string())
.collect();
let plan = build_rollback_plan(&baseline, &applied).unwrap();
assert_eq!(plan.steps(), 0);
assert!(!plan.affects_cache);
assert!(!plan.affects_user_data);
}
#[test]
fn rollback_plan_rejects_non_prefix_baseline() {
let baseline = SchemaBaseline {
schema_baseline_version: schema_metadata::SCHEMA_BASELINE_FORMAT_VERSION,
downgrade_floor: schema_metadata::DOWNGRADE_FLOOR.to_string(),
migrations: vec!["not_a_real_migration".to_string()],
};
let applied = Vec::new();
let err = build_rollback_plan(&baseline, &applied).unwrap_err();
assert!(err.to_string().contains("not a prefix"));
}
}