#![cfg_attr(docsrs, feature(doc_cfg))]
#[cfg(feature = "gui")]
#[cfg_attr(docsrs, doc(cfg(feature = "gui")))]
pub mod gui;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use anyhow::{anyhow, Context, Result};
pub const fn source_path_hash_const(path: &str) -> u64 {
let bytes = path.as_bytes();
let mut h: u64 = 14695981039346656037;
let mut i = 0;
while i < bytes.len() {
let b = if bytes[i] == b'\\' { b'/' } else { bytes[i] };
h ^= b as u64;
h = h.wrapping_mul(1099511628211);
i += 1;
}
h
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct Source(pub u64);
#[macro_export]
macro_rules! source {
($path:literal $(, $key:ident = $val:expr)* $(,)?) => {{
const H: u64 = $crate::source_path_hash_const($path);
$crate::Source(H)
}};
}
pub fn verify_payload(blobs: &[&[u8]], uninstaller_data: &[u8], expected: &[u8; 32]) -> Result<()> {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
for b in blobs {
h.update(b);
}
h.update(uninstaller_data);
let got: [u8; 32] = h.finalize().into();
if &got != expected {
return Err(anyhow!(
"installer payload integrity check failed — the file may be corrupt or tampered with"
));
}
Ok(())
}
pub enum EmbeddedEntry {
File {
source_path_hash: u64,
data: &'static [u8],
compression: &'static str,
},
Dir {
source_path_hash: u64,
children: &'static [DirChild],
},
}
pub struct DirChild {
pub name: &'static str,
pub kind: DirChildKind,
}
pub enum DirChildKind {
File {
data: &'static [u8],
compression: &'static str,
},
Dir {
children: &'static [DirChild],
},
}
#[derive(Default, Copy, Clone, Debug, PartialEq, Eq)]
pub enum OverwriteMode {
#[default]
Overwrite,
Skip,
Error,
Backup,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ErrorAction {
Skip,
Abort,
}
pub type DirFilter = Box<dyn Fn(&str) -> bool + 'static>;
pub type DirErrorHandler = Box<dyn Fn(&str, &anyhow::Error) -> ErrorAction + 'static>;
type DirFilterRef = dyn Fn(&str) -> bool + 'static;
type DirErrorHandlerRef = dyn Fn(&str, &anyhow::Error) -> ErrorAction + 'static;
pub trait ProgressSink: Send + Sync {
fn set_status(&self, status: &str);
fn set_progress(&self, fraction: f64);
fn log(&self, message: &str);
}
struct ProgressState {
steps_done: f64,
step_range_start: f64,
step_range_end: f64,
}
pub struct Installer {
pub headless: bool,
entries: &'static [EmbeddedEntry],
out_dir: Option<PathBuf>,
uninstaller_data: &'static [u8],
uninstaller_compression: &'static str,
#[cfg(target_os = "windows")]
self_delete: bool,
sink: Option<Box<dyn ProgressSink>>,
progress: Mutex<ProgressState>,
components: Vec<Component>,
cancelled: Arc<AtomicBool>,
options: Vec<CmdOption>,
option_values: std::collections::HashMap<String, OptionValue>,
log_file: Option<Mutex<std::fs::File>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OptionKind {
Flag,
String,
Int,
Bool,
}
#[derive(Clone, Debug)]
pub enum OptionValue {
Flag(bool),
String(String),
Int(i64),
Bool(bool),
}
pub trait FromOptionValue: Sized {
fn from_option_value(v: &OptionValue) -> Option<Self>;
}
impl FromOptionValue for bool {
fn from_option_value(v: &OptionValue) -> Option<Self> {
match v {
OptionValue::Flag(b) | OptionValue::Bool(b) => Some(*b),
_ => None,
}
}
}
impl FromOptionValue for String {
fn from_option_value(v: &OptionValue) -> Option<Self> {
match v {
OptionValue::String(s) => Some(s.clone()),
_ => None,
}
}
}
impl FromOptionValue for i64 {
fn from_option_value(v: &OptionValue) -> Option<Self> {
match v {
OptionValue::Int(n) => Some(*n),
_ => None,
}
}
}
impl FromOptionValue for i32 {
fn from_option_value(v: &OptionValue) -> Option<Self> {
match v {
OptionValue::Int(n) => i32::try_from(*n).ok(),
_ => None,
}
}
}
impl FromOptionValue for u64 {
fn from_option_value(v: &OptionValue) -> Option<Self> {
match v {
OptionValue::Int(n) if *n >= 0 => Some(*n as u64),
_ => None,
}
}
}
impl FromOptionValue for u32 {
fn from_option_value(v: &OptionValue) -> Option<Self> {
match v {
OptionValue::Int(n) if *n >= 0 => u32::try_from(*n as u64).ok(),
_ => None,
}
}
}
#[derive(Clone, Debug)]
struct CmdOption {
name: String,
kind: OptionKind,
}
#[derive(Clone, Debug)]
pub struct Component {
pub id: String,
pub label: String,
pub description: String,
pub progress_weight: u32,
pub default: bool,
pub required: bool,
pub selected: bool,
}
impl Component {
pub fn required(&mut self) -> &mut Self {
self.required = true;
self.selected = true;
self.default = true;
self
}
pub fn default_off(&mut self) -> &mut Self {
self.default = false;
if !self.required {
self.selected = false;
}
self
}
}
impl Installer {
pub fn new(
entries: &'static [EmbeddedEntry],
uninstaller_data: &'static [u8],
uninstaller_compression: &'static str,
) -> Self {
Installer {
headless: false,
entries,
out_dir: None,
uninstaller_data,
uninstaller_compression,
#[cfg(target_os = "windows")]
self_delete: false,
sink: None,
progress: Mutex::new(ProgressState {
steps_done: 0.0,
step_range_start: 0.0,
step_range_end: 0.0,
}),
components: Vec::new(),
cancelled: Arc::new(AtomicBool::new(false)),
options: Vec::new(),
option_values: std::collections::HashMap::new(),
log_file: None,
}
}
pub fn set_log_file(&mut self, path: impl AsRef<std::path::Path>) -> Result<()> {
use std::fs::OpenOptions;
let path = path.as_ref();
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("failed to open log file {}", path.display()))?;
self.log_file = Some(Mutex::new(file));
self.write_log_line(&format!(
"--- install session started (pid {}) ---",
std::process::id()
));
Ok(())
}
pub fn clear_log_file(&mut self) {
self.log_file = None;
}
pub fn log_error(&self, err: &anyhow::Error) {
self.write_log_line(&format!("[ERROR] {err:#}"));
}
fn write_log_line(&self, line: &str) {
if let Some(f) = &self.log_file {
if let Ok(mut f) = f.lock() {
use std::io::Write;
let _ = writeln!(f, "{line}");
}
}
}
pub fn option(&mut self, name: &str, kind: OptionKind) -> &mut Self {
let name = name.trim_start_matches('-').to_string();
if let Some(existing) = self.options.iter_mut().find(|o| o.name == name) {
existing.kind = kind;
} else {
self.options.push(CmdOption { name, kind });
}
self
}
pub fn get_option<T: FromOptionValue>(&self, name: &str) -> Option<T> {
let name = name.trim_start_matches('-');
self.option_values
.get(name)
.and_then(|v| T::from_option_value(v))
}
pub fn option_value(&self, name: &str) -> Option<&OptionValue> {
let name = name.trim_start_matches('-');
self.option_values.get(name)
}
pub fn set_option_value(&mut self, name: &str, value: OptionValue) {
let name = name.trim_start_matches('-').to_string();
self.option_values.insert(name, value);
}
pub fn option_values_snapshot(&self) -> std::collections::HashMap<String, OptionValue> {
self.option_values.clone()
}
pub fn cancellation_flag(&self) -> Arc<AtomicBool> {
self.cancelled.clone()
}
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed)
}
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Relaxed);
}
pub fn check_cancelled(&self) -> Result<()> {
if self.is_cancelled() {
Err(anyhow!("install cancelled by user"))
} else {
Ok(())
}
}
pub fn install_ctrlc_handler(&self) {
static INSTALLED: std::sync::Once = std::sync::Once::new();
let flag = self.cancelled.clone();
INSTALLED.call_once(|| {
let counter = Arc::new(AtomicU32::new(0));
let flag_h = flag.clone();
let _ = ctrlc::set_handler(move || {
let n = counter.fetch_add(1, Ordering::Relaxed) + 1;
if n == 1 {
flag_h.store(true, Ordering::Relaxed);
eprintln!("\nCancellation requested. Press Ctrl+C again to exit immediately.");
} else {
std::process::exit(130);
}
});
});
}
pub fn component(
&mut self,
id: impl Into<String>,
label: impl Into<String>,
description: impl Into<String>,
progress_weight: u32,
) -> &mut Component {
let id = id.into();
let label = label.into();
let description = description.into();
if let Some(pos) = self.components.iter().position(|c| c.id == id) {
let existing = &mut self.components[pos];
existing.label = label;
existing.description = description;
existing.progress_weight = progress_weight;
existing.default = true;
existing.selected = true;
return existing;
}
self.components.push(Component {
id,
label,
description,
progress_weight,
default: true,
required: false,
selected: true,
});
self.components.last_mut().unwrap()
}
pub fn components(&self) -> &[Component] {
&self.components
}
pub fn is_component_selected(&self, id: &str) -> bool {
self.components
.iter()
.find(|c| c.id == id)
.map(|c| c.selected)
.unwrap_or(false)
}
pub fn set_component_selected(&mut self, id: &str, on: bool) {
if let Some(c) = self.components.iter_mut().find(|c| c.id == id) {
if c.required && !on {
return;
}
c.selected = on;
}
}
pub fn process_commandline(&mut self) -> Result<()> {
let args: Vec<String> = std::env::args().collect();
self.process_commandline_from(&args)
}
#[doc(hidden)]
pub fn process_commandline_from(&mut self, args: &[String]) -> Result<()> {
let mut exact: Option<Vec<String>> = None;
let mut with: Vec<String> = Vec::new();
let mut without: Vec<String> = Vec::new();
let mut list = false;
let mut i = 1;
#[cfg(windows)]
if args.get(i).map(|s| s.as_str()) == Some("--self-delete") {
i += 1;
}
while i < args.len() {
let a = &args[i];
let (flag, inline_val): (&str, Option<&str>) = if let Some(eq) = a.find('=') {
(&a[..eq], Some(&a[eq + 1..]))
} else {
(a.as_str(), None)
};
let take_val = |i: &mut usize| -> Result<String> {
if let Some(v) = inline_val {
Ok(v.to_string())
} else {
*i += 1;
args.get(*i)
.cloned()
.ok_or_else(|| anyhow!("{flag} requires a value"))
}
};
match flag {
"--headless" => self.headless = true,
"--list-components" => list = true,
"--components" => {
let v = take_val(&mut i)?;
exact = Some(v.split(',').map(|s| s.trim().to_string()).collect());
}
"--with" => {
let v = take_val(&mut i)?;
with.extend(v.split(',').map(|s| s.trim().to_string()));
}
"--without" => {
let v = take_val(&mut i)?;
without.extend(v.split(',').map(|s| s.trim().to_string()));
}
"--log" => {
let v = take_val(&mut i)?;
self.set_log_file(&v)?;
}
_ => {
let bare = flag.strip_prefix("--").unwrap_or(flag);
let opt = self
.options
.iter()
.find(|o| o.name == bare)
.cloned()
.ok_or_else(|| anyhow!("unknown flag: {flag}"))?;
let parsed = match opt.kind {
OptionKind::Flag => {
if inline_val.is_some() {
return Err(anyhow!(
"--{} is a flag and does not take a value",
opt.name
));
}
OptionValue::Flag(true)
}
OptionKind::String => OptionValue::String(take_val(&mut i)?),
OptionKind::Int => {
let v = take_val(&mut i)?;
let n: i64 = v.parse().map_err(|_| {
anyhow!("--{} expected an integer, got {v:?}", opt.name)
})?;
OptionValue::Int(n)
}
OptionKind::Bool => {
let v = take_val(&mut i)?;
let b = match v.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => true,
"false" | "0" | "no" | "off" => false,
_ => {
return Err(anyhow!(
"--{} expected true/false, got {v:?}",
opt.name
))
}
};
OptionValue::Bool(b)
}
};
self.option_values.insert(opt.name.clone(), parsed);
}
}
i += 1;
}
for opt in &self.options {
if matches!(opt.kind, OptionKind::Flag) && !self.option_values.contains_key(&opt.name) {
self.option_values
.insert(opt.name.clone(), OptionValue::Flag(false));
}
}
if list {
println!("Available components:");
for c in &self.components {
let marker = if c.required {
"*"
} else if c.default {
"+"
} else {
"-"
};
println!(" {} {:<20} {}", marker, c.id, c.label);
if !c.description.is_empty() {
println!(" {}", c.description);
}
}
println!("\n * required + default on - default off");
std::process::exit(0);
}
let known: std::collections::HashSet<String> =
self.components.iter().map(|c| c.id.clone()).collect();
for id in exact
.iter()
.flatten()
.chain(with.iter())
.chain(without.iter())
{
if !id.is_empty() && !known.contains(id) {
return Err(anyhow!("unknown component: {id}"));
}
}
if let Some(wanted) = exact {
let wanted: std::collections::HashSet<String> = wanted.into_iter().collect();
for c in self.components.iter_mut() {
let on = c.required || wanted.contains(&c.id);
c.selected = on;
}
}
for id in with {
self.set_component_selected(&id, true);
}
for id in without {
self.set_component_selected(&id, false);
}
Ok(())
}
pub fn set_out_dir(&mut self, dir: &str) {
self.out_dir = Some(PathBuf::from(dir));
}
pub fn set_progress_sink(&mut self, sink: Box<dyn ProgressSink>) {
self.sink = Some(sink);
}
pub fn clear_progress_sink(&mut self) {
self.sink = None;
}
pub fn total_steps(&self) -> u64 {
self.components
.iter()
.filter(|c| c.selected)
.map(|c| c.progress_weight as u64)
.sum()
}
pub fn reset_progress(&mut self) {
let mut state = self.progress.lock().unwrap();
state.steps_done = 0.0;
state.step_range_start = 0.0;
state.step_range_end = 0.0;
}
pub fn begin_step(&self, status: &str, weight: u32) {
self.emit_status(&Some(status.to_string()));
let mut state = self.progress.lock().unwrap();
state.step_range_start = state.steps_done;
state.step_range_end = state.steps_done + weight as f64;
drop(state);
self.emit_progress();
}
pub fn set_step_progress(&self, fraction: f64) {
let f = fraction.clamp(0.0, 1.0);
let mut state = self.progress.lock().unwrap();
let span = state.step_range_end - state.step_range_start;
state.steps_done = state.step_range_start + f * span;
drop(state);
self.emit_progress();
}
pub fn end_step(&self) {
let mut state = self.progress.lock().unwrap();
state.steps_done = state.step_range_end;
state.step_range_start = state.step_range_end;
drop(state);
self.emit_progress();
}
pub fn step(&self, status: &str, weight: u32) {
self.begin_step(status, weight);
self.end_step();
}
fn resolve_out_path(&self, dest_path: &str) -> Result<PathBuf> {
let p = Path::new(dest_path);
if p.is_absolute() {
return Ok(p.to_path_buf());
}
let out = self
.out_dir
.as_ref()
.ok_or_else(|| anyhow!("output directory not set; call set_out_dir() first"))?;
Ok(out.join(p))
}
fn decompress(data: &[u8], compression: &str) -> Result<Vec<u8>> {
#[allow(unused_imports)]
use std::io::Read;
match compression {
"" | "none" => Ok(data.to_vec()),
#[cfg(feature = "lzma")]
"lzma" => {
let mut out = Vec::new();
lzma_rs::lzma_decompress(&mut std::io::Cursor::new(data), &mut out)
.context("LZMA decompression failed")?;
Ok(out)
}
#[cfg(feature = "gzip")]
"gzip" => {
let mut decoder = flate2::read::GzDecoder::new(data);
let mut out = Vec::new();
decoder
.read_to_end(&mut out)
.context("gzip decompression failed")?;
Ok(out)
}
#[cfg(feature = "bzip2")]
"bzip2" => {
let mut decoder = bzip2::read::BzDecoder::new(data);
let mut out = Vec::new();
decoder
.read_to_end(&mut out)
.context("bzip2 decompression failed")?;
Ok(out)
}
other => Err(anyhow!("unsupported compression: {other}")),
}
}
fn emit_status(&self, status: &Option<String>) {
if let Some(s) = status.as_ref() {
if let Some(sink) = self.sink.as_ref() {
sink.set_status(s);
}
self.write_log_line(&format!("[*] {s}"));
}
}
fn emit_log(&self, log: &Option<String>) {
if let Some(l) = log.as_ref() {
if let Some(sink) = self.sink.as_ref() {
sink.log(l);
}
self.write_log_line(&format!(" {l}"));
}
}
fn emit_progress(&self) {
let Some(sink) = self.sink.as_ref() else {
return;
};
let state = self.progress.lock().unwrap();
let total = self.total_steps();
let fraction = if total == 0 {
0.0
} else {
(state.steps_done / total as f64).clamp(0.0, 1.0)
};
drop(state);
sink.set_progress(fraction);
}
fn run_weighted_step<F>(&self, weight: u32, f: F) -> Result<()>
where
F: FnOnce() -> Result<()>,
{
{
let mut state = self.progress.lock().unwrap();
state.step_range_start = state.steps_done;
state.step_range_end = state.steps_done + weight as f64;
}
self.emit_progress();
let result = f();
{
let mut state = self.progress.lock().unwrap();
state.steps_done = state.step_range_end;
state.step_range_start = state.step_range_end;
}
self.emit_progress();
result
}
pub fn file<'i>(&'i mut self, source: Source, dst: impl Into<String>) -> FileOp<'i> {
FileOp {
installer: self,
source,
dst: dst.into(),
status: None,
log: None,
overwrite: OverwriteMode::default(),
mode: None,
weight: 1,
}
}
pub fn dir<'i>(&'i mut self, source: Source, dst: impl Into<String>) -> DirOp<'i> {
DirOp {
installer: self,
source,
dst: dst.into(),
status: None,
log: None,
overwrite: OverwriteMode::default(),
mode: None,
filter: None,
on_error: None,
per_file_weight: 1,
}
}
pub fn uninstaller<'i>(&'i mut self, dst: impl Into<String>) -> UninstallerOp<'i> {
UninstallerOp {
installer: self,
dst: dst.into(),
status: None,
log: None,
overwrite: OverwriteMode::default(),
weight: 1,
}
}
pub fn mkdir<'i>(&'i mut self, dst: impl Into<String>) -> MkdirOp<'i> {
MkdirOp {
installer: self,
dst: dst.into(),
weight: 1,
status: None,
log: None,
}
}
pub fn remove<'i>(&'i mut self, path: impl Into<String>) -> RemoveOp<'i> {
RemoveOp {
installer: self,
path: path.into(),
weight: 1,
status: None,
log: None,
}
}
#[cfg(target_os = "windows")]
pub fn shortcut<'i>(
&'i mut self,
dst: impl Into<String>,
target: impl Into<String>,
) -> ShortcutOp<'i> {
ShortcutOp {
installer: self,
dst: dst.into(),
target: target.into(),
arguments: None,
working_dir: None,
description: None,
icon: None,
weight: 1,
status: None,
log: None,
}
}
pub fn exists(&self, path: &str) -> Result<bool> {
let p = self.resolve_out_path(path)?;
Ok(p.exists())
}
#[cfg(target_os = "windows")]
pub fn enable_self_delete(&mut self) {
if std::env::args().nth(1).as_deref() == Some("--self-delete") {
self.self_delete = true;
return;
}
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(e) => {
eprintln!("Error getting executable path: {e}");
std::process::exit(1);
}
};
let tmp_dir = std::env::temp_dir().join(format!("uninstall-{}", std::process::id()));
if let Err(e) = std::fs::create_dir_all(&tmp_dir) {
eprintln!("Error creating temp dir: {e}");
std::process::exit(1);
}
let tmp_exe = tmp_dir.join("uninstaller.exe");
if let Err(e) = std::fs::copy(&exe, &tmp_exe) {
eprintln!("Error copying to temp: {e}");
std::process::exit(1);
}
let mut args: Vec<String> = std::env::args().skip(1).collect();
args.insert(0, "--self-delete".to_string());
match std::process::Command::new(&tmp_exe)
.args(&args)
.current_dir(&tmp_dir)
.spawn()
{
Ok(_) => {}
Err(e) => {
eprintln!("Error spawning temp uninstaller: {e}");
std::process::exit(1);
}
}
std::process::exit(0);
}
pub fn install_main(&mut self, install_fn: impl Fn(&mut Installer) -> Result<()>) {
if let Err(e) = install_fn(self) {
eprintln!("Error: {e:#}");
std::process::exit(1);
}
}
pub fn uninstall_main(&mut self, uninstall_fn: impl Fn(&mut Installer) -> Result<()>) {
if let Err(e) = uninstall_fn(self) {
eprintln!("Error: {e:#}");
std::process::exit(1);
}
#[cfg(target_os = "windows")]
if self.self_delete {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
use std::os::windows::process::CommandExt;
use std::process::Stdio;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let dir_str = dir.to_string_lossy().into_owned();
let ps_cwd = std::env::temp_dir();
let _ = std::process::Command::new("powershell")
.args([
"-ExecutionPolicy",
"Bypass",
"-Command",
&format!(
"Start-Sleep 5; Remove-Item -Path '{}' -Recurse -Force",
dir_str
),
])
.current_dir(&ps_cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
}
}
}
}
pub struct FileOp<'i> {
installer: &'i mut Installer,
source: Source,
dst: String,
status: Option<String>,
log: Option<String>,
overwrite: OverwriteMode,
mode: Option<u32>,
weight: u32,
}
impl<'i> FileOp<'i> {
pub fn status(mut self, s: impl Into<String>) -> Self {
self.status = Some(s.into());
self
}
pub fn log(mut self, s: impl Into<String>) -> Self {
self.log = Some(s.into());
self
}
pub fn overwrite(mut self, mode: OverwriteMode) -> Self {
self.overwrite = mode;
self
}
pub fn mode(mut self, mode: u32) -> Self {
self.mode = Some(mode);
self
}
pub fn weight(mut self, w: u32) -> Self {
self.weight = w;
self
}
pub fn install(self) -> Result<()> {
self.installer.check_cancelled()?;
self.installer.emit_status(&self.status);
self.installer.emit_log(&self.log);
let (raw_bytes, compression) = find_file(self.installer.entries, self.source.0)?;
let dest = self.installer.resolve_out_path(&self.dst)?;
let overwrite = self.overwrite;
let mode = self.mode;
let weight = self.weight;
self.installer.run_weighted_step(weight, || {
match overwrite {
OverwriteMode::Overwrite => {}
OverwriteMode::Skip => {
if dest.exists() {
return Ok(());
}
}
OverwriteMode::Error => {
if dest.exists() {
return Err(anyhow!("destination already exists: {}", dest.display()));
}
}
OverwriteMode::Backup => {
if dest.exists() {
backup_path(&dest)?;
}
}
}
let bytes = Installer::decompress(raw_bytes, compression)?;
write_file(&dest, &bytes)?;
apply_mode(&dest, mode)?;
Ok(())
})
}
}
pub struct DirOp<'i> {
installer: &'i mut Installer,
source: Source,
dst: String,
status: Option<String>,
log: Option<String>,
overwrite: OverwriteMode,
mode: Option<u32>,
filter: Option<DirFilter>,
on_error: Option<DirErrorHandler>,
per_file_weight: u32,
}
impl<'i> DirOp<'i> {
pub fn status(mut self, s: impl Into<String>) -> Self {
self.status = Some(s.into());
self
}
pub fn log(mut self, s: impl Into<String>) -> Self {
self.log = Some(s.into());
self
}
pub fn overwrite(mut self, mode: OverwriteMode) -> Self {
self.overwrite = mode;
self
}
pub fn mode(mut self, mode: u32) -> Self {
self.mode = Some(mode);
self
}
pub fn filter<F: Fn(&str) -> bool + 'static>(mut self, f: F) -> Self {
self.filter = Some(Box::new(f));
self
}
pub fn on_error<F: Fn(&str, &anyhow::Error) -> ErrorAction + 'static>(mut self, f: F) -> Self {
self.on_error = Some(Box::new(f));
self
}
pub fn weight(mut self, w: u32) -> Self {
self.per_file_weight = w;
self
}
pub fn install(self) -> Result<()> {
self.installer.check_cancelled()?;
self.installer.emit_status(&self.status);
self.installer.emit_log(&self.log);
let children = find_dir(self.installer.entries, self.source.0)?;
let dest = self.installer.resolve_out_path(&self.dst)?;
std::fs::create_dir_all(&dest)
.with_context(|| format!("failed to create directory: {}", dest.display()))?;
install_children(
children,
&dest,
"",
self.installer,
self.overwrite,
self.mode,
self.filter.as_deref(),
self.on_error.as_deref(),
self.per_file_weight,
)
}
}
pub struct UninstallerOp<'i> {
installer: &'i mut Installer,
dst: String,
status: Option<String>,
log: Option<String>,
overwrite: OverwriteMode,
weight: u32,
}
impl<'i> UninstallerOp<'i> {
pub fn status(mut self, s: impl Into<String>) -> Self {
self.status = Some(s.into());
self
}
pub fn log(mut self, s: impl Into<String>) -> Self {
self.log = Some(s.into());
self
}
pub fn overwrite(mut self, mode: OverwriteMode) -> Self {
self.overwrite = mode;
self
}
pub fn weight(mut self, w: u32) -> Self {
self.weight = w;
self
}
pub fn install(self) -> Result<()> {
self.installer.check_cancelled()?;
self.installer.emit_status(&self.status);
self.installer.emit_log(&self.log);
let dest = self.installer.resolve_out_path(&self.dst)?;
let overwrite = self.overwrite;
let weight = self.weight;
let data_ptr = self.installer.uninstaller_data;
let compression = self.installer.uninstaller_compression;
self.installer.run_weighted_step(weight, || {
match overwrite {
OverwriteMode::Overwrite => {}
OverwriteMode::Skip => {
if dest.exists() {
return Ok(());
}
}
OverwriteMode::Error => {
if dest.exists() {
return Err(anyhow!("destination already exists: {}", dest.display()));
}
}
OverwriteMode::Backup => {
if dest.exists() {
backup_path(&dest)?;
}
}
}
let data = Installer::decompress(data_ptr, compression)?;
write_file(&dest, &data)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))
.with_context(|| format!("failed to set permissions on: {}", dest.display()))?;
}
Ok(())
})
}
}
pub struct MkdirOp<'i> {
installer: &'i mut Installer,
dst: String,
status: Option<String>,
log: Option<String>,
weight: u32,
}
impl<'i> MkdirOp<'i> {
pub fn status(mut self, s: impl Into<String>) -> Self {
self.status = Some(s.into());
self
}
pub fn log(mut self, s: impl Into<String>) -> Self {
self.log = Some(s.into());
self
}
pub fn weight(mut self, w: u32) -> Self {
self.weight = w;
self
}
pub fn install(self) -> Result<()> {
self.installer.check_cancelled()?;
self.installer.emit_status(&self.status);
self.installer.emit_log(&self.log);
let path = self.installer.resolve_out_path(&self.dst)?;
self.installer.run_weighted_step(self.weight, || {
std::fs::create_dir_all(&path)
.with_context(|| format!("failed to create directory: {}", path.display()))
})
}
}
pub struct RemoveOp<'i> {
installer: &'i mut Installer,
path: String,
status: Option<String>,
log: Option<String>,
weight: u32,
}
impl<'i> RemoveOp<'i> {
pub fn status(mut self, s: impl Into<String>) -> Self {
self.status = Some(s.into());
self
}
pub fn log(mut self, s: impl Into<String>) -> Self {
self.log = Some(s.into());
self
}
pub fn weight(mut self, w: u32) -> Self {
self.weight = w;
self
}
pub fn install(self) -> Result<()> {
self.installer.check_cancelled()?;
self.installer.emit_status(&self.status);
self.installer.emit_log(&self.log);
let p = self.installer.resolve_out_path(&self.path)?;
self.installer.run_weighted_step(self.weight, || {
if !p.exists() {
return Ok(());
}
if p.is_dir() {
std::fs::remove_dir_all(&p)
.with_context(|| format!("failed to remove directory: {}", p.display()))
} else {
std::fs::remove_file(&p)
.with_context(|| format!("failed to remove file: {}", p.display()))
}
})
}
}
#[cfg(target_os = "windows")]
pub struct ShortcutOp<'i> {
installer: &'i mut Installer,
dst: String,
target: String,
arguments: Option<String>,
working_dir: Option<String>,
description: Option<String>,
icon: Option<(String, i32)>,
status: Option<String>,
log: Option<String>,
weight: u32,
}
#[cfg(target_os = "windows")]
impl<'i> ShortcutOp<'i> {
pub fn status(mut self, s: impl Into<String>) -> Self {
self.status = Some(s.into());
self
}
pub fn log(mut self, s: impl Into<String>) -> Self {
self.log = Some(s.into());
self
}
pub fn arguments(mut self, s: impl Into<String>) -> Self {
self.arguments = Some(s.into());
self
}
pub fn working_dir(mut self, s: impl Into<String>) -> Self {
self.working_dir = Some(s.into());
self
}
pub fn description(mut self, s: impl Into<String>) -> Self {
self.description = Some(s.into());
self
}
pub fn icon(mut self, path: impl Into<String>, index: i32) -> Self {
self.icon = Some((path.into(), index));
self
}
pub fn weight(mut self, w: u32) -> Self {
self.weight = w;
self
}
pub fn install(self) -> Result<()> {
self.installer.check_cancelled()?;
self.installer.emit_status(&self.status);
self.installer.emit_log(&self.log);
let dst = self.installer.resolve_out_path(&self.dst)?;
let target = self.installer.resolve_out_path(&self.target)?;
let working_dir = match self.working_dir {
Some(ref w) => Some(self.installer.resolve_out_path(w)?),
None => None,
};
let icon = match self.icon {
Some((ref p, idx)) => Some((self.installer.resolve_out_path(p)?, idx)),
None => None,
};
let arguments = self.arguments;
let description = self.description;
self.installer.run_weighted_step(self.weight, || {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("failed to create shortcut parent: {}", parent.display())
})?;
}
let target_str = target
.to_str()
.ok_or_else(|| anyhow!("shortcut target path is not valid UTF-8"))?;
let mut link = mslnk::ShellLink::new(target_str)
.with_context(|| format!("failed to build shortcut for {}", target.display()))?;
if let Some(a) = arguments {
link.set_arguments(Some(a));
}
if let Some(w) = working_dir {
let w = w
.to_str()
.ok_or_else(|| anyhow!("shortcut working_dir is not valid UTF-8"))?
.to_string();
link.set_working_dir(Some(w));
}
if let Some(d) = description {
link.set_name(Some(d));
}
if let Some((p, idx)) = icon {
let p = p
.to_str()
.ok_or_else(|| anyhow!("shortcut icon path is not valid UTF-8"))?;
let loc = if idx == 0 {
p.to_string()
} else {
format!("{p},{idx}")
};
link.set_icon_location(Some(loc));
}
link.create_lnk(&dst)
.with_context(|| format!("failed to write shortcut: {}", dst.display()))?;
Ok(())
})
}
}
fn find_file(
entries: &'static [EmbeddedEntry],
hash: u64,
) -> Result<(&'static [u8], &'static str)> {
for entry in entries {
if let EmbeddedEntry::File {
source_path_hash,
data,
compression,
} = entry
{
if *source_path_hash == hash {
return Ok((data, compression));
}
}
}
Err(anyhow!(
"file not embedded in installer (hash: {hash:#018x})"
))
}
fn find_dir(entries: &'static [EmbeddedEntry], hash: u64) -> Result<&'static [DirChild]> {
for entry in entries {
if let EmbeddedEntry::Dir {
source_path_hash,
children,
} = entry
{
if *source_path_hash == hash {
return Ok(children);
}
}
}
Err(anyhow!(
"directory not embedded in installer (hash: {hash:#018x})"
))
}
#[allow(clippy::too_many_arguments)]
fn install_children(
children: &[DirChild],
dest: &Path,
rel_prefix: &str,
installer: &Installer,
overwrite: OverwriteMode,
mode: Option<u32>,
filter: Option<&DirFilterRef>,
on_error: Option<&DirErrorHandlerRef>,
per_file_weight: u32,
) -> Result<()> {
for child in children {
installer.check_cancelled()?;
let target = dest.join(child.name);
let rel = if rel_prefix.is_empty() {
child.name.to_string()
} else {
format!("{rel_prefix}/{}", child.name)
};
match &child.kind {
DirChildKind::File { data, compression } => {
if let Some(f) = filter {
if !f(&rel) {
continue;
}
}
let res = installer.run_weighted_step(per_file_weight, || {
install_one_file(data, compression, &target, overwrite, mode)
});
if let Err(e) = res {
match on_error {
Some(h) => match h(&rel, &e) {
ErrorAction::Skip => continue,
ErrorAction::Abort => return Err(e),
},
None => return Err(e),
}
}
}
DirChildKind::Dir { children } => {
std::fs::create_dir_all(&target)
.with_context(|| format!("failed to create dir: {}", target.display()))?;
install_children(
children,
&target,
&rel,
installer,
overwrite,
mode,
filter,
on_error,
per_file_weight,
)?;
}
}
}
Ok(())
}
fn install_one_file(
data: &[u8],
compression: &str,
dest: &Path,
overwrite: OverwriteMode,
mode: Option<u32>,
) -> Result<()> {
match overwrite {
OverwriteMode::Overwrite => {}
OverwriteMode::Skip => {
if dest.exists() {
return Ok(());
}
}
OverwriteMode::Error => {
if dest.exists() {
return Err(anyhow!("destination already exists: {}", dest.display()));
}
}
OverwriteMode::Backup => {
if dest.exists() {
backup_path(dest)?;
}
}
}
let bytes = Installer::decompress(data, compression)?;
write_file(dest, &bytes)?;
apply_mode(dest, mode)?;
Ok(())
}
fn backup_path(path: &Path) -> Result<()> {
let backup = path.with_extension(match path.extension() {
Some(ext) => format!("{}.bak", ext.to_string_lossy()),
None => "bak".to_string(),
});
if backup.exists() {
if backup.is_dir() {
std::fs::remove_dir_all(&backup)
.with_context(|| format!("failed to remove old backup: {}", backup.display()))?;
} else {
std::fs::remove_file(&backup)
.with_context(|| format!("failed to remove old backup: {}", backup.display()))?;
}
}
std::fs::rename(path, &backup)
.with_context(|| format!("failed to back up: {}", path.display()))?;
Ok(())
}
#[cfg(unix)]
fn apply_mode(path: &Path, mode: Option<u32>) -> Result<()> {
if let Some(m) = mode {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(m))
.with_context(|| format!("failed to set permissions on: {}", path.display()))?;
}
Ok(())
}
#[cfg(not(unix))]
fn apply_mode(_path: &Path, _mode: Option<u32>) -> Result<()> {
Ok(())
}
fn write_file(dest: &Path, data: &[u8]) -> Result<()> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create parent dir for: {}", dest.display()))?;
}
std::fs::write(dest, data).with_context(|| format!("failed to write: {}", dest.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn leak_entries(entries: Vec<EmbeddedEntry>) -> &'static [EmbeddedEntry] {
Box::leak(entries.into_boxed_slice())
}
fn leak_children(children: Vec<DirChild>) -> &'static [DirChild] {
Box::leak(children.into_boxed_slice())
}
fn leak_bytes(data: Vec<u8>) -> &'static [u8] {
Box::leak(data.into_boxed_slice())
}
fn make_installer(entries: Vec<EmbeddedEntry>, out_dir: &std::path::Path) -> Installer {
let mut i = Installer::new(leak_entries(entries), leak_bytes(vec![]), "");
i.set_out_dir(&out_dir.to_string_lossy());
i
}
fn compress_gzip(data: &[u8]) -> Vec<u8> {
use std::io::Write;
let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
enc.write_all(data).unwrap();
enc.finish().unwrap()
}
fn compress_lzma(data: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
lzma_rs::lzma_compress(&mut std::io::Cursor::new(data), &mut out).unwrap();
out
}
fn compress_bzip2(data: &[u8]) -> Vec<u8> {
use std::io::Write;
let mut enc = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::best());
enc.write_all(data).unwrap();
enc.finish().unwrap()
}
fn file_entry(path: &str, data: &'static [u8]) -> EmbeddedEntry {
let norm = path.replace('\\', "/");
EmbeddedEntry::File {
source_path_hash: source_path_hash_const(&norm),
data,
compression: "",
}
}
fn dir_entry(path: &str, children: Vec<DirChild>) -> EmbeddedEntry {
let norm = path.replace('\\', "/");
EmbeddedEntry::Dir {
source_path_hash: source_path_hash_const(&norm),
children: leak_children(children),
}
}
fn child_file(name: &str, data: &'static [u8]) -> DirChild {
DirChild {
name: Box::leak(name.to_string().into_boxed_str()),
kind: DirChildKind::File {
data,
compression: "",
},
}
}
fn child_dir(name: &str, children: Vec<DirChild>) -> DirChild {
DirChild {
name: Box::leak(name.to_string().into_boxed_str()),
kind: DirChildKind::Dir {
children: leak_children(children),
},
}
}
fn src(path: &str) -> Source {
let norm = path.replace('\\', "/");
Source(source_path_hash_const(&norm))
}
#[test]
fn source_path_hash_const_is_stable() {
assert_eq!(
source_path_hash_const("foo/bar.txt"),
source_path_hash_const("foo/bar.txt")
);
}
#[test]
fn source_path_hash_const_normalizes_backslashes() {
assert_eq!(
source_path_hash_const("foo\\bar.txt"),
source_path_hash_const("foo/bar.txt")
);
}
#[test]
fn source_path_hash_const_different_inputs_differ() {
assert_ne!(
source_path_hash_const("a.txt"),
source_path_hash_const("b.txt")
);
assert_ne!(source_path_hash_const(""), source_path_hash_const("a"));
}
#[test]
fn source_path_hash_const_known_value() {
let expected: u64 = {
let mut h: u64 = 14695981039346656037;
for b in "hello".bytes() {
h ^= b as u64;
h = h.wrapping_mul(1099511628211);
}
h
};
assert_eq!(source_path_hash_const("hello"), expected);
}
#[test]
fn file_writes_content() {
let tmp = tempfile::TempDir::new().unwrap();
let mut i = make_installer(vec![file_entry("vendor/lib.so", b"ELF")], tmp.path());
i.file(src("vendor/lib.so"), "lib.so").install().unwrap();
assert_eq!(std::fs::read(tmp.path().join("lib.so")).unwrap(), b"ELF");
}
#[test]
fn file_creates_parent_dirs() {
let tmp = tempfile::TempDir::new().unwrap();
let mut i = make_installer(vec![file_entry("data.txt", b"x")], tmp.path());
i.file(src("data.txt"), "a/b/out.txt").install().unwrap();
assert!(tmp.path().join("a/b/out.txt").exists());
}
#[test]
fn file_errors_when_not_embedded() {
let tmp = tempfile::TempDir::new().unwrap();
let mut i = make_installer(vec![], tmp.path());
assert!(i.file(src("missing.txt"), "out.txt").install().is_err());
}
#[test]
fn file_decompresses_on_write() {
let tmp = tempfile::TempDir::new().unwrap();
let original = b"compressed content";
let compressed = compress_gzip(original);
let data: &'static [u8] = leak_bytes(compressed);
let entry = EmbeddedEntry::File {
source_path_hash: source_path_hash_const("comp.gz"),
data,
compression: "gzip",
};
let mut i = make_installer(vec![entry], tmp.path());
i.file(src("comp.gz"), "out.txt").install().unwrap();
assert_eq!(std::fs::read(tmp.path().join("out.txt")).unwrap(), original);
}
#[test]
fn file_overwrite_skip_leaves_existing() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("out.txt"), b"OLD").unwrap();
let mut i = make_installer(vec![file_entry("x.txt", b"NEW")], tmp.path());
i.file(src("x.txt"), "out.txt")
.overwrite(OverwriteMode::Skip)
.install()
.unwrap();
assert_eq!(std::fs::read(tmp.path().join("out.txt")).unwrap(), b"OLD");
}
#[test]
fn file_overwrite_error_fails_if_exists() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("out.txt"), b"OLD").unwrap();
let mut i = make_installer(vec![file_entry("x.txt", b"NEW")], tmp.path());
let r = i
.file(src("x.txt"), "out.txt")
.overwrite(OverwriteMode::Error)
.install();
assert!(r.is_err());
}
#[test]
fn file_overwrite_backup_renames_existing() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("out.txt"), b"OLD").unwrap();
let mut i = make_installer(vec![file_entry("x.txt", b"NEW")], tmp.path());
i.file(src("x.txt"), "out.txt")
.overwrite(OverwriteMode::Backup)
.install()
.unwrap();
assert_eq!(std::fs::read(tmp.path().join("out.txt")).unwrap(), b"NEW");
assert_eq!(
std::fs::read(tmp.path().join("out.txt.bak")).unwrap(),
b"OLD"
);
}
#[test]
fn dir_installs_tree() {
let tmp = tempfile::TempDir::new().unwrap();
let entries = vec![dir_entry(
"pkg",
vec![
child_file("readme.txt", b"readme"),
child_dir("bin", vec![child_file("app", b"binary")]),
],
)];
let mut i = make_installer(entries, tmp.path());
i.dir(src("pkg"), "out").install().unwrap();
assert_eq!(
std::fs::read(tmp.path().join("out/readme.txt")).unwrap(),
b"readme"
);
assert_eq!(
std::fs::read(tmp.path().join("out/bin/app")).unwrap(),
b"binary"
);
}
#[test]
fn dir_filter_excludes_files() {
let tmp = tempfile::TempDir::new().unwrap();
let entries = vec![dir_entry(
"pkg",
vec![child_file("keep.txt", b"K"), child_file("drop.txt", b"D")],
)];
let mut i = make_installer(entries, tmp.path());
i.dir(src("pkg"), "out")
.filter(|rel| !rel.starts_with("drop"))
.install()
.unwrap();
assert!(tmp.path().join("out/keep.txt").exists());
assert!(!tmp.path().join("out/drop.txt").exists());
}
#[test]
fn dir_on_error_skip_continues() {
let tmp = tempfile::TempDir::new().unwrap();
let bad: &'static [u8] = b"garbage";
let children = vec![
child_file("good.txt", b"OK"),
DirChild {
name: Box::leak("bad.bin".to_string().into_boxed_str()),
kind: DirChildKind::File {
data: bad,
compression: "zstd", },
},
child_file("tail.txt", b"T"),
];
let entries = vec![dir_entry("pkg", children)];
let mut i = make_installer(entries, tmp.path());
let skipped = std::sync::atomic::AtomicBool::new(false);
let skipped_ref: &'static std::sync::atomic::AtomicBool = Box::leak(Box::new(skipped));
i.dir(src("pkg"), "out")
.on_error(|_rel, _err| {
skipped_ref.store(true, std::sync::atomic::Ordering::Relaxed);
ErrorAction::Skip
})
.install()
.unwrap();
assert!(skipped_ref.load(std::sync::atomic::Ordering::Relaxed));
assert!(tmp.path().join("out/good.txt").exists());
assert!(tmp.path().join("out/tail.txt").exists());
assert!(!tmp.path().join("out/bad.bin").exists());
}
const SAMPLE: &[u8] = b"Hello, InstallRS test data! Hello, InstallRS!";
#[test]
fn decompress_none_empty() {
assert_eq!(Installer::decompress(SAMPLE, "").unwrap(), SAMPLE);
}
#[test]
fn decompress_none_explicit() {
assert_eq!(Installer::decompress(SAMPLE, "none").unwrap(), SAMPLE);
}
#[test]
fn decompress_lzma_roundtrip() {
let compressed = compress_lzma(SAMPLE);
assert_eq!(Installer::decompress(&compressed, "lzma").unwrap(), SAMPLE);
}
#[test]
fn decompress_gzip_roundtrip() {
let compressed = compress_gzip(SAMPLE);
assert_eq!(Installer::decompress(&compressed, "gzip").unwrap(), SAMPLE);
}
#[test]
fn decompress_bzip2_roundtrip() {
let compressed = compress_bzip2(SAMPLE);
assert_eq!(Installer::decompress(&compressed, "bzip2").unwrap(), SAMPLE);
}
#[test]
fn decompress_unknown_method_errors() {
let err = Installer::decompress(b"data", "zstd").unwrap_err();
assert!(err.to_string().contains("unsupported compression"));
}
#[test]
fn mkdir_creates_nested_directories() {
let tmp = tempfile::TempDir::new().unwrap();
let mut i = make_installer(vec![], tmp.path());
i.mkdir("a/b/c/d").install().unwrap();
assert!(tmp.path().join("a/b/c/d").is_dir());
}
#[test]
fn mkdir_is_idempotent() {
let tmp = tempfile::TempDir::new().unwrap();
let mut i = make_installer(vec![], tmp.path());
i.mkdir("exists").install().unwrap();
i.mkdir("exists").install().unwrap();
}
#[test]
fn mkdir_requires_out_dir() {
let mut i = Installer::new(leak_entries(vec![]), leak_bytes(vec![]), "none");
assert!(i.mkdir("foo").install().is_err());
}
#[test]
fn remove_deletes_file() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("victim.txt"), b"x").unwrap();
let mut i = make_installer(vec![], tmp.path());
i.remove("victim.txt").install().unwrap();
assert!(!tmp.path().join("victim.txt").exists());
}
#[test]
fn remove_deletes_directory_recursively() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join("tree/leaf")).unwrap();
std::fs::write(tmp.path().join("tree/leaf/f.txt"), b"x").unwrap();
let mut i = make_installer(vec![], tmp.path());
i.remove("tree").install().unwrap();
assert!(!tmp.path().join("tree").exists());
}
#[test]
fn remove_noop_when_nonexistent() {
let tmp = tempfile::TempDir::new().unwrap();
let mut i = make_installer(vec![], tmp.path());
i.remove("does_not_exist.txt").install().unwrap();
}
#[test]
fn exists_true_for_file() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("present.txt"), b"hi").unwrap();
let i = make_installer(vec![], tmp.path());
assert!(i.exists("present.txt").unwrap());
}
#[test]
fn exists_false_for_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let i = make_installer(vec![], tmp.path());
assert!(!i.exists("absent.txt").unwrap());
}
#[test]
fn exists_true_for_directory() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("mydir")).unwrap();
let i = make_installer(vec![], tmp.path());
assert!(i.exists("mydir").unwrap());
}
struct TestSink {
statuses: Mutex<Vec<String>>,
progresses: Mutex<Vec<f64>>,
logs: Mutex<Vec<String>>,
}
impl ProgressSink for TestSink {
fn set_status(&self, s: &str) {
self.statuses.lock().unwrap().push(s.to_string());
}
fn set_progress(&self, f: f64) {
self.progresses.lock().unwrap().push(f);
}
fn log(&self, m: &str) {
self.logs.lock().unwrap().push(m.to_string());
}
}
#[test]
fn file_install_reports_progress_and_status() {
let tmp = tempfile::TempDir::new().unwrap();
let mut i = make_installer(vec![file_entry("a.txt", b"HELLO")], tmp.path());
let sink = std::sync::Arc::new(TestSink {
statuses: Mutex::new(Vec::new()),
progresses: Mutex::new(Vec::new()),
logs: Mutex::new(Vec::new()),
});
struct Forward(std::sync::Arc<TestSink>);
impl ProgressSink for Forward {
fn set_status(&self, s: &str) {
self.0.set_status(s)
}
fn set_progress(&self, f: f64) {
self.0.set_progress(f)
}
fn log(&self, m: &str) {
self.0.log(m)
}
}
i.set_progress_sink(Box::new(Forward(sink.clone())));
i.component("core", "Core", "", 1).required();
i.file(src("a.txt"), "out.txt")
.status("installing")
.log("copying a.txt")
.install()
.unwrap();
assert_eq!(sink.statuses.lock().unwrap().as_slice(), &["installing"]);
assert_eq!(sink.logs.lock().unwrap().as_slice(), &["copying a.txt"]);
let progs = sink.progresses.lock().unwrap();
assert!(progs.len() >= 2);
assert!((progs.last().unwrap() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn total_steps_sums_selected_components() {
let mut i = make_bare_installer();
i.component("core", "Core", "", 5).required();
i.component("docs", "Docs", "", 3);
i.component("extras", "Extras", "", 2).default_off();
assert_eq!(i.total_steps(), 8);
i.set_component_selected("extras", true);
assert_eq!(i.total_steps(), 10);
}
fn make_bare_installer() -> Installer {
Installer::new(leak_entries(vec![]), leak_bytes(vec![]), "")
}
#[test]
fn component_register_and_query() {
let mut i = make_bare_installer();
i.component("core", "Core", "", 1).required();
i.component("docs", "Docs", "", 1).default_off();
i.component("extras", "Extras", "", 1);
assert_eq!(i.components().len(), 3);
assert!(i.is_component_selected("core"));
assert!(!i.is_component_selected("docs"));
assert!(i.is_component_selected("extras"));
assert!(!i.is_component_selected("nope"));
}
#[test]
fn component_required_cannot_be_deselected() {
let mut i = make_bare_installer();
i.component("core", "Core", "", 1).required();
i.set_component_selected("core", false);
assert!(i.is_component_selected("core"));
}
#[test]
fn component_reregistration_updates_in_place() {
let mut i = make_bare_installer();
i.component("docs", "v1", "", 1);
i.component("docs", "v2", "", 1).default_off();
assert_eq!(i.components().len(), 1);
assert_eq!(i.components()[0].label, "v2");
assert!(!i.is_component_selected("docs"));
}
#[test]
fn cli_exact_components_selects_only_listed() {
let mut i = make_bare_installer();
i.component("a", "A", "", 1);
i.component("b", "B", "", 1);
i.component("c", "C", "", 1);
let args = vec!["installer".into(), "--components".into(), "a,c".into()];
i.process_commandline_from(&args).unwrap();
assert!(i.is_component_selected("a"));
assert!(!i.is_component_selected("b"));
assert!(i.is_component_selected("c"));
}
#[test]
fn cli_exact_components_keeps_required() {
let mut i = make_bare_installer();
i.component("core", "Core", "", 1).required();
i.component("docs", "Docs", "", 1);
let args = vec!["installer".into(), "--components=docs".into()];
i.process_commandline_from(&args).unwrap();
assert!(i.is_component_selected("core"));
assert!(i.is_component_selected("docs"));
}
#[test]
fn cli_with_and_without_delta() {
let mut i = make_bare_installer();
i.component("a", "A", "", 1).default_off();
i.component("b", "B", "", 1);
let args = vec![
"installer".into(),
"--with".into(),
"a".into(),
"--without".into(),
"b".into(),
];
i.process_commandline_from(&args).unwrap();
assert!(i.is_component_selected("a"));
assert!(!i.is_component_selected("b"));
}
#[test]
fn cli_unknown_component_errors() {
let mut i = make_bare_installer();
i.component("a", "A", "", 1);
let args = vec!["installer".into(), "--with=bogus".into()];
assert!(i.process_commandline_from(&args).is_err());
}
#[test]
fn cli_without_cannot_disable_required() {
let mut i = make_bare_installer();
i.component("core", "Core", "", 1).required();
let args = vec!["installer".into(), "--without=core".into()];
i.process_commandline_from(&args).unwrap();
assert!(i.is_component_selected("core"));
}
#[test]
fn cli_headless_flag_sets_field() {
let mut i = make_bare_installer();
let args = vec!["installer".into(), "--headless".into()];
i.process_commandline_from(&args).unwrap();
assert!(i.headless);
}
#[test]
fn cli_user_options_parse_and_typed_read() {
let mut i = make_bare_installer();
i.option("config", OptionKind::String);
i.option("port", OptionKind::Int);
i.option("verbose", OptionKind::Flag);
i.option("fast", OptionKind::Bool);
let args = vec![
"installer".into(),
"--config".into(),
"/etc/my.conf".into(),
"--port=8080".into(),
"--verbose".into(),
"--fast=yes".into(),
];
i.process_commandline_from(&args).unwrap();
assert_eq!(
i.get_option::<String>("config").as_deref(),
Some("/etc/my.conf")
);
assert_eq!(i.get_option::<i64>("port"), Some(8080));
assert_eq!(i.get_option::<i32>("port"), Some(8080));
assert_eq!(i.get_option::<bool>("verbose"), Some(true));
assert_eq!(i.get_option::<bool>("fast"), Some(true));
}
#[test]
fn cli_flag_absent_is_false() {
let mut i = make_bare_installer();
i.option("verbose", OptionKind::Flag);
i.process_commandline_from(&["installer".into()]).unwrap();
assert_eq!(i.get_option::<bool>("verbose"), Some(false));
}
#[test]
fn cli_unknown_flag_errors() {
let mut i = make_bare_installer();
let args = vec!["installer".into(), "--nope".into()];
assert!(i.process_commandline_from(&args).is_err());
}
#[test]
fn cli_int_option_rejects_non_integer() {
let mut i = make_bare_installer();
i.option("port", OptionKind::Int);
let args = vec!["installer".into(), "--port=abc".into()];
assert!(i.process_commandline_from(&args).is_err());
}
#[test]
fn cli_flag_with_value_errors() {
let mut i = make_bare_installer();
i.option("verbose", OptionKind::Flag);
let args = vec!["installer".into(), "--verbose=true".into()];
assert!(i.process_commandline_from(&args).is_err());
}
#[test]
fn log_file_captures_status_log_and_error() {
let tmp = tempfile::TempDir::new().unwrap();
let log_path = tmp.path().join("install.log");
let mut i = make_bare_installer();
let args = vec![
"installer".into(),
"--log".into(),
log_path.to_str().unwrap().into(),
];
i.process_commandline_from(&args).unwrap();
i.emit_status(&Some("Installing foo".into()));
i.emit_log(&Some("wrote foo.exe".into()));
i.log_error(&anyhow!("disk full"));
i.clear_log_file();
let contents = std::fs::read_to_string(&log_path).unwrap();
assert!(contents.contains("install session started"));
assert!(contents.contains("[*] Installing foo"));
assert!(contents.contains(" wrote foo.exe"));
assert!(contents.contains("[ERROR] disk full"));
}
}