use crate::api::{Cask, CaskDetails};
use crate::bottle::{homebrew_prefix, BottleDownloader, DownloadTotals};
use crate::error::{Result, WaxError};
use crate::ui::dirs;
use crate::version::sort_versions;
use indicatif::ProgressBar;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::fs;
use tracing::{debug, info, instrument};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledCask {
pub name: String,
pub version: String,
pub install_date: i64,
#[serde(default)]
pub artifact_type: Option<String>,
#[serde(default)]
pub binary_paths: Option<Vec<String>>,
#[serde(default)]
pub app_name: Option<String>,
}
static CASK_STATE_WRITE_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
fn cask_state_write_lock() -> &'static tokio::sync::Mutex<()> {
CASK_STATE_WRITE_LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}
pub struct CaskState {
legacy_state_path: PathBuf,
}
fn temp_path_for(path: &Path) -> PathBuf {
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("installed_casks.json");
path.with_file_name(format!(".{}.{}.{}.tmp", file_name, pid, nanos))
}
fn normalize_existing_prefix(path: &Path) -> PathBuf {
if let Ok(normalized) = dunce::canonicalize(path) {
return normalized;
}
let mut suffix = PathBuf::new();
let mut current = path;
while let Some(parent) = current.parent() {
if let Some(name) = current.file_name() {
suffix = Path::new(name).join(suffix);
}
if let Ok(normalized_parent) = dunce::canonicalize(parent) {
return normalized_parent.join(suffix);
}
current = parent;
}
path.to_path_buf()
}
fn path_modified_unix_seconds(path: &Path) -> i64 {
std::fs::metadata(path)
.ok()
.and_then(|metadata| metadata.modified().ok())
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
fn latest_version_dir(cask_path: &Path) -> Option<(String, i64)> {
let mut version_dates = HashMap::new();
let entries = std::fs::read_dir(cask_path).ok()?;
for entry in entries.filter_map(|entry| entry.ok()) {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
version_dates.insert(name, path_modified_unix_seconds(&entry.path()));
}
let mut versions = version_dates.keys().cloned().collect::<Vec<_>>();
if versions.is_empty() {
return None;
}
sort_versions(&mut versions);
let version = versions.pop()?;
let install_date = version_dates.get(&version).copied().unwrap_or(0);
Some((version, install_date))
}
fn latest_metadata_version(cask_path: &Path) -> Option<(String, i64)> {
let metadata_dir = cask_path.join(".metadata");
let mut latest: Option<(String, String, i64)> = None;
let version_entries = std::fs::read_dir(metadata_dir).ok()?;
for version_entry in version_entries.filter_map(|entry| entry.ok()) {
let version = version_entry.file_name().to_string_lossy().to_string();
if version.starts_with('.') {
continue;
}
let Ok(version_type) = version_entry.file_type() else {
continue;
};
if !version_type.is_dir() {
continue;
}
let Ok(timestamp_entries) = std::fs::read_dir(version_entry.path()) else {
continue;
};
for timestamp_entry in timestamp_entries.filter_map(|entry| entry.ok()) {
let timestamp = timestamp_entry.file_name().to_string_lossy().to_string();
if timestamp.starts_with('.') {
continue;
}
let Ok(timestamp_type) = timestamp_entry.file_type() else {
continue;
};
if !timestamp_type.is_dir() {
continue;
}
let install_date = path_modified_unix_seconds(×tamp_entry.path());
let replace = latest
.as_ref()
.map(|(_, latest_timestamp, _)| timestamp > *latest_timestamp)
.unwrap_or(true);
if replace {
latest = Some((version.clone(), timestamp, install_date));
}
}
}
latest.map(|(version, _, install_date)| (version, install_date))
}
fn latest_caskroom_version(cask_path: &Path) -> Option<(String, i64)> {
latest_metadata_version(cask_path).or_else(|| latest_version_dir(cask_path))
}
fn merge_caskroom_entry(
casks: &mut HashMap<String, InstalledCask>,
name: String,
version: String,
install_date: i64,
) {
let existing = casks.get(&name).cloned();
casks.insert(
name.clone(),
InstalledCask {
name,
version,
install_date,
artifact_type: existing
.as_ref()
.and_then(|cask| cask.artifact_type.clone()),
binary_paths: existing.as_ref().and_then(|cask| cask.binary_paths.clone()),
app_name: existing.and_then(|cask| cask.app_name),
},
);
}
#[cfg(test)]
fn cask_path_has_homebrew_metadata(cask_path: &Path) -> bool {
let metadata_dir = cask_path.join(".metadata");
let Ok(version_entries) = std::fs::read_dir(metadata_dir) else {
return false;
};
for version_entry in version_entries.filter_map(|e| e.ok()) {
let Ok(version_type) = version_entry.file_type() else {
continue;
};
if !version_type.is_dir() {
continue;
}
let Ok(timestamp_entries) = std::fs::read_dir(version_entry.path()) else {
continue;
};
for timestamp_entry in timestamp_entries.filter_map(|e| e.ok()) {
let Ok(timestamp_type) = timestamp_entry.file_type() else {
continue;
};
if !timestamp_type.is_dir() {
continue;
}
let casks_dir = timestamp_entry.path().join("Casks");
let Ok(caskfiles) = std::fs::read_dir(casks_dir) else {
continue;
};
for caskfile in caskfiles.filter_map(|e| e.ok()) {
let path = caskfile.path();
if path.extension().and_then(|ext| ext.to_str()) == Some("rb")
|| path.extension().and_then(|ext| ext.to_str()) == Some("json")
{
return true;
}
}
}
}
false
}
fn homebrew_metadata_timestamp(install_date: i64) -> String {
let seconds = install_date.max(0);
let days = seconds.div_euclid(86_400);
let seconds_of_day = seconds.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
let hour = seconds_of_day / 3_600;
let minute = (seconds_of_day % 3_600) / 60;
let second = seconds_of_day % 60;
format!("{year:04}{month:02}{day:02}{hour:02}{minute:02}{second:02}.000")
}
fn civil_from_days(days_since_unix_epoch: i64) -> (i32, u32, u32) {
let z = days_since_unix_epoch + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = mp + if mp < 10 { 3 } else { -9 };
let year = y + if month <= 2 { 1 } else { 0 };
(year as i32, month as u32, day as u32)
}
fn cask_source_path(token: &str) -> String {
let shard = token.chars().next().unwrap_or('x');
format!("Casks/{shard}/{token}.rb")
}
fn fallback_cask_display_name(token: &str) -> String {
token
.split('-')
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn cask_artifacts_from_installed(cask: &InstalledCask) -> Vec<serde_json::Value> {
let mut artifacts = Vec::new();
if let Some(app_name) = &cask.app_name {
artifacts.push(json!({ "app": [app_name] }));
}
if let Some(binary_paths) = &cask.binary_paths {
for binary_path in binary_paths {
let source = Path::new(binary_path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(binary_path);
artifacts.push(json!({ "binary": [source, { "target": binary_path }] }));
}
}
artifacts
}
fn cask_metadata_from_details(
cask: &InstalledCask,
details: &CaskDetails,
) -> Result<serde_json::Value> {
let mut source = serde_json::to_value(details)?;
if let Some(obj) = source.as_object_mut() {
obj.entry("full_token")
.or_insert_with(|| serde_json::Value::String(cask.name.clone()));
obj.entry("tap")
.or_insert_with(|| serde_json::Value::String("homebrew/cask".to_string()));
obj.entry("ruby_source_path")
.or_insert_with(|| serde_json::Value::String(cask_source_path(&cask.name)));
obj.insert(
"version".to_string(),
serde_json::Value::String(cask.version.clone()),
);
if !obj.contains_key("artifacts") {
obj.insert(
"artifacts".to_string(),
serde_json::Value::Array(cask_artifacts_from_installed(cask)),
);
}
}
Ok(source)
}
fn cask_metadata_from_installed(cask: &InstalledCask, summary: Option<&Cask>) -> serde_json::Value {
let names = summary
.map(|c| c.name.clone())
.filter(|names| !names.is_empty())
.unwrap_or_else(|| vec![fallback_cask_display_name(&cask.name)]);
let desc = summary.and_then(|c| c.desc.clone());
let homepage = summary
.map(|c| c.homepage.clone())
.unwrap_or_else(|| format!("https://formulae.brew.sh/cask/{}", cask.name));
let full_token = summary
.map(|c| c.full_token.clone())
.unwrap_or_else(|| cask.name.clone());
json!({
"token": cask.name,
"full_token": full_token,
"name": names,
"desc": desc,
"homepage": homepage,
"version": cask.version,
"url": format!("https://formulae.brew.sh/cask/{}", cask.name),
"sha256": "no_check",
"artifacts": cask_artifacts_from_installed(cask),
"tap": "homebrew/cask",
"ruby_source_path": cask_source_path(&cask.name),
})
}
impl CaskState {
pub fn new() -> Result<Self> {
let legacy_state_path = dirs::wax_dir()?.join("installed_casks.json");
Ok(Self { legacy_state_path })
}
pub fn caskroom_dir() -> PathBuf {
homebrew_prefix().join("Caskroom")
}
pub fn user_caskroom_dir() -> Result<PathBuf> {
Ok(dirs::home_dir()?
.join(".local")
.join("wax")
.join("Caskroom"))
}
pub async fn load(&self) -> Result<HashMap<String, InstalledCask>> {
let mut casks = HashMap::new();
if self.legacy_state_path.exists() {
if let Ok(json) = fs::read_to_string(&self.legacy_state_path).await {
if let Ok(legacy_casks) =
serde_json::from_str::<HashMap<String, InstalledCask>>(&json)
{
casks.extend(legacy_casks);
}
}
}
Ok(casks)
}
#[allow(dead_code)]
async fn scan_cask_version_dir(&self, cask_path: &Path) -> Result<(String, i64)> {
Ok(latest_caskroom_version(cask_path).unwrap_or_else(|| ("unknown".to_string(), 0)))
}
pub async fn sync_from_caskrooms(&self) -> Result<HashSet<String>> {
let _guard = cask_state_write_lock().lock().await;
let mut casks = self.load().await?;
let mut synced_names = HashSet::new();
let mut roots = vec![Self::caskroom_dir()];
if let Ok(user_dir) = Self::user_caskroom_dir() {
roots.push(user_dir);
}
for root in roots {
let entries = match std::fs::read_dir(&root) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.filter_map(|entry| entry.ok()) {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() || file_type.is_symlink() {
continue;
}
let Some((version, install_date)) = latest_caskroom_version(&entry.path()) else {
continue;
};
if version == "unknown" {
continue;
}
synced_names.insert(name.clone());
merge_caskroom_entry(&mut casks, name, version, install_date);
}
}
self.save(&casks).await?;
Ok(synced_names)
}
pub async fn save(&self, casks: &HashMap<String, InstalledCask>) -> Result<()> {
let parent = self
.legacy_state_path
.parent()
.ok_or_else(|| WaxError::CacheError("Cannot determine parent directory".into()))?;
fs::create_dir_all(parent).await?;
let json = serde_json::to_string_pretty(casks)?;
let temp_path = temp_path_for(&self.legacy_state_path);
fs::write(&temp_path, json).await?;
fs::rename(&temp_path, &self.legacy_state_path)
.await
.inspect_err(|_| {
let _ = std::fs::remove_file(&temp_path);
})?;
Ok(())
}
#[allow(dead_code)]
pub async fn add(&self, cask: InstalledCask) -> Result<()> {
self.add_with_details(cask, None).await
}
pub async fn add_with_details(
&self,
cask: InstalledCask,
details: Option<&CaskDetails>,
) -> Result<()> {
let _guard = cask_state_write_lock().lock().await;
let mut casks = self.load().await?;
let caskroom = Self::caskroom_dir();
let cask_dir = caskroom.join(&cask.name);
let version_dir = cask_dir.join(&cask.version);
fs::create_dir_all(&version_dir).await?;
if let Some(app_name) = &cask.app_name {
let app_path = PathBuf::from("/Applications").join(app_name);
let link_path = version_dir.join(app_name);
if app_path.exists() && !link_path.exists() {
#[cfg(unix)]
if let Err(e) = tokio::fs::symlink(&app_path, &link_path).await {
tracing::warn!(
"Failed to create Caskroom symlink {:?} -> {:?}: {}",
link_path,
app_path,
e
);
}
}
}
let metadata = match details {
Some(details) => cask_metadata_from_details(&cask, details)?,
None => cask_metadata_from_installed(&cask, None),
};
self.write_homebrew_metadata_value(&cask, &metadata).await?;
casks.insert(cask.name.clone(), cask);
self.save(&casks).await?;
Ok(())
}
async fn write_homebrew_metadata_value(
&self,
cask: &InstalledCask,
source: &serde_json::Value,
) -> Result<bool> {
let cask_dir = Self::caskroom_dir().join(&cask.name);
let timestamp = homebrew_metadata_timestamp(cask.install_date);
let metadata_dir = cask_dir
.join(".metadata")
.join(&cask.version)
.join(timestamp)
.join("Casks");
let metadata_file = metadata_dir.join(format!("{}.json", cask.name));
if metadata_file.exists() {
return Ok(false);
}
fs::create_dir_all(&metadata_dir).await?;
let json = serde_json::to_string_pretty(source)?;
fs::write(metadata_file, json).await?;
Ok(true)
}
pub async fn remove(&self, name: &str) -> Result<()> {
let _guard = cask_state_write_lock().lock().await;
let mut casks = self.load().await?;
let caskroom = Self::caskroom_dir();
let cask_dir = caskroom.join(name);
if cask_dir.exists() {
let _ = fs::remove_dir_all(&cask_dir).await;
}
if let Ok(user_dir) = Self::user_caskroom_dir() {
let user_cask_dir = user_dir.join(name);
if user_cask_dir.exists() {
let _ = fs::remove_dir_all(&user_cask_dir).await;
}
}
casks.remove(name);
self.save(&casks).await?;
Ok(())
}
}
async fn installed_cask_version_dir(cask: &InstalledCask) -> Result<Option<PathBuf>> {
let mut candidates = vec![CaskState::caskroom_dir()
.join(&cask.name)
.join(&cask.version)];
if let Ok(user_dir) = CaskState::user_caskroom_dir() {
candidates.push(user_dir.join(&cask.name).join(&cask.version));
}
for candidate in candidates {
if candidate.exists() {
return Ok(Some(candidate));
}
}
Ok(None)
}
async fn replace_path_with_link(source: &Path, dest: &Path) -> Result<()> {
if let Ok(metadata) = fs::symlink_metadata(dest).await {
let file_type = metadata.file_type();
if file_type.is_symlink() || file_type.is_file() {
fs::remove_file(dest).await.ok();
} else if file_type.is_dir() {
fs::remove_dir_all(dest).await.ok();
}
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).await?;
}
#[cfg(unix)]
{
tokio::fs::symlink(source, dest).await?;
}
#[cfg(not(unix))]
{
if source.is_dir() {
crate::ui::copy_dir_all(source, dest)?;
} else {
fs::copy(source, dest).await?;
}
}
Ok(())
}
async fn remove_path_if_present(path: &Path) -> Result<()> {
if let Ok(metadata) = fs::symlink_metadata(path).await {
let file_type = metadata.file_type();
if file_type.is_dir() && !file_type.is_symlink() {
fs::remove_dir_all(path).await.ok();
} else {
fs::remove_file(path).await.ok();
}
}
Ok(())
}
pub async fn relink_installed_cask(cask: &InstalledCask) -> Result<Vec<PathBuf>> {
let mut links = Vec::new();
let Some(version_dir) = installed_cask_version_dir(cask).await? else {
return Ok(links);
};
if let Some(app_name) = &cask.app_name {
#[cfg(target_os = "macos")]
{
let app_path = PathBuf::from("/Applications").join(app_name);
let link_path = version_dir.join(app_name);
if app_path.exists() {
replace_path_with_link(&app_path, &link_path).await?;
links.push(link_path);
}
}
#[cfg(not(target_os = "macos"))]
{
let link_path = version_dir.join(app_name);
if link_path.exists() {
links.push(link_path);
}
}
}
if let Some(binary_paths) = &cask.binary_paths {
for binary_path in binary_paths {
let dest = PathBuf::from(binary_path);
let Some(name) = dest.file_name().and_then(|n| n.to_str()) else {
continue;
};
let source = version_dir.join(name);
if source.exists() {
replace_path_with_link(&source, &dest).await?;
links.push(dest);
}
}
}
Ok(links)
}
pub async fn unlink_installed_cask(cask: &InstalledCask) -> Result<Vec<PathBuf>> {
let mut removed = Vec::new();
let version_dir = installed_cask_version_dir(cask).await?;
if let Some(app_name) = &cask.app_name {
if let Some(version_dir) = &version_dir {
let link_path = version_dir.join(app_name);
if link_path.exists() {
remove_path_if_present(&link_path).await?;
removed.push(link_path);
}
}
}
if let Some(binary_paths) = &cask.binary_paths {
for binary_path in binary_paths {
let dest = PathBuf::from(binary_path);
if dest.exists() {
remove_path_if_present(&dest).await?;
removed.push(dest);
}
}
}
Ok(removed)
}
impl Default for CaskState {
fn default() -> Self {
Self::new().expect("Failed to initialize cask state")
}
}
pub struct StagingContext {
pub staging_root: PathBuf,
mount_point: Option<PathBuf>,
_temp_dir: Option<tempfile::TempDir>,
}
pub struct RollbackContext {
installed_paths: Vec<PathBuf>,
committed: bool,
}
impl RollbackContext {
pub fn new() -> Self {
Self {
installed_paths: Vec::new(),
committed: false,
}
}
pub fn add(&mut self, path: PathBuf) {
self.installed_paths.push(path);
}
pub fn commit(&mut self) {
self.committed = true;
}
}
impl Drop for RollbackContext {
fn drop(&mut self) {
if !self.committed && !self.installed_paths.is_empty() {
crate::signal::println_through_active_multi(format!(
" ⚠️ rolling back {} partially installed artifact(s)...",
self.installed_paths.len()
));
for path in &self.installed_paths {
if path.exists() {
if path.is_dir() {
let _ = std::fs::remove_dir_all(path);
} else {
let _ = std::fs::remove_file(path);
}
}
}
}
}
}
const MAX_STAGING_SIZE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
const MAX_STAGING_DIRS_VISITED: usize = 100_000;
const MAX_STAGING_ENTRIES_VISITED: usize = 1_000_000;
fn dir_size(path: &Path) -> std::io::Result<u64> {
let mut total = 0u64;
let mut dirs_to_visit = vec![path.to_path_buf()];
let mut dirs_visited = 0usize;
let mut entries_visited = 0usize;
while let Some(dir) = dirs_to_visit.pop() {
dirs_visited += 1;
if dirs_visited > MAX_STAGING_DIRS_VISITED {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"staging directory scan exceeded directory traversal limit",
));
}
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
entries_visited += 1;
if entries_visited > MAX_STAGING_ENTRIES_VISITED {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"staging directory scan exceeded entry traversal limit",
));
}
let entry_path = entry.path();
let metadata = std::fs::symlink_metadata(&entry_path)?;
let file_type = metadata.file_type();
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
dirs_to_visit.push(entry_path);
} else if file_type.is_file() {
total = total.saturating_add(metadata.len());
if total > MAX_STAGING_SIZE_BYTES {
return Ok(total);
}
}
}
}
Ok(total)
}
impl StagingContext {
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn permanent_dir(&self) -> PathBuf {
match &self.mount_point {
Some(mp) => mp
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| self.staging_root.clone()),
None => self.staging_root.clone(),
}
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn is_mounted(&self) -> bool {
self.mount_point.is_some()
}
pub async fn new_in_dir(
download_path: &Path,
artifact_type: &str,
url: &str,
target_dir: PathBuf,
) -> Result<Self> {
tokio::fs::create_dir_all(&target_dir).await?;
Self::new_internal(download_path, artifact_type, url, target_dir, None).await
}
async fn new_internal(
download_path: &Path,
artifact_type: &str,
url: &str,
staging_root: PathBuf,
temp_dir: Option<tempfile::TempDir>,
) -> Result<Self> {
let mut mount_point = None;
match artifact_type {
"dmg" => {
let mp = staging_root.join("mount");
tokio::fs::create_dir_all(&mp).await?;
let attach_output = tokio::process::Command::new("hdiutil")
.arg("attach")
.arg("-nobrowse")
.arg("-quiet")
.arg("-mountpoint")
.arg(&mp)
.arg(download_path)
.output()
.await?;
if attach_output.status.success() {
mount_point = Some(mp);
} else {
let unzip_output = tokio::process::Command::new("unzip")
.arg("-q")
.arg("-o")
.arg(download_path)
.arg("-d")
.arg(&staging_root)
.output()
.await?;
if !unzip_output.status.success() {
return Err(WaxError::InstallError(format!(
"Failed to mount DMG and fallback unzip failed: {} | {}",
String::from_utf8_lossy(&attach_output.stderr),
String::from_utf8_lossy(&unzip_output.stderr)
)));
}
}
}
"zip" => {
let unzip_output = tokio::process::Command::new("unzip")
.arg("-q")
.arg("-o")
.arg(download_path)
.arg("-d")
.arg(&staging_root)
.output()
.await?;
if !unzip_output.status.success() {
return Err(WaxError::InstallError(format!(
"Failed to extract ZIP: {}",
String::from_utf8_lossy(&unzip_output.stderr)
)));
}
}
"tar.gz" | "tar" | "tgz" | "tar.bz2" | "tbz" | "tar.xz" | "txz" => {
let tar_output = tokio::process::Command::new("tar")
.arg("-xf")
.arg(download_path)
.arg("-C")
.arg(&staging_root)
.output()
.await?;
if !tar_output.status.success() {
return Err(WaxError::InstallError(format!(
"Failed to extract tarball: {}",
String::from_utf8_lossy(&tar_output.stderr)
)));
}
}
_ => {
let original_filename = url
.split('?')
.next()
.unwrap_or(url)
.split('/')
.next_back()
.unwrap_or_else(|| {
download_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
});
let decoded_filename = urlencoding::decode(original_filename)
.unwrap_or(std::borrow::Cow::Borrowed(original_filename));
let filename = decoded_filename.as_ref();
if filename.contains("..") || filename.starts_with("/") || filename.contains("\0") {
return Err(WaxError::InstallError(format!(
"Filename contains unsafe characters: {}",
filename
)));
}
let dest = staging_root.join(filename);
tokio::fs::copy(download_path, &dest).await?;
}
}
let actual_staging_root = if let Some(ref mp) = mount_point {
mp.clone()
} else {
staging_root
};
let staging_root_for_size = actual_staging_root.clone();
match tokio::task::spawn_blocking(move || dir_size(&staging_root_for_size))
.await
.map_err(|e| {
WaxError::InstallError(format!("Failed to scan staging directory: {}", e))
})? {
Ok(size) if size > MAX_STAGING_SIZE_BYTES => {
return Err(WaxError::InstallError(format!(
"Extracted cask staging directory exceeds size limit ({} > {} bytes)",
size, MAX_STAGING_SIZE_BYTES
)));
}
Ok(_) => {}
Err(e) => {
tracing::warn!("Unable to compute staging directory size: {}", e);
}
}
Ok(Self {
staging_root: actual_staging_root,
mount_point,
_temp_dir: temp_dir,
})
}
}
impl Drop for StagingContext {
fn drop(&mut self) {
if let Some(ref mp) = self.mount_point {
let _ = std::process::Command::new("hdiutil")
.arg("detach")
.arg(mp)
.arg("-quiet")
.status();
}
}
}
pub struct CaskInstaller {
downloader: BottleDownloader,
}
impl CaskInstaller {
pub fn new() -> Self {
Self {
downloader: BottleDownloader::new(),
}
}
pub fn applications_dir() -> Result<PathBuf> {
#[cfg(target_os = "macos")]
{
Ok(PathBuf::from("/Applications"))
}
#[cfg(not(target_os = "macos"))]
{
Ok(dirs::home_dir()?.join("Applications"))
}
}
pub async fn detect_writable_bin_dir() -> Result<PathBuf> {
let candidates = vec![
crate::bottle::homebrew_prefix().join("bin"),
PathBuf::from("/usr/local/bin"),
PathBuf::from("/opt/homebrew/bin"),
];
for candidate in candidates {
if candidate.exists() && Self::is_dir_writable(&candidate).await {
debug!("Using writable bin directory: {:?}", candidate);
return Ok(candidate);
}
}
let local_bin = Self::user_bin_dir()?;
tokio::fs::create_dir_all(&local_bin).await?;
debug!("Using fallback bin directory: {:?}", local_bin);
Ok(local_bin)
}
fn user_bin_dir() -> Result<PathBuf> {
Ok(dirs::home_dir()?.join(".local").join("wax").join("bin"))
}
async fn is_dir_writable(path: &Path) -> bool {
let test_file = path.join(".wax_write_test");
match tokio::fs::File::create(&test_file).await {
Ok(_) => {
let _ = tokio::fs::remove_file(&test_file).await;
true
}
Err(_) => false,
}
}
fn fallback_path(&self, staging: &StagingContext, source_rel: &str) -> PathBuf {
staging.staging_root.join(
Path::new(source_rel)
.file_name()
.unwrap_or(std::ffi::OsStr::new("unknown")),
)
}
fn resolve_absolute_source_path(
&self,
staging: &StagingContext,
source_rel: &str,
resolved: &Path,
) -> Option<PathBuf> {
let allowed_prefixes: Vec<PathBuf> = vec![
crate::bottle::homebrew_prefix(),
staging.staging_root.clone(),
#[cfg(target_os = "macos")]
PathBuf::from("/Applications"),
#[cfg(not(target_os = "macos"))]
dirs::home_dir()
.unwrap_or_else(|_| PathBuf::from("/tmp"))
.join("Applications"),
];
let normalized_resolved = normalize_existing_prefix(resolved);
let is_allowed = allowed_prefixes.iter().any(|allowed| {
let normalized_allowed = normalize_existing_prefix(allowed);
normalized_resolved.starts_with(&normalized_allowed)
});
if !is_allowed {
tracing::warn!(
"Rejecting absolute source path outside safe directories: {} (resolved: {:?})",
source_rel,
resolved
);
return Some(self.fallback_path(staging, source_rel));
}
Some(resolved.to_path_buf())
}
fn resolve_source_path(&self, staging: &StagingContext, source_rel: &str) -> PathBuf {
if source_rel.contains('\0') {
tracing::warn!("Rejecting source path with NUL byte");
return staging.staging_root.join("unknown");
}
let prefix = crate::bottle::homebrew_prefix()
.to_string_lossy()
.to_string();
let staging_str = staging.staging_root.to_str().unwrap_or("");
let path = source_rel
.replace("$HOMEBREW_PREFIX", &prefix)
.replace("#{HOMEBREW_PREFIX}", &prefix)
.replace("$APPDIR", staging_str);
let p = Path::new(&path);
let resolved = if p.is_absolute() {
p.to_path_buf()
} else {
staging.staging_root.join(&path)
};
if p.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
tracing::warn!(
"Rejecting source path with parent-directory traversal: {} (resolved: {:?})",
source_rel,
resolved
);
return self.fallback_path(staging, source_rel);
}
if p.is_absolute() {
if let Some(res) = self.resolve_absolute_source_path(staging, source_rel, &resolved) {
return res;
}
}
self.resolve_relative_source_path(staging, source_rel, &resolved)
}
fn resolve_relative_source_path(
&self,
staging: &StagingContext,
source_rel: &str,
resolved: &Path,
) -> PathBuf {
let mut normalized = PathBuf::new();
for component in resolved.components() {
match component {
std::path::Component::ParentDir => {
if !normalized.pop() {
tracing::warn!(
"Rejecting source path that escapes staging root: {} (resolved: {:?})",
source_rel,
resolved
);
return self.fallback_path(staging, source_rel);
}
}
std::path::Component::CurDir => {}
other => normalized.push(other),
}
}
if !normalized.starts_with(&staging.staging_root) {
tracing::warn!(
"Rejecting source path that escapes staging root: {} (normalized: {:?})",
source_rel,
normalized
);
return self.fallback_path(staging, source_rel);
}
normalized
}
pub async fn probe_artifact_type(&self, url: &str) -> Option<&'static str> {
let client = crate::http_client::default_client();
let response = match client.head(url).send().await {
Ok(r) if r.status().is_success() => r,
_ => {
client
.get(url)
.header(reqwest::header::RANGE, "bytes=0-0")
.send()
.await
.ok()?
}
};
let final_url = response.url().to_string();
if let Some(t) = detect_artifact_type(&final_url) {
return Some(t);
}
if let Some(disposition) = response
.headers()
.get("content-disposition")
.and_then(|v| v.to_str().ok())
{
if let Some(t) = detect_artifact_type_from_disposition(disposition) {
return Some(t);
}
}
if let Some(ct) = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
{
if let Some(t) = detect_artifact_type_from_content_type(ct) {
return Some(t);
}
}
None
}
#[instrument(skip(self, progress, totals))]
pub async fn download_cask(
&self,
url: &str,
dest_path: &Path,
progress: Option<&ProgressBar>,
totals: Option<&DownloadTotals>,
) -> Result<()> {
debug!("Downloading cask from {}", url);
self.downloader
.download(
url,
dest_path,
progress,
BottleDownloader::GLOBAL_CONNECTION_POOL,
totals,
)
.await
}
fn reject_traversal(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
if path.as_os_str().as_bytes().contains(&0) {
return Err(WaxError::InstallError(format!(
"Path contains NUL byte: {}",
path.display()
)));
}
}
#[cfg(not(unix))]
{
if path.as_os_str().to_string_lossy().contains('\0') {
return Err(WaxError::InstallError(format!(
"Path contains NUL byte: {}",
path.display()
)));
}
}
if path
.components()
.any(|c| c == std::path::Component::ParentDir)
{
return Err(WaxError::InstallError(format!(
"Path contains directory traversal: {}",
path.display()
)));
}
Ok(())
}
fn resolve_artifact_dest(target_path: &str) -> Result<PathBuf> {
if target_path.contains('\0') {
return Err(WaxError::InstallError(
"unsafe artifact target contains NUL byte".to_string(),
));
}
let dest = PathBuf::from(target_path);
Self::reject_traversal(&dest)?;
if dest.is_absolute() {
let allowed_roots = [
crate::bottle::homebrew_prefix(),
dirs::home_dir()?,
Self::applications_dir()?,
];
let normalized_dest = normalize_existing_prefix(&dest);
if allowed_roots
.iter()
.map(|root| normalize_existing_prefix(root))
.any(|root| normalized_dest.starts_with(root))
{
return Ok(dest);
}
return Err(WaxError::InstallError(format!(
"unsafe artifact target outside install roots: {}",
dest.display()
)));
}
Ok(crate::bottle::homebrew_prefix().join(dest))
}
#[instrument(skip(self, _staging, _rollback))]
pub async fn install_app(
&self,
_staging: &StagingContext,
_rollback: &mut RollbackContext,
source_rel: &str,
) -> Result<()> {
#[cfg(not(target_os = "macos"))]
{
debug!("Skipping .app bundle install on non-macOS: {}", source_rel);
return Ok(());
}
#[cfg(target_os = "macos")]
{
let source = self.resolve_source_path(_staging, source_rel);
let app_name = Path::new(source_rel)
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
WaxError::InstallError(format!("Invalid app source: {}", source_rel))
})?;
info!("Installing app: {}", app_name);
if !source.exists() {
return Err(WaxError::InstallError(format!(
"App source does not exist: {:?}",
source
)));
}
let app_dest = Self::applications_dir()?.join(app_name);
if app_dest.exists() {
tokio::fs::remove_dir_all(&app_dest).await?;
}
_rollback.add(app_dest.clone());
crate::ui::copy_dir_all(&source, &app_dest)?;
Ok(())
}
}
#[instrument(skip(self, _staging, _rollback))]
pub async fn install_pkg(
&self,
_staging: &StagingContext,
_rollback: &mut RollbackContext,
source_rel: &str,
) -> Result<()> {
#[cfg(not(target_os = "macos"))]
return Err(WaxError::PlatformNotSupported(
"PKG installers are macOS-only".to_string(),
));
#[cfg(target_os = "macos")]
{
let source = self.resolve_source_path(_staging, source_rel);
info!("Installing PKG: {:?}", source);
if !source.exists() {
return Err(WaxError::InstallError(format!(
"PKG source does not exist: {:?}",
source
)));
}
let verify_output = tokio::process::Command::new("pkgutil")
.arg("--check-signature")
.arg(&source)
.output()
.await?;
let stdout_str = String::from_utf8_lossy(&verify_output.stdout);
let stderr_str = String::from_utf8_lossy(&verify_output.stderr);
let combined = format!("{stdout_str}{stderr_str}");
let is_unsigned = combined.contains("Status: no signature")
|| combined.contains("Status: unsigned")
|| combined.contains("invalid signature");
let looks_signed = combined.contains("Status: signed")
|| combined.contains("Signed with a trusted timestamp")
|| combined.contains("Developer ID Installer");
if !verify_output.status.success() || is_unsigned || !looks_signed {
let err_msg = if stderr_str.trim().is_empty() {
stdout_str.into_owned()
} else {
stderr_str.into_owned()
};
return Err(WaxError::InstallError(format!(
"PKG signature verification failed: {}",
err_msg.trim()
)));
}
tokio::task::spawn_blocking(|| {
crate::sudo::acquire_sudo_for(Some(
"PKG installer requires administrator privileges.",
))
})
.await
.map_err(|e| WaxError::InstallError(e.to_string()))??;
let install_output = tokio::process::Command::new("sudo")
.arg("installer")
.arg("-pkg")
.arg(&source)
.arg("-target")
.arg("/")
.output()
.await?;
if !install_output.status.success() {
return Err(WaxError::InstallError(format!(
"Failed to install PKG: {}",
String::from_utf8_lossy(&install_output.stderr)
)));
}
info!("Successfully installed PKG");
Ok(())
}
}
#[instrument(skip(self, staging, rollback))]
pub async fn install_binary(
&self,
staging: &StagingContext,
rollback: &mut RollbackContext,
source_rel: &str,
target_name: Option<&str>,
cask_name: Option<&str>,
) -> Result<Option<PathBuf>> {
let source = self.resolve_source_path(staging, source_rel);
let name = target_name.unwrap_or_else(|| {
Path::new(source_rel)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(source_rel)
});
Self::reject_traversal(Path::new(name))?;
info!("Installing binary: {} from {:?}", name, source);
if !source.exists() {
if let Some(cask) = cask_name {
debug!(
"Binary missing, attempting to fetch and extract preflight shimscript for {}",
cask
);
if let Ok(ruby_content) =
crate::formula_parser::FormulaParser::fetch_cask_rb(cask).await
{
if let Some(script_content) =
crate::formula_parser::FormulaParser::extract_shimscript(&ruby_content)
{
if let Some(parent) = source.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
if tokio::fs::write(&source, script_content).await.is_ok() {
crate::signal::println_through_active_multi(format!(
" {} generated wrapper script via preflight",
console::style("✓").green()
));
}
}
}
}
}
if !source.exists() {
crate::signal::println_through_active_multi(
" ⚠️ skipping binary: source not found (possibly requires preflight script)",
);
return Ok(None);
}
let bin_dest_dir = Self::detect_writable_bin_dir().await?;
let binary_dest_path = bin_dest_dir.join(name);
if let Ok(metadata) = tokio::fs::symlink_metadata(&binary_dest_path).await {
let file_type = metadata.file_type();
if file_type.is_symlink() || file_type.is_file() {
tokio::fs::remove_file(&binary_dest_path).await.ok();
} else if file_type.is_dir() {
tokio::fs::remove_dir_all(&binary_dest_path).await.ok();
}
}
rollback.add(binary_dest_path.clone());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let link_target = if staging.is_mounted() {
let perm_dir = staging.permanent_dir();
tokio::fs::create_dir_all(&perm_dir).await?;
let dest = perm_dir.join(name);
tokio::fs::copy(&source, &dest).await?;
dest
} else {
source
};
tokio::fs::symlink(&link_target, &binary_dest_path).await?;
let mut perms = tokio::fs::metadata(&link_target).await?.permissions();
perms.set_mode(0o755);
tokio::fs::set_permissions(&link_target, perms).await?;
}
#[cfg(not(unix))]
{
tokio::fs::copy(&source, &binary_dest_path).await?;
}
info!(
"Successfully installed {} to {}",
name,
bin_dest_dir.display()
);
Ok(Some(binary_dest_path))
}
#[instrument(skip(self, staging, rollback))]
pub async fn install_font(
&self,
staging: &StagingContext,
rollback: &mut RollbackContext,
source_rel: &str,
) -> Result<()> {
let source = self.resolve_source_path(staging, source_rel);
let font_name = Path::new(source_rel)
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
WaxError::InstallError(format!("Invalid font source: {}", source_rel))
})?;
#[cfg(target_os = "macos")]
let user_fonts = dirs::home_dir()?.join("Library/Fonts");
#[cfg(not(target_os = "macos"))]
let user_fonts = dirs::home_dir()?.join(".local/share/fonts");
tokio::fs::create_dir_all(&user_fonts).await?;
let dest = user_fonts.join(font_name);
if dest.exists() {
tokio::fs::remove_file(&dest).await.ok();
}
rollback.add(dest.clone());
tokio::fs::copy(&source, &dest).await?;
Ok(())
}
#[instrument(skip(self, staging, rollback))]
pub async fn install_manpage(
&self,
staging: &StagingContext,
rollback: &mut RollbackContext,
source_rel: &str,
) -> Result<()> {
let source = self.resolve_source_path(staging, source_rel);
let man_name = Path::new(source_rel)
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
WaxError::InstallError(format!("Invalid manpage source: {}", source_rel))
})?;
let man_prefix = crate::bottle::homebrew_prefix().join("share/man");
let section = Path::new(man_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("man1");
let dest_dir = man_prefix.join(format!("man{}", section));
tokio::fs::create_dir_all(&dest_dir).await?;
let dest = dest_dir.join(man_name);
if dest.exists() {
tokio::fs::remove_file(&dest).await.ok();
}
rollback.add(dest.clone());
tokio::fs::copy(&source, &dest).await?;
Ok(())
}
#[instrument(skip(self, staging, rollback))]
pub async fn install_artifact(
&self,
staging: &StagingContext,
rollback: &mut RollbackContext,
source_rel: &str,
target_path: &str,
) -> Result<()> {
let source = self.resolve_source_path(staging, source_rel);
let dest = Self::resolve_artifact_dest(target_path)?;
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await?;
}
if dest.exists() {
if dest.is_dir() {
tokio::fs::remove_dir_all(&dest).await?;
} else {
tokio::fs::remove_file(&dest).await?;
}
}
rollback.add(dest.clone());
if source.is_dir() {
crate::ui::copy_dir_all(&source, &dest)?;
} else {
tokio::fs::copy(&source, &dest).await?;
}
Ok(())
}
pub async fn install_generic_directory(
&self,
staging: &StagingContext,
rollback: &mut RollbackContext,
source_rel: &str,
dest_parent: &Path,
) -> Result<()> {
let source = self.resolve_source_path(staging, source_rel);
let name = Path::new(source_rel)
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| WaxError::InstallError(format!("Invalid source: {}", source_rel)))?;
tokio::fs::create_dir_all(dest_parent).await?;
let dest = dest_parent.join(name);
if dest.exists() {
let meta = tokio::fs::symlink_metadata(&dest).await?;
if meta.is_dir() {
tokio::fs::remove_dir_all(&dest).await?;
} else {
tokio::fs::remove_file(&dest).await?;
}
}
rollback.add(dest.clone());
crate::ui::copy_dir_all(&source, &dest)?;
Ok(())
}
#[instrument(skip(self, staging, rollback))]
pub async fn install_completion(
&self,
staging: &StagingContext,
rollback: &mut RollbackContext,
source_rel: &str,
shell: &str,
token: &str,
target_name: Option<&str>,
) -> Result<()> {
let source = self.resolve_source_path(staging, source_rel);
if !source.exists() {
debug!("Completion source not found at {:?}, skipping", source);
return Ok(());
}
let prefix = crate::bottle::homebrew_prefix();
let dest_dir = match shell {
"bash" => prefix.join("etc/bash_completion.d"),
"zsh" => prefix.join("share/zsh/site-functions"),
"fish" => prefix.join("share/fish/vendor_completions.d"),
_ => {
return Err(WaxError::InstallError(format!(
"Unsupported shell: {}",
shell
)));
}
};
tokio::fs::create_dir_all(&dest_dir).await?;
let filename = target_name.unwrap_or_else(|| {
Path::new(source_rel)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(token)
});
Self::reject_traversal(Path::new(filename))?;
let dest = dest_dir.join(filename);
if let Ok(metadata) = tokio::fs::symlink_metadata(&dest).await {
let file_type = metadata.file_type();
if file_type.is_symlink() || file_type.is_file() {
tokio::fs::remove_file(&dest).await.ok();
} else if file_type.is_dir() {
tokio::fs::remove_dir_all(&dest).await.ok();
}
}
rollback.add(dest.clone());
#[cfg(unix)]
{
tokio::fs::symlink(&source, &dest).await?;
}
#[cfg(not(unix))]
{
if source.is_dir() {
crate::ui::copy_dir_all(&source, &dest)?;
} else {
tokio::fs::copy(&source, &dest).await?;
}
}
Ok(())
}
}
impl Default for CaskInstaller {
fn default() -> Self {
Self::new()
}
}
pub fn detect_artifact_type(url: &str) -> Option<&'static str> {
let path = url.split('?').next().unwrap_or(url);
let path = path.split('#').next().unwrap_or(path);
if path.ends_with(".dmg") {
Some("dmg")
} else if path.ends_with(".pkg") {
Some("pkg")
} else if path.ends_with(".zip") {
Some("zip")
} else if path.ends_with(".tar.gz")
|| path.ends_with(".tgz")
|| path.ends_with(".tar.bz2")
|| path.ends_with(".tbz")
|| path.ends_with(".tar.xz")
|| path.ends_with(".txz")
{
Some("tar.gz")
} else {
None
}
}
pub fn detect_artifact_type_from_content_type(content_type: &str) -> Option<&'static str> {
let ct = content_type
.split(';')
.next()
.unwrap_or(content_type)
.trim();
match ct {
"application/x-apple-diskimage" => Some("dmg"),
"application/octet-stream" => Some("binary"),
"application/zip" | "application/x-zip-compressed" => Some("zip"),
"application/x-tar" | "application/gzip" | "application/x-gzip" => Some("tar.gz"),
"application/x-pkg" | "application/vnd.apple.installer+xml" => Some("pkg"),
_ => None,
}
}
pub fn detect_artifact_type_from_disposition(disposition: &str) -> Option<&'static str> {
for part in disposition.split(';') {
let part = part.trim();
let value = if let Some(v) = part.strip_prefix("filename*=") {
v.splitn(3, '\'').nth(2).unwrap_or(v).to_string()
} else if let Some(v) = part.strip_prefix("filename=") {
v.trim_matches('"').to_string()
} else {
continue;
};
if let Some(t) = detect_artifact_type(&value) {
return Some(t);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_detect_artifact_type_from_disposition() {
assert_eq!(
detect_artifact_type_from_disposition("attachment; filename=\"app-1.0.dmg\""),
Some("dmg")
);
assert_eq!(
detect_artifact_type_from_disposition("attachment; filename=app-1.0.pkg"),
Some("pkg")
);
assert_eq!(
detect_artifact_type_from_disposition("filename=\"archive.zip\""),
Some("zip")
);
assert_eq!(
detect_artifact_type_from_disposition("filename=\"source.tar.gz\""),
Some("tar.gz")
);
assert_eq!(
detect_artifact_type_from_disposition("attachment; filename*=UTF-8''app%201.0.dmg"),
Some("dmg")
);
assert_eq!(
detect_artifact_type_from_disposition(
"attachment; filename=\"ignore.txt\"; filename*=UTF-8''app.zip"
),
Some("zip")
);
assert_eq!(
detect_artifact_type_from_disposition(" attachment ; filename=\"app.pkg\" "),
Some("pkg")
);
assert_eq!(
detect_artifact_type_from_disposition("attachment; filename=\"app.txt\""),
None
);
assert_eq!(
detect_artifact_type_from_disposition("attachment; filename=\"app.exe\""),
None
);
assert_eq!(detect_artifact_type_from_disposition("attachment"), None);
}
#[tokio::test]
#[cfg(target_os = "macos")]
async fn test_resolve_source_path() {
let installer = CaskInstaller::new();
let temp = tempdir().unwrap();
let staging_root = temp.path().to_path_buf();
let staging = StagingContext {
staging_root: staging_root.clone(),
mount_point: None,
_temp_dir: Some(temp),
};
let prefix = crate::bottle::homebrew_prefix()
.to_string_lossy()
.to_string();
let res = installer.resolve_source_path(&staging, "$HOMEBREW_PREFIX/bin/foo");
assert_eq!(res, PathBuf::from(format!("{}/bin/foo", prefix)));
let res = installer.resolve_source_path(&staging, "#{HOMEBREW_PREFIX}/bin/bar");
assert_eq!(res, PathBuf::from(format!("{}/bin/bar", prefix)));
let res = installer.resolve_source_path(&staging, "$APPDIR/Contents/MacOS/qux");
assert_eq!(res, staging_root.join("Contents/MacOS/qux"));
let res = installer.resolve_source_path(&staging, "/usr/bin/true");
assert_eq!(res, staging_root.join("true"));
let res = installer.resolve_source_path(&staging, "relative/path");
assert_eq!(res, staging_root.join("relative/path"));
let res = installer.resolve_source_path(&staging, "../../etc/passwd");
assert_eq!(res, staging_root.join("passwd"));
let res = installer.resolve_source_path(&staging, &format!("{}/bin/brew", prefix));
assert_eq!(res, PathBuf::from(format!("{}/bin/brew", prefix)));
let abs_in_staging = staging_root.join("foo/bar");
let res = installer.resolve_source_path(&staging, abs_in_staging.to_str().unwrap());
assert_eq!(res, abs_in_staging);
}
#[tokio::test]
async fn resolve_source_path_rejects_nul_byte() {
let installer = CaskInstaller::new();
let temp = tempdir().unwrap();
let staging_root = temp.path().to_path_buf();
let staging = StagingContext {
staging_root: staging_root.clone(),
mount_point: None,
_temp_dir: Some(temp),
};
let res = installer.resolve_source_path(&staging, "bin/tool\0evil");
assert_eq!(res, staging_root.join("unknown"));
}
#[test]
#[cfg(target_os = "macos")]
fn artifact_dest_rejects_absolute_system_path() {
let err = CaskInstaller::resolve_artifact_dest("/etc/launchd.conf")
.expect_err("absolute system path should be rejected");
assert!(format!("{err:?}").contains("unsafe artifact target"));
}
#[test]
fn artifact_dest_allows_homebrew_prefix_path() {
let target = crate::bottle::homebrew_prefix().join("share/example/file");
let resolved = CaskInstaller::resolve_artifact_dest(target.to_str().unwrap()).unwrap();
assert_eq!(resolved, target);
}
#[test]
fn user_bin_dir_matches_documented_path() {
let user_bin_dir = CaskInstaller::user_bin_dir().unwrap();
assert_eq!(
user_bin_dir,
dirs::home_dir().unwrap().join(".local/wax/bin")
);
}
#[test]
fn homebrew_metadata_timestamp_matches_homebrew_format() {
assert_eq!(homebrew_metadata_timestamp(0), "19700101000000.000");
}
#[test]
fn detects_homebrew_cask_metadata_file() {
let temp = tempdir().unwrap();
let cask_dir = temp.path().join("example-cask");
let metadata_dir = cask_dir
.join(".metadata")
.join("1.0.0")
.join("20260102030405.000")
.join("Casks");
assert!(!cask_path_has_homebrew_metadata(&cask_dir));
std::fs::create_dir_all(&metadata_dir).unwrap();
std::fs::write(metadata_dir.join("example-cask.json"), "{}").unwrap();
assert!(cask_path_has_homebrew_metadata(&cask_dir));
}
#[test]
fn fallback_cask_metadata_uses_homebrew_json_shape() {
let cask = InstalledCask {
name: "example-app".to_string(),
version: "1.2.3".to_string(),
install_date: 0,
artifact_type: Some("dmg".to_string()),
binary_paths: Some(vec!["/opt/homebrew/bin/example".to_string()]),
app_name: Some("Example.app".to_string()),
};
let source = cask_metadata_from_installed(&cask, None);
assert_eq!(source["token"], "example-app");
assert_eq!(source["version"], "1.2.3");
assert_eq!(source["sha256"], "no_check");
assert_eq!(source["tap"], "homebrew/cask");
assert_eq!(source["ruby_source_path"], "Casks/e/example-app.rb");
assert_eq!(source["artifacts"].as_array().unwrap().len(), 2);
}
#[test]
fn latest_caskroom_version_prefers_homebrew_metadata() {
let temp = tempdir().unwrap();
let cask_dir = temp.path().join("example-cask");
std::fs::create_dir_all(cask_dir.join("1.0.0")).unwrap();
std::fs::create_dir_all(cask_dir.join("2.0.0")).unwrap();
std::fs::create_dir_all(cask_dir.join(".metadata/3.0.0/20260101000000.000")).unwrap();
let (version, _) = latest_caskroom_version(&cask_dir).unwrap();
assert_eq!(version, "3.0.0");
}
#[test]
fn latest_caskroom_version_uses_latest_version_dir_without_metadata() {
let temp = tempdir().unwrap();
let cask_dir = temp.path().join("example-cask");
std::fs::create_dir_all(cask_dir.join("1.0.0")).unwrap();
std::fs::create_dir_all(cask_dir.join("2.0.0")).unwrap();
let (version, _) = latest_caskroom_version(&cask_dir).unwrap();
assert_eq!(version, "2.0.0");
}
#[test]
fn latest_caskroom_version_uses_metadata_without_version_dirs() {
let temp = tempdir().unwrap();
let cask_dir = temp.path().join("example-cask");
std::fs::create_dir_all(cask_dir.join(".metadata/1.0.0/20260101000000.000")).unwrap();
std::fs::create_dir_all(cask_dir.join(".metadata/2.0.0/20260201000000.000")).unwrap();
let (version, _) = latest_caskroom_version(&cask_dir).unwrap();
assert_eq!(version, "2.0.0");
}
#[test]
fn test_detect_artifact_type() {
assert_eq!(
detect_artifact_type("https://example.com/app.dmg"),
Some("dmg")
);
assert_eq!(
detect_artifact_type("https://example.com/app.pkg"),
Some("pkg")
);
assert_eq!(
detect_artifact_type("https://example.com/app.zip"),
Some("zip")
);
assert_eq!(
detect_artifact_type("https://example.com/app.tar.gz"),
Some("tar.gz")
);
assert_eq!(
detect_artifact_type("https://example.com/app.tgz"),
Some("tar.gz")
);
assert_eq!(
detect_artifact_type("https://example.com/app.tar.bz2"),
Some("tar.gz")
);
assert_eq!(
detect_artifact_type("https://example.com/app.tbz"),
Some("tar.gz")
);
assert_eq!(
detect_artifact_type("https://example.com/app.tar.xz"),
Some("tar.gz")
);
assert_eq!(
detect_artifact_type("https://example.com/app.txz"),
Some("tar.gz")
);
assert_eq!(
detect_artifact_type("https://example.com/app.dmg?version=1.0"),
Some("dmg")
);
assert_eq!(
detect_artifact_type("https://example.com/app.zip?token=xyz123"),
Some("zip")
);
assert_eq!(
detect_artifact_type("https://example.com/app.pkg#checksum"),
Some("pkg")
);
assert_eq!(
detect_artifact_type("https://example.com/app.tar.gz?v=1#checksum"),
Some("tar.gz")
);
assert_eq!(detect_artifact_type("https://example.com/app.exe"), None);
assert_eq!(detect_artifact_type("https://example.com/app.msi"), None);
assert_eq!(
detect_artifact_type("https://example.com/app.AppImage"),
None
);
assert_eq!(detect_artifact_type("https://example.com/app"), None);
assert_eq!(detect_artifact_type("https://example.com/app?v=1"), None);
}
#[test]
fn test_detect_artifact_type_from_content_type() {
assert_eq!(
detect_artifact_type_from_content_type("application/x-apple-diskimage"),
Some("dmg")
);
assert_eq!(
detect_artifact_type_from_content_type("application/octet-stream"),
Some("binary")
);
assert_eq!(
detect_artifact_type_from_content_type("application/zip"),
Some("zip")
);
assert_eq!(
detect_artifact_type_from_content_type("application/x-zip-compressed"),
Some("zip")
);
assert_eq!(
detect_artifact_type_from_content_type("application/x-tar"),
Some("tar.gz")
);
assert_eq!(
detect_artifact_type_from_content_type("application/gzip"),
Some("tar.gz")
);
assert_eq!(
detect_artifact_type_from_content_type("application/x-gzip"),
Some("tar.gz")
);
assert_eq!(
detect_artifact_type_from_content_type("application/x-pkg"),
Some("pkg")
);
assert_eq!(
detect_artifact_type_from_content_type("application/vnd.apple.installer+xml"),
Some("pkg")
);
assert_eq!(
detect_artifact_type_from_content_type("application/zip; charset=utf-8"),
Some("zip")
);
assert_eq!(
detect_artifact_type_from_content_type("application/x-apple-diskimage; version=1"),
Some("dmg")
);
assert_eq!(
detect_artifact_type_from_content_type(" application/zip ; charset=utf-8"),
Some("zip")
);
assert_eq!(detect_artifact_type_from_content_type("text/html"), None);
assert_eq!(
detect_artifact_type_from_content_type("application/json"),
None
);
assert_eq!(detect_artifact_type_from_content_type(""), None);
}
}