use std::io::{Read, Seek, SeekFrom};
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::{Component, Path, PathBuf};
use std::{
    borrow::Cow,
    collections::{BTreeMap, BTreeSet},
};

use async_trait::async_trait;
use eyre::{WrapErr, bail, eyre};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use walkdir::WalkDir;

use super::api::RubySourceChecksum;
use super::prefix;
use super::source;
use crate::cmd::CmdLineRunner;
use crate::file::{self, ExtractOptions, ExtractionFormat};
use crate::git::{CloneOptions, Git};
use crate::hash;
use crate::http::{HTTP, HTTP_FETCH};
use crate::result::Result;
use crate::system::packages::{
    InstallOpts, PackageRequest, PackageState, PackageStatus, SystemPackageManager,
};
use crate::system::sudo;
use crate::ui::multi_progress_report::MultiProgressReport;
use crate::ui::progress_report::{ProgressIcon, SingleReport};

const API_BASE: &str = "https://formulae.brew.sh/api";
const HOMEBREW_CASK_RAW: &str = "https://raw.githubusercontent.com/Homebrew/homebrew-cask";
const CASK_SHIM_RB: &str = include_str!("cask_shim.rb");
/// where `app` artifacts are linked when [`APP_DIR_ENV`] is unset
const DEFAULT_APP_DIR: &str = "/Applications";
/// user-facing override for the `app` artifact destination, mirroring
/// `brew install --appdir`; see [`target_app_dir`] and
/// docs/bootstrap/packages/brew.md
const APP_DIR_ENV: &str = "MISE_BREW_CASK_OPT_APPDIR";

pub struct BrewCaskManager {}

#[derive(Debug, Clone, Default, Deserialize)]
struct CaskUrlSpecs {
    #[serde(default)]
    branch: Option<String>,
    #[serde(default)]
    only_path: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
struct Cask {
    token: String,
    #[serde(default)]
    aliases: Vec<String>,
    #[serde(default)]
    old_tokens: Vec<String>,
    version: String,
    url: String,
    #[serde(default)]
    url_specs: CaskUrlSpecs,
    #[serde(default)]
    sha256: Option<String>,
    #[serde(default)]
    artifacts: Vec<Value>,
    #[serde(default, deserialize_with = "deserialize_null_default")]
    depends_on: CaskDependencies,
    #[serde(default, deserialize_with = "deserialize_null_default")]
    conflicts_with: CaskConflicts,
    #[serde(default)]
    ruby_source_path: Option<String>,
    #[serde(default)]
    ruby_source_checksum: Option<RubySourceChecksum>,
    #[serde(default)]
    tap_git_head: Option<String>,
    #[serde(skip)]
    raw_base: Option<String>,
}

#[derive(Debug, Clone, Default, Deserialize)]
struct CaskDependencies {
    #[serde(default)]
    formula: Vec<String>,
    #[serde(default)]
    cask: Vec<String>,
}

#[derive(Debug, Clone, Default, Deserialize)]
struct CaskConflicts {
    #[serde(default)]
    cask: Vec<String>,
}

fn deserialize_null_default<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
where
    D: serde::Deserializer<'de>,
    T: Deserialize<'de> + Default,
{
    Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct AppArtifact {
    source: String,
    target: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct BinaryArtifact {
    source: String,
    target: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct CommandWrapperArtifact {
    name: String,
    target: Option<String>,
    content: Option<String>,
    executable: Option<String>,
    args: Vec<String>,
    env: BTreeMap<String, String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct PkgArtifact {
    source: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct InstallerArtifact {
    executable: String,
    args: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct GenericArtifact {
    source: String,
    target: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct FontArtifact {
    source: String,
    target: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CompletionShell {
    Bash,
    Fish,
    Zsh,
    Pwsh,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct CompletionArtifact {
    shell: CompletionShell,
    source: String,
    target: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct GeneratedCompletionArtifact {
    executable: String,
    args: Vec<String>,
    base_name: Option<String>,
    shell_parameter_format: Option<String>,
    shells: Vec<CompletionShell>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum FlightStep {
    Move {
        source: FlightPath,
        target: FlightPath,
        source_glob: bool,
    },
    Remove {
        paths: Vec<FlightPath>,
        recursive: bool,
    },
    Copy {
        source: FlightPath,
        target: FlightPath,
        recursive: bool,
        overwrite: bool,
        source_glob: bool,
        guards: Vec<FlightGuard>,
    },
    Symlink {
        source: FlightPath,
        target: FlightPath,
        force: bool,
        uninstall: bool,
        source_glob: bool,
        sudo: FlightSudo,
        guards: Vec<FlightGuard>,
    },
    Run {
        command: FlightPath,
        args: Vec<String>,
        env: BTreeMap<String, String>,
        sudo: bool,
        guards: Vec<FlightGuard>,
    },
    TerminateProcess {
        name: String,
        match_mode: ProcessMatch,
        sudo: bool,
        attempts: usize,
        must_succeed: bool,
        notices: Vec<String>,
        failure_message: Option<String>,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProcessMatch {
    Name,
    Full,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FlightPathBase {
    StagedPath,
    AppDir,
    HomebrewPrefix,
    Literal,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FlightSudo {
    Never,
    Always,
    IfNeeded,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct FlightPath {
    base: FlightPathBase,
    path: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum FlightGuard {
    OnMacos,
    OnLinux,
    IfExists(FlightPath),
    UnlessExists(FlightPath),
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct CaskArtifacts {
    apps: Vec<AppArtifact>,
    binaries: Vec<BinaryArtifact>,
    command_wrappers: Vec<CommandWrapperArtifact>,
    pkgs: Vec<PkgArtifact>,
    installers: Vec<InstallerArtifact>,
    generic: Vec<GenericArtifact>,
    fonts: Vec<FontArtifact>,
    completions: Vec<CompletionArtifact>,
    generated_completions: Vec<GeneratedCompletionArtifact>,
    preflight_steps: Vec<FlightStep>,
    postflight_steps: Vec<FlightStep>,
    pkg_ids: Vec<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
struct CaskReceipt {
    #[serde(default)]
    schema_version: u8,
    version: String,
    #[serde(default)]
    apps: Vec<PathBuf>,
    #[serde(default)]
    binaries: Vec<PathBuf>,
    #[serde(default)]
    fonts: Vec<PathBuf>,
    #[serde(default)]
    completions: Vec<PathBuf>,
    #[serde(default)]
    flight_directories: Vec<PathBuf>,
    #[serde(default)]
    generic: Vec<PathBuf>,
    #[serde(default)]
    pkg_ids: Vec<String>,
    #[serde(default)]
    targets: Vec<CaskTargetRecord>,
    #[serde(default)]
    prune_safe: bool,
    #[serde(default)]
    prune_blocker: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
struct CaskTargetRecord {
    path: PathBuf,
    fingerprint: CaskTargetFingerprint,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    uninstall: Option<bool>,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
struct CaskTargetFingerprint {
    kind: CaskTargetKind,
    digest: String,
}

#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum CaskTargetKind {
    File,
    Directory,
    Symlink,
}

#[derive(Debug, Serialize)]
struct CaskTransactionJournal<'a> {
    schema_version: u8,
    token: &'a str,
    version: &'a str,
    completed: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct CaskPruneCandidate {
    pub token: String,
    pub version: String,
    version_dir: PathBuf,
    receipt: CaskReceipt,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CaskPruneSkip {
    pub token: String,
    pub reason: String,
}

#[derive(Debug, Default, Clone)]
pub struct CaskPrunePlan {
    pub remove: Vec<CaskPruneCandidate>,
    pub skipped: Vec<CaskPruneSkip>,
}

impl CaskPrunePlan {
    pub fn is_empty(&self) -> bool {
        self.remove.is_empty()
    }
}

impl BrewCaskManager {
    pub fn new() -> Self {
        Self {}
    }

    async fn install_one(
        &self,
        req: &PackageRequest,
        opts: &InstallOpts,
        pr: Option<&dyn SingleReport>,
    ) -> Result<String> {
        self.install_one_with_ancestors(req, opts, pr, &BTreeSet::new())
            .await
    }

    async fn install_one_with_ancestors(
        &self,
        req: &PackageRequest,
        opts: &InstallOpts,
        pr: Option<&dyn SingleReport>,
        ancestors: &BTreeSet<String>,
    ) -> Result<String> {
        let cask = fetch_cask(req).await?;
        if ancestors.contains(&cask.token) {
            bail!("brew-cask:{}: dependency cycle detected", cask.token);
        }
        let mut ancestors = ancestors.clone();
        ancestors.insert(cask.token.clone());
        let artifacts = cask_artifacts(&cask)?;
        validate_platform_support(&cask, &artifacts)?;
        if homebrew_metadata_present(&cask.token) {
            bail!(
                "brew-cask:{}: Homebrew owns this cask; remove it with Homebrew before installing it with mise",
                cask.token
            );
        }
        if installed_cask_version(&cask, &artifacts)?.as_deref() == Some(cask.version.as_str()) {
            info!("brew-cask:{}: already installed", cask.token);
            return Ok(cask.version);
        }
        for conflict in &cask.conflicts_with.cask {
            if !installed_versions(conflict).is_empty() {
                bail!(
                    "brew-cask:{}: conflicts with installed cask {}",
                    cask.token,
                    conflict
                );
            }
        }
        if !cask.depends_on.formula.is_empty() {
            let dependencies = cask
                .depends_on
                .formula
                .iter()
                .map(|name| PackageRequest {
                    name: name.clone(),
                    version: None,
                    tap_url: None,
                })
                .collect::<Vec<_>>();
            super::BrewManager::new()
                .install(&dependencies, opts)
                .await?;
        }
        for dependency in &cask.depends_on.cask {
            let request = PackageRequest {
                name: dependency.clone(),
                version: None,
                tap_url: None,
            };
            Box::pin(self.install_one_with_ancestors(&request, opts, None, &ancestors)).await?;
        }
        if opts.dry_run {
            miseprintln!("install cask {}/{}", cask.token, cask.version);
            for app in &artifacts.apps {
                miseprintln!("link app {}", app.target_name());
            }
            for binary in &artifacts.binaries {
                miseprintln!("link binary {}", binary.target_name()?);
            }
            for wrapper in &artifacts.command_wrappers {
                miseprintln!("link command wrapper {}", wrapper.target_name()?);
            }
            for pkg in &artifacts.pkgs {
                miseprintln!("install pkg {}", pkg.source);
            }
            for installer in &artifacts.installers {
                miseprintln!("run installer {}", installer.executable);
            }
            for artifact in &artifacts.generic {
                miseprintln!("install artifact {}", artifact.target);
            }
            for font in &artifacts.fonts {
                miseprintln!("install font {}", font.source);
            }
            for completion in &artifacts.completions {
                miseprintln!(
                    "install {} completion {}",
                    completion.shell.name(),
                    completion.source
                );
            }
            for generated in &artifacts.generated_completions {
                miseprintln!("generate completions from {}", generated.executable);
            }
            return Ok(cask.version);
        }
        prefix::bootstrap(false)?;
        let stage = fetch_and_stage(&cask, pr).await?;
        let _caskroom_lock = lock_caskroom()?;
        recover_flight_backups()?;
        if homebrew_metadata_present(&cask.token) {
            file::remove_all(&stage)?;
            bail!(
                "brew-cask:{}: Homebrew took ownership of this cask while installation was pending",
                cask.token
            );
        }
        if installed_cask_version(&cask, &artifacts)?.as_deref() == Some(cask.version.as_str()) {
            file::remove_all(stage)?;
            return Ok(cask.version);
        }
        let previous_binaries = previous_binary_targets(&cask)?;
        let previous_fonts = previous_font_targets(&cask)?;
        let previous_completions = previous_completion_targets(&cask)?;
        let previous_flight_symlinks = previous_flight_symlink_targets(&cask)?;
        let previous_flight_directories = previous_flight_directory_targets(&cask)?;
        let previous_generic = previous_generic_targets(&cask)?;
        let caskroom_token = caskroom_token_dir(&cask.token);
        let caskroom = caskroom_version_dir(&cask.token, &cask.version);
        let tmp_caskroom = caskroom_tmp_dir(&cask);
        file::remove_all(&tmp_caskroom)?;
        file::create_dir_all(&tmp_caskroom)?;
        let appdir = cask_appdir(&artifacts.apps)?;
        let mut journal = CaskTransactionJournal {
            schema_version: 1,
            token: &cask.token,
            version: &cask.version,
            completed: Vec::new(),
        };
        let mut flight_targets = FlightTargetTransaction::default();
        flight_targets.receipt_caskroom = Some(caskroom.clone());
        flight_targets.previous_symlinks = previous_flight_symlinks.iter().cloned().collect();
        flight_targets.previous_directories = previous_flight_directories.into_iter().collect();
        write_cask_journal(&journal)?;
        let current_completions = completion_target_paths(&cask, &artifacts)?;
        for target in &current_completions {
            ensure_completion_target_replaceable(&cask, &artifacts, target)?;
        }
        // Match Homebrew's artifact phases: preflight runs before app installation.
        // An appdir-based preflight command therefore sees only a previously installed app.
        execute_flight_steps_recording(
            &cask,
            &artifacts.preflight_steps,
            &stage,
            &appdir,
            "preflight_steps",
            &mut journal,
            &mut flight_targets,
        )?;
        execute_lifecycle_hook(&cask, &stage, &appdir, "preflight", pr).await?;
        if has_lifecycle_hook(&cask, "preflight") {
            record_cask_action(&mut journal, "preflight_hook")?;
        }
        // Homebrew leaves artifacts from the installed version available to
        // preflight. Back them up only after preflight so guards and commands
        // can observe those links during an upgrade. A structured preflight
        // step that replaces one has already protected it transactionally.
        for target in &previous_flight_symlinks {
            flight_targets.protect(target)?;
        }
        run_installers_before_durabilizing(
            &stage,
            &tmp_caskroom,
            &artifacts.installers,
            &mut flight_targets,
            |index| record_cask_action(&mut journal, &format!("installer[{index}]")),
        )?;
        for (index, app) in artifacts.apps.iter().enumerate() {
            install_app(&stage, &tmp_caskroom, app)?;
            record_cask_action(&mut journal, &format!("app[{index}]"))?;
        }
        for (index, pkg) in artifacts.pkgs.iter().enumerate() {
            install_pkg(&stage, pkg)?;
            record_cask_action(&mut journal, &format!("pkg[{index}]"))?;
        }
        for (index, font) in artifacts.fonts.iter().enumerate() {
            stage_font(&stage, &tmp_caskroom, font)?;
            record_cask_action(&mut journal, &format!("font[{index}]"))?;
        }
        for (index, wrapper) in artifacts.command_wrappers.iter().enumerate() {
            stage_command_wrapper(&tmp_caskroom, &appdir, &cask, wrapper)?;
            record_cask_action(&mut journal, &format!("command_wrapper[{index}]"))?;
        }
        for (index, artifact) in artifacts.generic.iter().enumerate() {
            install_generic_artifact(&stage, &tmp_caskroom, artifact, &mut flight_targets)?;
            record_cask_action(&mut journal, &format!("artifact[{index}]"))?;
        }
        execute_flight_steps_recording(
            &cask,
            &artifacts.postflight_steps,
            &tmp_caskroom,
            &appdir,
            "postflight_steps",
            &mut journal,
            &mut flight_targets,
        )?;
        execute_lifecycle_hook(&cask, &tmp_caskroom, &appdir, "postflight", pr).await?;
        if has_lifecycle_hook(&cask, "postflight") {
            record_cask_action(&mut journal, "postflight_hook")?;
        }
        for (index, binary) in artifacts.binaries.iter().enumerate() {
            stage_binary(&stage, &tmp_caskroom, &cask, &artifacts.apps, binary)?;
            record_cask_action(&mut journal, &format!("binary[{index}]"))?;
        }
        for (index, completion) in artifacts.completions.iter().enumerate() {
            stage_completion(&stage, &tmp_caskroom, &cask, &artifacts.apps, completion)?;
            record_cask_action(&mut journal, &format!("completion[{index}]"))?;
        }
        for (index, generated) in artifacts.generated_completions.iter().enumerate() {
            stage_generated_completions(&stage, &tmp_caskroom, &cask, &artifacts.apps, generated)?;
            record_cask_action(&mut journal, &format!("generated_completion[{index}]"))?;
        }
        let current_binaries = binary_targets(&artifacts)?;
        let current_fonts = font_target_paths(&artifacts)?;
        let mut current_targets = current_binaries.clone();
        current_targets.extend(current_completions.iter().cloned());
        current_targets.extend(current_fonts.iter().cloned());
        let mut link_transaction = ArtifactLinkTransaction::begin(current_targets)?;
        let activation = replace_caskroom(&cask, &tmp_caskroom, &caskroom, || {
            retarget_transient_symlinks(&tmp_caskroom, &caskroom, &caskroom, &flight_targets)?;
            for binary in &artifacts.binaries {
                link_binary(&caskroom, &appdir, binary)?;
            }
            for wrapper in &artifacts.command_wrappers {
                link_command_wrapper(&caskroom, wrapper)?;
            }
            for target in &current_completions {
                link_completion(&cask, &artifacts, &caskroom, target)?;
            }
            for font in &artifacts.fonts {
                link_font(&caskroom, font)?;
            }
            write_receipt_with_flight_targets(
                &caskroom,
                &cask,
                &artifacts,
                flight_targets.installed_targets(),
                flight_targets.uninstall_targets(),
                flight_targets.installed_directories(),
            )?;
            Ok(())
        });
        if let Err(err) = activation {
            if let Err(rollback_err) = link_transaction.rollback() {
                return Err(err.wrap_err(format!(
                    "failed to restore external cask artifacts: {rollback_err:#}"
                )));
            }
            return Err(err);
        }
        if let Err(err) = flight_targets.commit() {
            warn!("brew-cask: failed to remove flight target backups: {err:#}");
        }
        if let Err(err) = link_transaction.commit() {
            warn!("brew-cask: failed to remove artifact link backups: {err:#}");
        }
        record_cask_action(&mut journal, "activated")?;
        remove_obsolete_binary_links(&cask, &previous_binaries, &current_binaries)?;
        remove_obsolete_completions(&cask, &previous_completions, &current_completions)?;
        remove_obsolete_fonts(&cask, &previous_fonts, &current_fonts)?;
        remove_obsolete_generic_artifacts(
            &previous_generic,
            &generic_artifact_targets(&artifacts)?,
        )?;
        remove_obsolete_flight_directories(
            &flight_targets.previous_directories,
            flight_targets.installed_directories(),
        )?;
        remove_stale_versions(&caskroom_token, &cask.version)?;
        remove_cask_journals(&cask.token)?;
        file::remove_all(stage)?;
        Ok(cask.version)
    }
}

impl AppArtifact {
    fn target_name(&self) -> &str {
        self.target.as_deref().unwrap_or(&self.source)
    }
}

impl BinaryArtifact {
    fn target_name(&self) -> Result<String> {
        match &self.target {
            Some(target) => Ok(target.clone()),
            None => Path::new(&self.source)
                .file_name()
                .and_then(|name| name.to_str())
                .map(str::to_string)
                .ok_or_else(|| eyre!("brew-cask: invalid binary source '{}'", self.source)),
        }
    }

    fn target_path(&self, appdir: &Path) -> Result<PathBuf> {
        binary_target_path(&self.target_name()?, appdir)
    }
}

impl CommandWrapperArtifact {
    fn target_name(&self) -> Result<String> {
        match &self.target {
            Some(target) => Ok(target.clone()),
            None => Ok(self.name.clone()),
        }
    }

    fn target_path(&self) -> Result<PathBuf> {
        binary_target_path(&self.target_name()?, &target_app_dir()?)
    }

    fn caskroom_path(&self, caskroom: &Path) -> PathBuf {
        caskroom.join(".homebrew-command-wrappers").join(&self.name)
    }
}

impl CompletionShell {
    fn parse(raw: &str) -> Option<Self> {
        match raw {
            "bash" => Some(Self::Bash),
            "fish" => Some(Self::Fish),
            "zsh" => Some(Self::Zsh),
            "pwsh" => Some(Self::Pwsh),
            _ => None,
        }
    }

    fn name(self) -> &'static str {
        match self {
            Self::Bash => "bash",
            Self::Fish => "fish",
            Self::Zsh => "zsh",
            Self::Pwsh => "pwsh",
        }
    }

    fn parameter_name(self) -> &'static str {
        match self {
            Self::Pwsh => "powershell",
            _ => self.name(),
        }
    }
}

impl CompletionArtifact {
    fn target_name(&self) -> Result<String> {
        match &self.target {
            Some(target) => Ok(target.clone()),
            None => Path::new(&self.source)
                .file_name()
                .and_then(|name| name.to_str())
                .map(str::to_string)
                .ok_or_else(|| eyre!("brew-cask: invalid completion source '{}'", self.source)),
        }
    }

    fn target_path(&self) -> Result<PathBuf> {
        completion_target_path(self.shell, &self.target_name()?)
    }
}

impl GeneratedCompletionArtifact {
    fn resolved_base_name(&self, cask: &Cask) -> String {
        let name = self.base_name.clone().unwrap_or_else(|| {
            Path::new(&self.executable)
                .file_name()
                .and_then(|name| name.to_str())
                .unwrap_or(&cask.token)
                .to_string()
        });
        if name.is_empty() {
            cask.token.clone()
        } else {
            name
        }
    }

    fn target_paths(&self, cask: &Cask) -> Result<Vec<PathBuf>> {
        let base_name = self.resolved_base_name(cask);
        self.shells
            .iter()
            .map(|shell| generated_completion_target_path(*shell, &base_name))
            .collect()
    }
}

#[async_trait(?Send)]
impl SystemPackageManager for BrewCaskManager {
    fn name(&self) -> &str {
        "brew-cask"
    }

    fn is_available(&self) -> bool {
        cfg!(any(target_os = "macos", target_os = "linux"))
    }

    fn unavailable_reason(&self) -> String {
        "only available on macos and linux".to_string()
    }

    fn supports_version_pins(&self) -> bool {
        false
    }

    async fn installed(&self, pkgs: &[PackageRequest]) -> Result<Vec<PackageStatus>> {
        let mut statuses = Vec::with_capacity(pkgs.len());
        for req in pkgs {
            let cask = fetch_cask(req).await?;
            let artifacts = cask_artifacts(&cask)?;
            if let Some(state) = platform_unavailable_state(&cask, &artifacts) {
                statuses.push(PackageStatus {
                    request: req.clone(),
                    state,
                });
                continue;
            }
            let version = installed_cask_version(&cask, &artifacts)?;
            let state = match version {
                Some(version) => match &req.version {
                    Some(requested) if version != *requested => {
                        PackageState::VersionMismatch { installed: version }
                    }
                    _ => PackageState::Installed { version },
                },
                None => PackageState::Missing,
            };
            statuses.push(PackageStatus {
                request: req.clone(),
                state,
            });
        }
        Ok(statuses)
    }

    async fn install(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> {
        if let Some(p) = pkgs.iter().find(|p| p.version.is_some()) {
            bail!("brew casks are installed at their current version ('{p}')");
        }
        if opts.dry_run {
            prefix::bootstrap(true)?;
            for pkg in pkgs {
                self.install_one(pkg, opts, None).await?;
            }
            return Ok(());
        }
        let mpr = MultiProgressReport::get();
        mpr.init_footer(false, "install", pkgs.len());
        for pkg in pkgs {
            let pr: Box<dyn SingleReport> = mpr.add(&format!("brew-cask:{}", pkg.name));
            match self.install_one(pkg, opts, Some(&*pr)).await {
                Ok(version) => {
                    pr.finish_with_message(version);
                    mpr.footer_inc(1);
                }
                Err(err) => {
                    pr.finish_with_icon("failed".to_string(), ProgressIcon::Error);
                    mpr.footer_finish();
                    return Err(err);
                }
            }
        }
        mpr.footer_finish();
        Ok(())
    }

    async fn upgrade(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> {
        self.install(pkgs, opts).await
    }
}

async fn fetch_cask(req: &PackageRequest) -> Result<Cask> {
    let name = &req.name;
    let (requested_token, official_api) = match split_tap_name(name) {
        Some(("homebrew", "cask", token)) => (token, true),
        Some((_, _, token)) => (token, false),
        None => (name.as_str(), true),
    };
    validate_cask_path_component("requested token", requested_token)?;
    let (url, raw_base) = match split_tap_name(name) {
        Some(("homebrew", "cask", token)) => (
            format!("{API_BASE}/cask/{token}.json"),
            Some(HOMEBREW_CASK_RAW.to_string()),
        ),
        Some((owner, tap, token)) => {
            let Some(base) = super::api::tap_raw_base(owner, tap, req.tap_url.as_deref()) else {
                bail!(
                    "brew-cask: unsupported tap URL for '{name}'; only GitHub tap URLs can be fetched directly"
                );
            };
            (
                format!("{base}/api/cask/{token}.json"),
                Some(base.trim_end_matches("/HEAD").to_string()),
            )
        }
        None => (
            format!("{API_BASE}/cask/{name}.json"),
            Some(HOMEBREW_CASK_RAW.to_string()),
        ),
    };
    let mut cask = HTTP_FETCH
        .json_cached::<Cask, _>(url)
        .await
        .wrap_err_with(|| {
            format!(
                "failed to fetch Homebrew cask '{name}' directly. \
                 Tapped casks must publish API metadata at api/cask/<token>.json"
            )
        })?;
    cask.raw_base = raw_base;
    validate_cask_identity(&cask, requested_token, official_api)?;
    Ok(cask)
}

fn validate_cask_identity(cask: &Cask, requested_token: &str, official_api: bool) -> Result<()> {
    validate_cask_path_component("API token", &cask.token)?;
    validate_cask_path_component("version", &cask.version)?;
    let trusted_alias = official_api
        && cask
            .aliases
            .iter()
            .chain(&cask.old_tokens)
            .any(|alias| alias == requested_token);
    if cask.token != requested_token && !trusted_alias {
        bail!(
            "brew-cask: requested token '{requested_token}' does not match API token '{}'",
            cask.token
        );
    }
    Ok(())
}

fn validate_cask_path_component(kind: &str, value: &str) -> Result<()> {
    let mut components = Path::new(value).components();
    let valid = !value.is_empty()
        && !value.contains('\0')
        && matches!(components.next(), Some(Component::Normal(_)))
        && components.next().is_none()
        && value != ".metadata"
        && !value.starts_with(".mise-");
    if !valid {
        bail!("brew-cask: invalid {kind} '{value}'");
    }
    Ok(())
}

async fn fetch_and_stage(cask: &Cask, pr: Option<&dyn SingleReport>) -> Result<PathBuf> {
    if cask.url.ends_with(".git") {
        return fetch_git_clone_and_stage(cask, pr).await;
    }
    let archive = fetch_archive(cask, pr).await?;
    extract_archive(cask, &archive, pr)
}

async fn fetch_git_clone_and_stage(cask: &Cask, pr: Option<&dyn SingleReport>) -> Result<PathBuf> {
    let extract_dir = crate::dirs::CACHE
        .join("system-brew")
        .join("cask-extract")
        .join(format!("{}-{}", cask.token, cask.version));
    file::remove_all(&extract_dir)?;
    file::create_dir_all(&extract_dir)?;
    let clone_dir = crate::dirs::CACHE
        .join("system-brew")
        .join("cask-git-clone")
        .join(format!("{}-{}", cask.token, cask.version));
    file::remove_all(&clone_dir)?;
    let mut clone_opts = CloneOptions::default();
    if let Some(branch) = cask.url_specs.branch.as_deref() {
        clone_opts = clone_opts.branch(branch);
    }
    if let Some(pr) = pr {
        clone_opts = clone_opts.pr(pr);
    }
    Git::new(&clone_dir)
        .clone(&cask.url, clone_opts)
        .wrap_err_with(|| format!("brew-cask:{}: failed to clone {}", cask.token, cask.url))?;
    if let Some(only_path) = &cask.url_specs.only_path {
        let source = clone_dir.join(only_path);
        if source.is_dir() {
            for entry in std::fs::read_dir(&source)? {
                let entry = entry?;
                let dest = extract_dir.join(entry.file_name());
                file::rename(entry.path(), &dest)?;
            }
        }
    } else {
        for entry in std::fs::read_dir(&clone_dir)? {
            let entry = entry?;
            if entry.file_name() == ".git" {
                continue;
            }
            let dest = extract_dir.join(entry.file_name());
            file::rename(entry.path(), &dest)?;
        }
    }
    file::remove_all(&clone_dir)?;
    Ok(extract_dir)
}

async fn fetch_archive(cask: &Cask, pr: Option<&dyn SingleReport>) -> Result<PathBuf> {
    let filename = archive_filename(&cask.url)
        .ok_or_else(|| eyre!("brew-cask:{}: URL has no file name", cask.token))?;
    let cache_dir = crate::dirs::CACHE.join("system-brew").join("casks");
    file::create_dir_all(&cache_dir)?;
    let url_hash = &hash::hash_sha256_to_str(&cask.url)[..12];
    let archive = cache_dir.join(format!(
        "{}-{}-{url_hash}-{filename}",
        cask.token, cask.version
    ));
    if !archive.exists() {
        HTTP.download_file(&cask.url, &archive, pr).await?;
        // Strip macOS quarantine so it doesn't propagate into extracted/copied artifacts.
        let _ = std::process::Command::new("xattr")
            .args(["-d", "com.apple.quarantine"])
            .arg(&archive)
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status();
    }
    match cask.sha256.as_deref() {
        Some("no_check") => {}
        Some(sha256) => hash::ensure_checksum(&archive, sha256, pr, "sha256")?,
        None => bail!("brew-cask:{}: cask metadata has no sha256", cask.token),
    }
    Ok(archive)
}

fn extract_archive(cask: &Cask, archive: &Path, pr: Option<&dyn SingleReport>) -> Result<PathBuf> {
    let extract_dir = crate::dirs::CACHE
        .join("system-brew")
        .join("cask-extract")
        .join(format!("{}-{}", cask.token, cask.version));
    file::remove_all(&extract_dir)?;
    file::create_dir_all(&extract_dir)?;
    let filename = archive
        .file_name()
        .and_then(|f| f.to_str())
        .unwrap_or_default();
    if is_dmg_archive(archive, filename)? {
        file::un_dmg(archive, &extract_dir)?;
    } else {
        let format = cask_extraction_format(archive, filename)?;
        if format == ExtractionFormat::Raw {
            // Raw executable binary — copy it using the original URL filename so
            // find_file_artifact can match against the binary stanza source name (e.g. "claude").
            let url_filename = archive_filename(&cask.url).unwrap_or_else(|| filename.to_string());
            let dest = extract_dir.join(&url_filename);
            file::copy(archive, &dest)?;
            file::make_executable(&dest)?;
        } else if !format.is_archive() {
            bail!(
                "brew-cask:{}: unsupported archive type for {}",
                cask.token,
                filename
            );
        } else {
            file::extract_archive(
                archive,
                &extract_dir,
                format,
                &ExtractOptions {
                    pr,
                    ..Default::default()
                },
            )?;
        }
    }
    Ok(extract_dir)
}

async fn execute_lifecycle_hook(
    cask: &Cask,
    staged_path: &Path,
    appdir: &Path,
    hook: &str,
    pr: Option<&dyn SingleReport>,
) -> Result<()> {
    if !has_lifecycle_hook(cask, hook) {
        return Ok(());
    }
    let ruby = cask_ruby_bin().await?;
    let cask_rb = fetch_cask_rb(cask, pr).await?;
    let shim_path = crate::dirs::CACHE
        .join("system-brew")
        .join("casks")
        .join("mise-brew-cask-shim.rb");
    ensure_cask_shim(&shim_path)?;
    if let Some(pr) = pr {
        pr.set_message(format!("run cask {hook}"));
    }
    let runner = CmdLineRunner::new(&ruby).arg(&shim_path).envs([
        ("MISE_BREW_CASK_FILE", cask_rb.display().to_string()),
        ("MISE_BREW_CASK_TOKEN", cask.token.clone()),
        ("MISE_BREW_CASK_VERSION", cask.version.clone()),
        (
            "MISE_BREW_CASK_STAGED_PATH",
            staged_path.display().to_string(),
        ),
        ("MISE_BREW_CASK_APPDIR", appdir.display().to_string()),
        ("MISE_BREW_PREFIX", prefix::prefix().display().to_string()),
        ("MISE_BREW_CASK_HOOK", hook.to_string()),
        ("MISE_BREW_CASK_SUDO", sudo::subprocess_mode().to_string()),
    ]);
    let runner = match pr {
        Some(pr) => runner.with_pr(pr),
        None => runner,
    };
    runner
        .execute_async()
        .await
        .wrap_err_with(|| format!("brew-cask:{}: failed to run {hook}", cask.token))
}

async fn cask_ruby_bin() -> Result<PathBuf> {
    if let Some(brew) = file::which("brew")
        && let Ok(output) = tokio::process::Command::new(brew)
            .args(["ruby", "-e", "print RbConfig.ruby"])
            .output()
            .await
        && output.status.success()
        && let Ok(path) = String::from_utf8(output.stdout)
    {
        let path = PathBuf::from(path.trim());
        if path.is_file() {
            return Ok(path);
        }
    }
    if let Some(ruby) = file::which("ruby") {
        return Ok(ruby);
    }
    source::ruby_bin().await
}

fn ensure_cask_shim(path: &Path) -> Result<()> {
    if let Some(parent) = path.parent() {
        file::create_dir_all(parent)?;
    }
    if file::read_to_string(path).is_ok_and(|contents| contents == CASK_SHIM_RB) {
        return Ok(());
    }
    file::write(path, CASK_SHIM_RB)
}

async fn fetch_cask_rb(cask: &Cask, pr: Option<&dyn SingleReport>) -> Result<PathBuf> {
    let rb_path = cask.ruby_source_path.as_ref().ok_or_else(|| {
        eyre!(
            "brew-cask:{}: lifecycle hooks require ruby_source_path in API metadata",
            cask.token
        )
    })?;
    let sha256 = cask
        .ruby_source_checksum
        .as_ref()
        .and_then(|c| c.sha256.as_deref())
        .ok_or_else(|| {
            eyre!(
                "brew-cask:{}: lifecycle hooks require ruby_source_checksum in API metadata",
                cask.token
            )
        })?;
    let commit = cask.tap_git_head.as_deref().ok_or_else(|| {
        eyre!(
            "brew-cask:{}: lifecycle hooks require tap_git_head in API metadata",
            cask.token
        )
    })?;
    let raw_base = cask.raw_base.as_deref().ok_or_else(|| {
        eyre!(
            "brew-cask:{}: lifecycle hooks require a GitHub raw source URL",
            cask.token
        )
    })?;
    let cache_dir = crate::dirs::CACHE.join("system-brew").join("cask-source");
    file::create_dir_all(&cache_dir)?;
    let short_sha = sha256.get(..12).unwrap_or(sha256);
    let dest = cache_dir.join(format!("{}-{short_sha}.rb", cask.token));
    if dest.exists() && hash::ensure_checksum(&dest, sha256, None, "sha256").is_ok() {
        return Ok(dest);
    }
    let url = format!("{raw_base}/{commit}/{rb_path}");
    if let Some(pr) = pr {
        pr.set_message(format!("download {rb_path}"));
    }
    HTTP_FETCH.download_file(&url, &dest, pr).await?;
    hash::ensure_checksum(&dest, sha256, pr, "sha256")?;
    Ok(dest)
}

fn cask_extraction_format(archive: &Path, filename: &str) -> Result<ExtractionFormat> {
    let format = ExtractionFormat::from_file_name(filename);
    if format != ExtractionFormat::Raw {
        return Ok(format);
    }
    Ok(detect_extraction_format(archive)?.unwrap_or(format))
}

fn is_dmg_archive(archive: &Path, filename: &str) -> Result<bool> {
    if filename.ends_with(".dmg") {
        return Ok(true);
    }
    if ExtractionFormat::from_file_name(filename) != ExtractionFormat::Raw {
        return Ok(false);
    }

    // UDIF images end with a 512-byte resource footer containing this prefix.
    const UDIF_TRAILER_SIZE: i64 = 512;
    const UDIF_TRAILER_PREFIX: &[u8; 12] = b"koly\0\0\0\x04\0\0\x02\0";
    let mut file = std::fs::File::open(archive)?;
    if file.metadata()?.len() < UDIF_TRAILER_SIZE as u64 {
        return Ok(false);
    }
    file.seek(SeekFrom::End(-UDIF_TRAILER_SIZE))?;
    let mut prefix = [0; UDIF_TRAILER_PREFIX.len()];
    file.read_exact(&mut prefix)?;
    Ok(&prefix == UDIF_TRAILER_PREFIX)
}

fn detect_extraction_format(archive: &Path) -> Result<Option<ExtractionFormat>> {
    let mut file = std::fs::File::open(archive)?;
    let mut magic = [0; 8];
    let len = file.read(&mut magic)?;
    let magic = &magic[..len];
    if magic.starts_with(b"PK\x03\x04") {
        return Ok(Some(ExtractionFormat::Zip));
    }
    Ok(None)
}

fn install_app(stage: &Path, caskroom: &Path, app: &AppArtifact) -> Result<()> {
    let source = find_app(stage, &app.source)
        .ok_or_else(|| eyre!("brew-cask: app artifact '{}' was not found", app.source))?;
    let caskroom_app = caskroom.join(app_bundle_name(app.target_name())?);
    file::remove_all(&caskroom_app)?;
    ditto(&source, &caskroom_app)?;
    let logical_target = app_target_path(app.target_name())?;
    // Hold the verified appdir open for the whole mutation and address the app
    // only by name relative to that descriptor. Nothing below resolves a
    // pathname for the application directory, so a post-validation replacement
    // of any appdir component — even by the same uid — cannot redirect the
    // copy, rename, removal, permission repair, or quarantine steps.
    let parent = ensure_trusted_appdir(
        logical_target
            .parent()
            .ok_or_else(|| eyre!("brew-cask: app target has no parent directory"))?,
    )?;
    let name = logical_target
        .file_name()
        .ok_or_else(|| eyre!("brew-cask: app target has no filename"))?
        .to_owned();
    // Suffix hashes stay derived from the logical path so temporary and backup
    // names are stable across runs.
    let name_hash = crate::hash::hash_to_str(&logical_target.display().to_string());
    let tmp_name = replace_bundle_extension(&name, &format!("mise-tmp-{name_hash}"));
    let old_name = replace_bundle_extension(&name, &format!("mise-old-{name_hash}"));
    remove_all_at(&parent.fd, &tmp_name)?;
    ditto_into(&caskroom_app, &parent.fd, &tmp_name)?;
    activate_app_at(
        &parent,
        &name,
        &tmp_name,
        &old_name,
        &caskroom_app,
        &logical_target,
    )?;
    // Remove macOS quarantine attribute so Gatekeeper doesn't block the app.
    let relative = Path::new(".").join(&name);
    let _ = run_in_trusted_dir(
        "xattr",
        &[
            std::ffi::OsStr::new("-r"),
            std::ffi::OsStr::new("-d"),
            std::ffi::OsStr::new("com.apple.quarantine"),
            relative.as_os_str(),
        ],
        &parent.fd,
    );
    Ok(())
}

/// Replace an app bundle's extension for a bare file name
/// (`Firefox.app` + `mise-tmp-ab12` -> `Firefox.mise-tmp-ab12`).
fn replace_bundle_extension(name: &std::ffi::OsStr, extension: &str) -> std::ffi::OsString {
    Path::new(name).with_extension(extension).into_os_string()
}

/// Activate the staged app before replacing its Caskroom copy with a symlink.
/// A failed app swap therefore leaves the durable staged copy available for
/// recovery instead of exposing a symlink to the previous app installation.
#[cfg(unix)]
fn activate_app_at(
    parent: &TrustedOperationParent,
    name: &std::ffi::OsStr,
    tmp_name: &std::ffi::OsStr,
    old_name: &std::ffi::OsStr,
    caskroom_app: &Path,
    logical_target: &Path,
) -> Result<()> {
    swap_app_at(parent, name, tmp_name, old_name)?;
    replace_caskroom_app_with_symlink(caskroom_app, logical_target)
}

#[cfg(unix)]
fn replace_caskroom_app_with_symlink(caskroom_app: &Path, target: &Path) -> Result<()> {
    let suffix = crate::hash::hash_to_str(&caskroom_app.display().to_string());
    let staged_link = caskroom_app.with_extension(format!("mise-link-{suffix}"));
    let staged_copy = caskroom_app.with_extension(format!("mise-copy-{suffix}"));
    file::remove_all(&staged_link)?;
    file::remove_all(&staged_copy)?;
    file::make_symlink(target, &staged_link)?;
    file::rename(caskroom_app, &staged_copy)?;
    if let Err(err) = file::rename(&staged_link, caskroom_app) {
        let _ = file::rename(&staged_copy, caskroom_app);
        let _ = file::remove_all(&staged_link);
        return Err(err).wrap_err("failed to replace Caskroom app copy with symlink");
    }
    if let Err(err) = file::remove_all(&staged_copy) {
        warn!(
            "brew-cask: failed to remove staged app copy {}: {err:#}",
            staged_copy.display()
        );
    }
    Ok(())
}

/// Atomically replace an app inside `parent`, restoring the previous bundle if
/// activation fails. All operations are `*at`-relative to the verified
/// descriptor, so no application directory pathname is ever re-resolved.
#[cfg(unix)]
fn swap_app_at(
    parent: &TrustedOperationParent,
    name: &std::ffi::OsStr,
    tmp_name: &std::ffi::OsStr,
    old_name: &std::ffi::OsStr,
) -> Result<()> {
    // Atomic swap: rename the existing target aside before putting the new one
    // in place so a failure leaves the old app intact rather than nothing.
    remove_app_at(parent, old_name)?;
    if exists_at(&parent.fd, name)? {
        nix::fcntl::renameat(&parent.fd, name, &parent.fd, old_name)?;
    }
    if let Err(e) = nix::fcntl::renameat(&parent.fd, tmp_name, &parent.fd, name) {
        // Restore the old app if the swap failed.
        if exists_at(&parent.fd, old_name)? {
            let _ = nix::fcntl::renameat(&parent.fd, old_name, &parent.fd, name);
        }
        return Err(e).wrap_err_with(|| {
            format!(
                "brew-cask: failed to activate {}",
                Path::new(name).display()
            )
        });
    }
    // The replacement is already live. A cleanup failure must not report the
    // install as failed or prevent removing quarantine.
    if let Err(err) = remove_app_at(parent, old_name) {
        warn!(
            "brew-cask: failed to remove old app backup {}: {err:#}",
            Path::new(old_name).display()
        );
    }
    Ok(())
}

/// Whether `name` exists in `dir`, without following a final symlink.
#[cfg(unix)]
fn exists_at<Fd: std::os::fd::AsFd>(dir: Fd, name: &std::ffi::OsStr) -> Result<bool> {
    match nix::sys::stat::fstatat(dir, name, nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW) {
        Ok(_) => Ok(true),
        Err(nix::errno::Errno::ENOENT) => Ok(false),
        Err(err) => Err(err.into()),
    }
}

/// Remove an app bundle inside `parent`, repairing protected contents before
/// escalating ownership. Removal and repair are bound to the descriptor.
#[cfg(unix)]
fn remove_app_at(parent: &TrustedOperationParent, name: &std::ffi::OsStr) -> Result<()> {
    match remove_all_at(&parent.fd, name) {
        Ok(()) => return Ok(()),
        Err(err) if !is_permission_denied(&err) => return Err(err),
        Err(_) => {}
    }

    repair_app_permissions_at(parent, name);
    match remove_all_at(&parent.fd, name) {
        Ok(()) => return Ok(()),
        Err(err) if !is_permission_denied(&err) => return Err(err),
        Err(_) => {}
    }

    let user = nix::unistd::User::from_uid(nix::unistd::geteuid())?
        .map(|user| user.name)
        .ok_or_else(|| eyre!("brew-cask: could not determine current user"))?;
    // Match Homebrew's final ownership-recovery step. Both the unprivileged and
    // the elevated attempt run with their working directory bound to the
    // verified appdir descriptor and address the bundle by a relative name, so
    // the recursive chown cannot be redirected outside the validated directory
    // by a replacement of any appdir component. sudo::run_in_dir applies the
    // same system_packages.sudo policy as sudo::run and refuses to prompt
    // without a TTY.
    //
    // `-h -P` keep the recursion inside the bundle: cask bundles legitimately
    // contain symlinks, and without these the walk would dereference one that
    // points outward and change ownership of the referent outside the verified
    // application directory.
    let relative = Path::new(".").join(name);
    let status = run_in_trusted_dir(
        "chown",
        &[
            std::ffi::OsStr::new("-R"),
            std::ffi::OsStr::new("-h"),
            std::ffi::OsStr::new("-P"),
            std::ffi::OsStr::new("--"),
            std::ffi::OsStr::new(&user),
            relative.as_os_str(),
        ],
        &parent.fd,
    );
    if !matches!(status, Ok(status) if status.success()) {
        sudo::run_in_dir(
            "chown",
            &[
                "-R".to_string(),
                "-h".to_string(),
                "-P".to_string(),
                "--".to_string(),
                user,
                relative.display().to_string(),
            ],
            &parent.fd,
        )?;
    }
    repair_app_permissions_at(parent, name);
    remove_all_at(&parent.fd, name)
}

/// Clear flags, restore owner permissions, and remove ACLs from an app bundle,
/// resolving it relative to the verified descriptor.
///
/// Every command is recursive, so `-P` is mandatory: cask bundles legitimately
/// contain symlinks, and without it a bundle symlink pointing outside the
/// application directory would have the flags or permissions of its referent
/// changed instead. macOS `chmod`/`chflags` reject `-R` together with `-h`, but
/// `-P` alone keeps the recursion from dereferencing symlink entries (verified:
/// an outward symlink's referent keeps its original mode and flags).
#[cfg(unix)]
fn repair_app_permissions_at(parent: &TrustedOperationParent, name: &std::ffi::OsStr) {
    let relative = Path::new(".").join(name);
    let run = |program: &str, args: &[&str]| {
        let mut argv: Vec<&std::ffi::OsStr> = args.iter().map(std::ffi::OsStr::new).collect();
        argv.push(relative.as_os_str());
        let _ = run_in_trusted_dir(program, &argv, &parent.fd);
    };
    run("/usr/bin/chflags", &["-R", "-P", "--", "000"]);
    run("/bin/chmod", &["-R", "-P", "--", "u+rwx"]);
    run("/bin/chmod", &["-R", "-P", "-N"]);
}

/// Return whether an eyre chain originated from an I/O permission error.
fn is_permission_denied(err: &eyre::Report) -> bool {
    err.chain().any(|cause| {
        cause
            .downcast_ref::<std::io::Error>()
            .is_some_and(|err| err.kind() == std::io::ErrorKind::PermissionDenied)
            || cause
                .downcast_ref::<nix::errno::Errno>()
                .is_some_and(|err| {
                    matches!(err, nix::errno::Errno::EACCES | nix::errno::Errno::EPERM)
                })
    })
}

/// Retry a failed artifact-target mutation with sudo on permission errors.
///
/// Cask artifact targets can live in root-owned directories — most commonly
/// `/usr/local/bin` on Apple Silicon, where casks like docker-desktop
/// hardcode absolute binary targets. Homebrew elevates its cask
/// `ln`/`mkdir`/`rm`/`mv` calls when the target directory is not writable;
/// this matches that behavior. sudo::run honors `system_packages.sudo` and
/// never prompts for a password without a TTY.
fn with_sudo_fallback(result: Result<()>, program: &str, args: &[String]) -> Result<()> {
    match result {
        Err(err) if is_permission_denied(&err) => sudo::run(program, args, &[]),
        other => other,
    }
}

fn create_dir_all_elevating(dir: &Path) -> Result<()> {
    with_sudo_fallback(
        file::create_dir_all(dir),
        "mkdir",
        &["-p".into(), "--".into(), dir.display().to_string()],
    )
}

fn make_symlink_elevating(source: &Path, link: &Path) -> Result<()> {
    with_sudo_fallback(
        file::make_symlink(source, link).map(|_| ()),
        "ln",
        &symlink_command_args(source, link),
    )
}

fn symlink_command_args(source: &Path, link: &Path) -> Vec<String> {
    let mut args = vec!["-s".into(), "-f".into()];
    if cfg!(target_os = "macos") {
        args.push("-h".into());
    } else {
        args.push("-n".into());
    }
    args.extend([
        "--".into(),
        source.display().to_string(),
        link.display().to_string(),
    ]);
    args
}

fn rename_elevating(from: &Path, to: &Path) -> Result<()> {
    with_sudo_fallback(
        file::rename(from, to),
        "mv",
        &[
            "-f".into(),
            "--".into(),
            from.display().to_string(),
            to.display().to_string(),
        ],
    )
}

fn remove_artifact_target_elevating(path: &Path) -> Result<()> {
    let Ok(metadata) = path.symlink_metadata() else {
        return Ok(());
    };
    let (result, args) = if metadata.file_type().is_symlink() {
        (
            file::remove_file(path),
            vec!["-f".into(), "--".into(), path.display().to_string()],
        )
    } else {
        (
            file::remove_all(path),
            vec![
                "-r".into(),
                "-f".into(),
                "--".into(),
                path.display().to_string(),
            ],
        )
    };
    with_sudo_fallback(result, "rm", &args)
}

fn remove_empty_directory_elevating(path: &Path) -> Result<()> {
    let Ok(metadata) = path.symlink_metadata() else {
        return Ok(());
    };
    if !metadata.is_dir() {
        return Ok(());
    }
    if path
        .read_dir()
        .is_ok_and(|mut entries| entries.next().is_some())
    {
        return Ok(());
    }
    with_sudo_fallback(
        file::remove_dir(path),
        "rmdir",
        &["--".into(), path.display().to_string()],
    )
}

/// Copy a cask artifact while preserving macOS metadata where it matters.
///
/// Linux cask support currently covers font files, which do not need the
/// resource-fork and extended-attribute handling provided by `ditto`.
fn copy_cask_artifact(from: &Path, to: &Path) -> Result<()> {
    #[cfg(target_os = "macos")]
    {
        ditto(from, to)
    }
    #[cfg(not(target_os = "macos"))]
    {
        if from.is_dir() {
            file::create_dir_all(to)?;
            file::copy_dir_all_preserve_symlinks(from, to)
        } else {
            file::copy(from, to)
        }
    }
}

fn copy_staged_artifact_closure(stage: &Path, owned_stage: &Path, source: &Path) -> Result<()> {
    let stage = lexically_normalized_path(stage);
    let mut pending = vec![lexically_normalized_path(source)];
    let mut visited = BTreeSet::new();
    while let Some(source) = pending.pop() {
        let relative = staged_relative_path(&stage, &source).ok_or_else(|| {
            eyre!(
                "brew-cask: staged symlink target escaped extraction root: {}",
                source.display()
            )
        })?;
        if relative.components().next().is_some()
            && !source
                .parent()
                .is_some_and(|parent| path_starts_with_resolved_root(parent, &stage))
        {
            bail!(
                "brew-cask: staged symlink path escaped extraction root: {}",
                source.display()
            );
        }
        if !visited.insert(relative.to_path_buf()) {
            continue;
        }
        let destination = owned_stage.join(&relative);
        let metadata = source.symlink_metadata()?;
        if destination.symlink_metadata().is_err() {
            if let Some(parent) = destination.parent() {
                file::create_dir_all(parent)?;
            }
            if metadata.file_type().is_symlink() {
                file::make_symlink(&std::fs::read_link(&source)?, &destination)?;
            } else {
                copy_cask_artifact(&source, &destination)?;
            }
        } else if metadata.is_dir() && destination.is_dir() {
            copy_cask_artifact(&source, &destination)?;
        }

        let symlinks = WalkDir::new(&destination)
            .follow_links(false)
            .into_iter()
            .filter_map(|entry| match entry {
                Ok(entry) if entry.file_type().is_symlink() => Some(Ok(entry.into_path())),
                Ok(_) => None,
                Err(err) => Some(Err(err)),
            })
            .collect::<std::result::Result<Vec<_>, _>>()?;
        for link in symlinks {
            let link_relative = link.strip_prefix(owned_stage)?;
            let original_link = stage.join(link_relative);
            let link_source = std::fs::read_link(&link)?;
            let original_target = lexically_normalized_path(&resolve_symlink_target(
                &original_link,
                link_source.clone(),
            ));
            let Some(target_relative) = staged_relative_path(&stage, &original_target) else {
                continue;
            };
            let owned_target = owned_stage.join(&target_relative);
            pending.push(original_target);
            if link_source.is_absolute() {
                file::remove_file(&link)?;
                file::make_symlink(&owned_target, &link)?;
            }
        }
    }
    Ok(())
}

fn durabilize_staged_symlink_targets(
    stage: &Path,
    temporary_caskroom: &Path,
    targets: &mut FlightTargetTransaction,
) -> Result<()> {
    let owned_stage = temporary_caskroom.join(".homebrew-staged");
    for target in targets.installed.clone() {
        if staged_relative_path(stage, &target).is_some() {
            continue;
        }
        let Ok(metadata) = target.symlink_metadata() else {
            continue;
        };
        if !metadata.file_type().is_symlink() {
            continue;
        }
        let source = std::fs::read_link(&target)?;
        let staged_source = lexically_normalized_path(&resolve_symlink_target(&target, source));
        let Some(relative) = staged_relative_path(stage, &staged_source) else {
            continue;
        };
        let temporary_source = owned_stage.join(relative);
        copy_staged_artifact_closure(stage, &owned_stage, &staged_source)?;
        remove_artifact_target_elevating(&target)?;
        create_flight_symlink(&temporary_source, &target, FlightSudo::IfNeeded)?;
    }
    Ok(())
}

fn retarget_transient_symlinks(
    temporary_caskroom: &Path,
    installed_caskroom: &Path,
    final_caskroom: &Path,
    targets: &FlightTargetTransaction,
) -> Result<()> {
    for target in &targets.installed {
        let Ok(metadata) = target.symlink_metadata() else {
            continue;
        };
        if !metadata.file_type().is_symlink() {
            continue;
        }
        let source = std::fs::read_link(target)?;
        let resolved = resolve_symlink_target(target, source);
        let Ok(relative) = resolved.strip_prefix(temporary_caskroom) else {
            continue;
        };
        remove_artifact_target_elevating(target)?;
        create_flight_symlink(&final_caskroom.join(relative), target, FlightSudo::IfNeeded)?;
    }
    let internal_symlinks = WalkDir::new(installed_caskroom)
        .follow_links(false)
        .min_depth(1)
        .into_iter()
        .filter_map(|entry| match entry {
            Ok(entry) if entry.file_type().is_symlink() => Some(Ok(entry.into_path())),
            Ok(_) => None,
            Err(err) => Some(Err(err)),
        })
        .collect::<std::result::Result<Vec<_>, _>>()?;
    for target in internal_symlinks {
        let source = std::fs::read_link(&target)?;
        // Relative links retain the same relationship when the whole caskroom is
        // renamed. Only absolute links embed the temporary caskroom path.
        if !source.is_absolute() {
            continue;
        }
        let Ok(relative) = source.strip_prefix(temporary_caskroom) else {
            continue;
        };
        file::remove_file(&target)?;
        create_flight_symlink(&final_caskroom.join(relative), &target, FlightSudo::Never)?;
    }
    Ok(())
}

fn install_generic_artifact(
    stage: &Path,
    temporary_caskroom: &Path,
    artifact: &GenericArtifact,
    targets: &mut FlightTargetTransaction,
) -> Result<()> {
    let source = find_artifact_matching(stage, &artifact.source, |_| true)
        .ok_or_else(|| eyre!("brew-cask: artifact '{}' was not found", artifact.source))?;
    if !path_starts_with_resolved_root(&source, stage) {
        bail!(
            "brew-cask: refusing generic artifact source outside the extraction root: {}",
            source.display()
        );
    }
    let target = generic_artifact_target_path(&artifact.target)?;
    let relative_source = source.strip_prefix(stage)?;
    let caskroom_source = temporary_caskroom.join(relative_source);
    if !path_starts_with_resolved_root(&caskroom_source, temporary_caskroom) {
        bail!(
            "brew-cask: refusing to stage generic artifact through a path outside the caskroom: {}",
            caskroom_source.display()
        );
    }
    #[cfg(not(unix))]
    if let Some(parent) = target.parent() {
        file::create_dir_all(parent)?;
    }
    let elevated_target = targets.protect_generic(&target)?;
    copy_generic_artifact(&source, &target, elevated_target.as_deref())?;
    if let Some(parent) = caskroom_source.parent() {
        file::create_dir_all(parent)?;
    }
    file::make_symlink(&target, &caskroom_source)?;
    targets.record_installed(target);
    Ok(())
}

fn copy_generic_artifact(from: &Path, to: &Path, elevated_target: Option<&Path>) -> Result<()> {
    validate_generic_copy_target(to)?;
    #[cfg(unix)]
    {
        if let Some(target) = elevated_target {
            copy_generic_artifact_elevated(from, target)
        } else {
            copy_generic_artifact_unprivileged(from, to)
        }
    }
    #[cfg(not(unix))]
    {
        let _ = elevated_target;
        copy_cask_artifact(from, to)
    }
}

#[cfg(unix)]
fn copy_generic_artifact_unprivileged(from: &Path, to: &Path) -> Result<()> {
    let parent = open_trusted_operation_parent(to, true, true)?;
    let name = to
        .file_name()
        .ok_or_else(|| eyre!("brew-cask: generic artifact target has no filename"))?;
    let staging_name = format!(".mise-copy-{}", crate::rand::random_string(16));
    nix::sys::stat::mkdirat(
        &parent.fd,
        staging_name.as_str(),
        nix::sys::stat::Mode::S_IRWXU,
    )?;
    let flags = nix::fcntl::OFlag::O_RDONLY
        | nix::fcntl::OFlag::O_DIRECTORY
        | nix::fcntl::OFlag::O_NOFOLLOW;
    let staging_fd = nix::fcntl::openat(
        &parent.fd,
        staging_name.as_str(),
        flags,
        nix::sys::stat::Mode::empty(),
    )?;
    let staging_stat = nix::sys::stat::fstat(&staging_fd)?;
    if staging_stat.st_uid != nix::unistd::geteuid().as_raw() || staging_stat.st_mode & 0o077 != 0 {
        bail!("brew-cask: temporary artifact directory is not private");
    }
    let staging = TrustedOperationParent { fd: staging_fd };
    let temporary_name = std::ffi::OsStr::new("payload");
    match copy_cask_artifact_at(from, &staging.fd, temporary_name) {
        Ok(()) => {
            match nix::fcntl::renameat(&staging.fd, temporary_name, &parent.fd, name)
                .wrap_err_with(|| format!("failed to install {}", to.display()))
            {
                Ok(()) => {
                    remove_private_staging_dir(&parent, &staging, staging_name.as_ref())?;
                    Ok(())
                }
                Err(err) => {
                    remove_all_at(&staging.fd, temporary_name).wrap_err_with(|| {
                            format!(
                                "failed to clean up temporary generic artifact after rename failed: {err:#}"
                            )
                        })?;
                    remove_private_staging_dir(&parent, &staging, staging_name.as_ref())?;
                    Err(err)
                }
            }
        }
        Err(err) => {
            let _ = remove_all_at(&staging.fd, temporary_name);
            let _ = remove_private_staging_dir(&parent, &staging, staging_name.as_ref());
            Err(err)
        }
    }
}

#[cfg(unix)]
fn copy_generic_artifact_elevated(from: &Path, to: &Path) -> Result<()> {
    ensure_target_absent(to)?;
    let staging = tempfile::Builder::new()
        .prefix("mise-cask-copy-")
        .tempdir()?;
    let payload = staging.path().join("payload");
    copy_cask_artifact(from, &payload)?;
    sudo::run(
        "mv",
        &[
            "--".into(),
            payload.display().to_string(),
            to.display().to_string(),
        ],
        &[],
    )
}

#[cfg(unix)]
fn ensure_strict_elevated_target(target: &Path) -> Result<PathBuf> {
    let parent = target
        .parent()
        .ok_or_else(|| eyre!("brew-cask: generic artifact target has no parent"))?;
    let mut existing = parent;
    loop {
        match existing.symlink_metadata() {
            Ok(_) => break,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
                existing = existing.parent().ok_or_else(|| {
                    eyre!("brew-cask: generic artifact parent has no existing ancestor")
                })?;
            }
            Err(err) => return Err(err.into()),
        }
    }
    let trusted = open_trusted_operation_parent(&existing.join(".mise-validation"), false, false)?;
    let stable_existing = trusted.stable_path()?;
    validate_strict_elevated_ancestors(&stable_existing)?;
    let missing = parent.strip_prefix(existing)?;
    let stable_parent = stable_existing.join(missing);
    if existing != parent {
        sudo::run(
            "mkdir",
            &[
                "-p".into(),
                "--".into(),
                stable_parent.display().to_string(),
            ],
            &[],
        )?;
    }
    let stable_parent = std::fs::canonicalize(&stable_parent)?;
    validate_strict_elevated_ancestors(&stable_parent)?;
    Ok(stable_parent.join(
        target
            .file_name()
            .ok_or_else(|| eyre!("brew-cask: generic artifact target has no filename"))?,
    ))
}

#[cfg(unix)]
fn validate_strict_elevated_ancestors(path: &Path) -> Result<()> {
    use std::os::unix::fs::MetadataExt;
    let stable_prefix = file::desymlink_path(&prefix::prefix());
    for directory in path.ancestors() {
        let metadata = directory.symlink_metadata()?;
        if !strict_elevated_directory_is_trusted(
            directory,
            &stable_prefix,
            metadata.uid(),
            metadata.mode(),
        ) {
            bail!(
                "brew-cask: refusing elevated operation through mutable directory {}",
                directory.display()
            );
        }
    }
    Ok(())
}

#[cfg(unix)]
fn strict_elevated_directory_is_trusted(
    directory: &Path,
    stable_prefix: &Path,
    uid: u32,
    mode: u32,
) -> bool {
    uid == 0
        && mode & 0o002 == 0
        // Intel Homebrew conventionally uses root:admin 0775 for /usr/local.
        // Permit that exact prefix, but require every descendant and every
        // other ancestor used by the elevated operation to be non-writable.
        && (mode & 0o020 == 0 || directory == stable_prefix)
}

#[cfg(unix)]
fn ensure_target_absent(target: &Path) -> Result<()> {
    match target.symlink_metadata() {
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Ok(_) => bail!(
            "brew-cask: refusing elevated operation because target appeared: {}",
            target.display()
        ),
        Err(err) => Err(err.into()),
    }
}

#[cfg(unix)]
fn copy_cask_artifact_at<Fd: std::os::fd::AsFd>(
    from: &Path,
    parent: Fd,
    name: &std::ffi::OsStr,
) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;

    let metadata = from.symlink_metadata()?;
    let mode = nix::sys::stat::Mode::from_bits_truncate(
        metadata.permissions().mode() as nix::libc::mode_t
    );
    if metadata.file_type().is_symlink() {
        nix::unistd::symlinkat(&std::fs::read_link(from)?, parent, name)?;
    } else if metadata.is_dir() {
        nix::sys::stat::mkdirat(&parent, name, nix::sys::stat::Mode::S_IRWXU)?;
        let fd = nix::fcntl::openat(
            &parent,
            name,
            nix::fcntl::OFlag::O_RDONLY
                | nix::fcntl::OFlag::O_DIRECTORY
                | nix::fcntl::OFlag::O_NOFOLLOW,
            nix::sys::stat::Mode::empty(),
        )?;
        for entry in std::fs::read_dir(from)? {
            let entry = entry?;
            copy_cask_artifact_at(&entry.path(), &fd, &entry.file_name())?;
        }
        nix::sys::stat::fchmod(&fd, mode)?;
    } else if metadata.is_file() {
        let destination = nix::fcntl::openat(
            parent,
            name,
            nix::fcntl::OFlag::O_WRONLY
                | nix::fcntl::OFlag::O_CREAT
                | nix::fcntl::OFlag::O_EXCL
                | nix::fcntl::OFlag::O_NOFOLLOW,
            nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR,
        )?;
        let mut source = std::fs::File::open(from)?;
        let mut destination = std::fs::File::from(destination);
        copy_file_contents(&mut source, &mut destination)?;
        nix::sys::stat::fchmod(&destination, mode)?;
    } else {
        bail!(
            "brew-cask: unsupported generic artifact type: {}",
            from.display()
        );
    }
    Ok(())
}

#[cfg(all(unix, target_os = "macos"))]
fn copy_file_contents(from: &mut std::fs::File, to: &mut std::fs::File) -> Result<()> {
    use std::os::fd::AsRawFd;

    // SAFETY: both descriptors remain open for the call and fcopyfile does not
    // retain them. A null state requests the default copyfile state.
    let result = unsafe {
        nix::libc::fcopyfile(
            from.as_raw_fd(),
            to.as_raw_fd(),
            std::ptr::null_mut(),
            nix::libc::COPYFILE_DATA | nix::libc::COPYFILE_METADATA,
        )
    };
    if result == 0 {
        Ok(())
    } else {
        Err(std::io::Error::last_os_error().into())
    }
}

#[cfg(all(unix, not(target_os = "macos")))]
fn copy_file_contents(from: &mut std::fs::File, to: &mut std::fs::File) -> Result<()> {
    std::io::copy(from, to)?;
    Ok(())
}

#[cfg(unix)]
fn remove_all_at<Fd: std::os::fd::AsFd>(parent: Fd, name: &std::ffi::OsStr) -> Result<()> {
    let stat =
        match nix::sys::stat::fstatat(&parent, name, nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW) {
            Ok(stat) => stat,
            Err(nix::errno::Errno::ENOENT) => return Ok(()),
            Err(err) => return Err(err.into()),
        };
    let kind = nix::sys::stat::SFlag::from_bits_truncate(stat.st_mode);
    if kind.contains(nix::sys::stat::SFlag::S_IFDIR) {
        let fd = nix::fcntl::openat(
            &parent,
            name,
            nix::fcntl::OFlag::O_RDONLY
                | nix::fcntl::OFlag::O_DIRECTORY
                | nix::fcntl::OFlag::O_NOFOLLOW,
            nix::sys::stat::Mode::empty(),
        )?;
        let mut directory = nix::dir::Dir::from_fd(fd)?;
        let entries = directory
            .iter()
            .map(|entry| entry.map(|entry| entry.file_name().to_owned()))
            .collect::<std::result::Result<Vec<_>, _>>()?;
        for entry in entries {
            if entry.as_bytes() != b"." && entry.as_bytes() != b".." {
                remove_all_at(&directory, std::ffi::OsStr::from_bytes(entry.to_bytes()))?;
            }
        }
        nix::unistd::unlinkat(parent, name, nix::unistd::UnlinkatFlags::RemoveDir)?;
    } else {
        nix::unistd::unlinkat(parent, name, nix::unistd::UnlinkatFlags::NoRemoveDir)?;
    }
    Ok(())
}

#[cfg(unix)]
fn remove_private_staging_dir(
    parent: &TrustedOperationParent,
    staging: &TrustedOperationParent,
    staging_name: &std::ffi::OsStr,
) -> Result<()> {
    let bound = nix::sys::stat::fstat(&staging.fd)?;
    let linked = nix::sys::stat::fstatat(
        &parent.fd,
        staging_name,
        nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW,
    )?;
    if bound.st_dev != linked.st_dev || bound.st_ino != linked.st_ino {
        bail!("brew-cask: temporary artifact directory was replaced");
    }
    nix::unistd::unlinkat(
        &parent.fd,
        staging_name,
        nix::unistd::UnlinkatFlags::RemoveDir,
    )?;
    Ok(())
}

fn validate_generic_copy_target(target: &Path) -> Result<()> {
    let prefix = prefix::prefix();
    if !target.starts_with(&prefix)
        || target.strip_prefix(&prefix)?.components().next().is_none()
        || !path_starts_with_resolved_root(target, &prefix)
    {
        bail!(
            "brew-cask: refusing generic artifact copy outside Homebrew prefix: {}",
            target.display()
        );
    }
    Ok(())
}

#[cfg(unix)]
struct TrustedOperationParent {
    fd: std::os::fd::OwnedFd,
}

#[cfg(unix)]
impl TrustedOperationParent {
    fn path(&self) -> Result<PathBuf> {
        #[cfg(target_os = "linux")]
        return Ok(
            Path::new("/proc/self/fd").join(std::os::fd::AsRawFd::as_raw_fd(&self.fd).to_string())
        );
        #[cfg(any(target_os = "macos", target_os = "ios"))]
        {
            let mut path = PathBuf::new();
            nix::fcntl::fcntl(&self.fd, nix::fcntl::FcntlArg::F_GETPATH(&mut path))?;
            Ok(path)
        }
        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "ios")))]
        Ok(Path::new("/dev/fd").join(std::os::fd::AsRawFd::as_raw_fd(&self.fd).to_string()))
    }

    fn stable_path(&self) -> Result<PathBuf> {
        Ok(std::fs::canonicalize(self.path()?)?)
    }
}

#[cfg(unix)]
fn trusted_parent_is_writable(parent: &TrustedOperationParent) -> Result<bool> {
    let stat = nix::sys::stat::fstat(&parent.fd)?;
    let uid = nix::unistd::geteuid().as_raw();
    if uid == 0 {
        return Ok(true);
    }
    if stat.st_uid == uid {
        return Ok(stat.st_mode & 0o200 != 0);
    }
    let gid = nix::unistd::getegid().as_raw();
    if stat.st_gid == gid || current_process_groups()?.contains(&stat.st_gid) {
        return Ok(stat.st_mode & 0o020 != 0);
    }
    Ok(stat.st_mode & 0o002 != 0)
}

#[cfg(unix)]
fn current_process_groups() -> Result<Vec<nix::libc::gid_t>> {
    // SAFETY: A null buffer with size zero is the documented way to query the
    // number of supplementary groups and does not dereference the pointer.
    let count = unsafe { nix::libc::getgroups(0, std::ptr::null_mut()) };
    if count < 0 {
        return Err(std::io::Error::last_os_error().into());
    }
    let mut groups = vec![0; count as usize];
    // SAFETY: `groups` has capacity for exactly `count` gid_t values;
    // getgroups returns an error instead of writing when that is insufficient.
    let count = unsafe { nix::libc::getgroups(count, groups.as_mut_ptr()) };
    if count < 0 {
        return Err(std::io::Error::last_os_error().into());
    }
    groups.truncate(count as usize);
    Ok(groups)
}

#[cfg(unix)]
fn sudo_invoking_id(effective_uid: u32, variable: &str) -> Option<u32> {
    sudo_invoking_id_from(effective_uid, crate::env::var(variable).ok().as_deref())
}

#[cfg(unix)]
fn sudo_invoking_id_from(effective_uid: u32, value: Option<&str>) -> Option<u32> {
    if effective_uid != 0 {
        return None;
    }
    value
        .and_then(|value| value.parse().ok())
        .filter(|id| *id != 0)
}

#[cfg(unix)]
fn open_trusted_operation_parent(
    target: &Path,
    allow_current_user: bool,
    create_missing: bool,
) -> Result<TrustedOperationParent> {
    let prefix = prefix::prefix();
    let parent = target
        .parent()
        .ok_or_else(|| eyre!("brew-cask: generic artifact target has no parent"))?;
    let relative_parent = parent.strip_prefix(&prefix)?;
    let resolved_prefix = file::desymlink_path(&prefix);
    open_trusted_directory(
        &resolved_prefix,
        relative_parent,
        allow_current_user,
        create_missing,
    )
}

/// Open (and optionally create) `resolved_root`/`relative` one component at a
/// time with `O_NOFOLLOW`, verifying at every step that the component is a real
/// directory owned by root or the current user and not writable by an untrusted
/// party. `resolved_root` must already be a real (symlink-free) absolute path.
///
/// Walking component-by-component with `O_NOFOLLOW` binds the operation to the
/// exact directory chain that existed when the walk ran: a symlink cannot be
/// interposed for any component, and because every ancestor is verified as
/// non-untrusted-writable an unprivileged attacker cannot plant one to begin
/// with. Callers then mutate relative to the returned directory fd.
#[cfg(unix)]
fn open_trusted_directory(
    resolved_root: &Path,
    relative: &Path,
    allow_current_user: bool,
    create_missing: bool,
) -> Result<TrustedOperationParent> {
    use nix::fcntl::{OFlag, open, openat};
    use nix::sys::stat::{Mode, SFlag, fstat};

    let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW;
    let mut fd = open(resolved_root, flags, Mode::empty()).wrap_err_with(|| {
        format!(
            "brew-cask: cannot open operation directory {}",
            resolved_root.display()
        )
    })?;
    let current_uid = nix::unistd::geteuid().as_raw();
    let current_gid = nix::unistd::getegid().as_raw();
    let current_groups = current_process_groups()?;
    let sudo_uid = sudo_invoking_id(current_uid, "SUDO_UID");
    let sudo_gid = sudo_invoking_id(current_uid, "SUDO_GID");
    let verify = |fd: &std::os::fd::OwnedFd, directory: &Path| -> Result<()> {
        let stat = fstat(fd)?;
        let owner_is_user = stat.st_uid == current_uid || Some(stat.st_uid) == sudo_uid;
        let trusted_owner = stat.st_uid == 0 || (allow_current_user && owner_is_user);
        let trusted_group = stat.st_gid == current_gid
            || Some(stat.st_gid) == sudo_gid
            || current_groups.contains(&stat.st_gid);
        let writable_by_untrusted = stat.st_mode & 0o002 != 0
            || (stat.st_mode & 0o020 != 0 && (!allow_current_user || !trusted_group));
        if !SFlag::from_bits_truncate(stat.st_mode).contains(SFlag::S_IFDIR)
            || !trusted_owner
            || writable_by_untrusted
        {
            bail!(
                "brew-cask: refusing operation through untrusted directory {}",
                directory.display()
            );
        }
        Ok(())
    };
    let mut directory = resolved_root.to_path_buf();
    verify(&fd, &directory)?;
    for component in relative.components() {
        let Component::Normal(name) = component else {
            bail!("brew-cask: invalid generic artifact parent");
        };
        directory.push(name);
        fd = match openat(&fd, name, flags, Mode::empty()) {
            Ok(fd) => fd,
            Err(nix::errno::Errno::ENOENT) if create_missing => {
                match nix::sys::stat::mkdirat(
                    &fd,
                    name,
                    Mode::S_IRWXU | Mode::S_IRGRP | Mode::S_IXGRP | Mode::S_IROTH | Mode::S_IXOTH,
                ) {
                    Ok(()) | Err(nix::errno::Errno::EEXIST) => {}
                    Err(err) => {
                        return Err(err).wrap_err_with(|| {
                            format!(
                                "brew-cask: cannot create operation directory {}",
                                directory.display()
                            )
                        });
                    }
                }
                openat(&fd, name, flags, Mode::empty()).wrap_err_with(|| {
                    format!(
                        "brew-cask: cannot open operation directory {}",
                        directory.display()
                    )
                })?
            }
            Err(err) => {
                return Err(err).wrap_err_with(|| {
                    format!(
                        "brew-cask: cannot open operation directory {}",
                        directory.display()
                    )
                });
            }
        };
        verify(&fd, &directory)?;
    }
    Ok(TrustedOperationParent { fd })
}

/// Create the appdir (if missing) using symlink-safe, trust-verified directory
/// operations and return the bound descriptor.
///
/// Every component is opened with `openat`/`O_NOFOLLOW` and trust-checked, and
/// missing components are created with `mkdirat`, so a symlink interposed on any
/// component — including a not-yet-existing tail — is rejected rather than
/// followed.
///
/// The descriptor is returned rather than dropped so the caller can address the
/// app through it. Releasing it here would reintroduce the race: a replacement
/// of an accepted (user-owned) component between validation and mutation would
/// otherwise redirect the subsequent copy, rename, removal, and ownership
/// recovery, all of which resolve pathnames again.
#[cfg(unix)]
fn ensure_trusted_appdir(appdir: &Path) -> Result<TrustedOperationParent> {
    // Anchor the walk at `/` and verify every component from there. `/` is the
    // only directory that cannot be renamed or replaced, so it is the one safe
    // pathname to open; each component below it is opened with `openat` and
    // `O_NOFOLLOW` and trust-checked before descending.
    //
    // Scanning for the deepest existing ancestor and re-opening *that* by
    // pathname would reintroduce a race: between the scan and the open, a
    // same-uid process could replace the scanned directory with another
    // same-uid-owned real directory, which would pass the ownership checks and
    // become the retained descriptor. Starting from an unreplaceable root and
    // descending only through verified descriptors removes that window.
    // The path is walked exactly as given — it is NOT canonicalized here.
    // Canonicalizing would follow a symlink planted on the appdir or its tail,
    // which is precisely the substitution this walk must reject. Configured
    // overrides are already resolved by `target_app_dir`, so production paths
    // are symlink-free and the walk succeeds; a planted symlink hits
    // `O_NOFOLLOW` and fails.
    let relative = appdir.strip_prefix(Path::new("/")).map_err(|_| {
        eyre!(
            "brew-cask: app directory '{}' must be an absolute path",
            appdir.display()
        )
    })?;
    // `allow_current_user` is true because a per-user appdir such as
    // `~/Applications` is legitimately owned by the invoking user.
    open_trusted_directory(Path::new("/"), relative, true, true)
}

fn run_installer_artifact(
    stage: &Path,
    installer: &InstallerArtifact,
    copied_files: &BTreeSet<PathBuf>,
) -> Result<()> {
    let executable = stage.join(&installer.executable);
    if staged_relative_path(stage, &executable).is_none() {
        bail!(
            "brew-cask: refusing installer executable outside trusted installer roots: {}",
            executable.display()
        );
    }
    if !executable.is_file() {
        bail!(
            "brew-cask: installer executable '{}' was not found",
            installer.executable
        );
    }
    let executable = file::desymlink_path(&executable);
    if !executable.starts_with(file::desymlink_path(stage)) && !copied_files.contains(&executable) {
        bail!(
            "brew-cask: refusing installer executable outside trusted installer roots: {}",
            executable.display()
        );
    }
    file::make_executable(&executable)?;
    let prefix = prefix::prefix();
    let mut paths = vec![prefix.join("bin"), prefix.join("sbin")];
    if let Some(path) = std::env::var_os("PATH") {
        paths.extend(std::env::split_paths(&path));
    }
    let path = std::env::join_paths(paths)?;
    CmdLineRunner::new(executable)
        .env("PATH", path)
        .args(&installer.args)
        .raw(true)
        .execute()
}

fn run_installers_before_durabilizing(
    stage: &Path,
    temporary_caskroom: &Path,
    installers: &[InstallerArtifact],
    targets: &mut FlightTargetTransaction,
    mut completed: impl FnMut(usize) -> Result<()>,
) -> Result<()> {
    for (index, installer) in installers.iter().enumerate() {
        run_installer_artifact(stage, installer, targets.copied_files())?;
        completed(index)?;
    }
    durabilize_staged_symlink_targets(stage, temporary_caskroom, targets)
}

fn generic_artifact_target_path(target: &str) -> Result<PathBuf> {
    let prefix = prefix::prefix();
    let expanded = target.replace("$HOMEBREW_PREFIX", &prefix.to_string_lossy());
    let target = PathBuf::from(expanded);
    if !target.is_absolute()
        || !target.starts_with(&prefix)
        || target.strip_prefix(&prefix)?.components().next().is_none()
        || target
            .components()
            .any(|component| matches!(component, Component::ParentDir))
        || !path_starts_with_resolved_root(&target, &prefix)
    {
        bail!(
            "brew-cask: generic artifact target '{}' must stay below {}",
            target.display(),
            prefix.display()
        );
    }
    Ok(target)
}

fn generic_artifact_targets(artifacts: &CaskArtifacts) -> Result<Vec<PathBuf>> {
    artifacts
        .generic
        .iter()
        .map(|artifact| generic_artifact_target_path(&artifact.target))
        .collect()
}

fn previous_generic_targets(cask: &Cask) -> Result<Vec<CaskTargetRecord>> {
    let Some(version) = installed_version(&cask.token) else {
        return Ok(Vec::new());
    };
    let version_dir = caskroom_version_dir(&cask.token, &version);
    let Some(receipt) = read_receipt(&version_dir)? else {
        return Ok(Vec::new());
    };
    Ok(receipt
        .targets
        .into_iter()
        .filter(|record| receipt.generic.contains(&record.path))
        .collect())
}

fn remove_obsolete_generic_artifacts(
    previous_targets: &[CaskTargetRecord],
    current_targets: &[PathBuf],
) -> Result<()> {
    let prefix = prefix::prefix();
    for record in previous_targets {
        if current_targets.contains(&record.path) || !cask_target_record_matches(record)? {
            continue;
        }
        if !path_starts_with_resolved_root(&record.path, &prefix) {
            bail!(
                "brew-cask: refusing to remove generic artifact outside {}: {}",
                prefix.display(),
                record.path.display()
            );
        }
        if let Err(err) = remove_trusted_generic_target(&record.path) {
            warn!(
                "brew-cask: leaving obsolete generic artifact {} because its parent directories are mutable: {err:#}",
                record.path.display()
            );
        }
    }
    Ok(())
}

fn remove_trusted_generic_target(target: &Path) -> Result<()> {
    let expected_parent = resolved_parent(target)?;
    match remove_trusted_generic_target_from(target, &expected_parent) {
        Ok(()) => Ok(()),
        Err(err) if is_permission_denied(&err) => {
            #[cfg(unix)]
            let operation_target = ensure_strict_elevated_target(target)?;
            #[cfg(not(unix))]
            let operation_target = target.to_path_buf();
            remove_artifact_target_elevating(&operation_target).wrap_err_with(|| {
                format!(
                    "failed to remove generic artifact after unprivileged removal failed: {err:#}"
                )
            })
        }
        Err(err) => Err(err),
    }
}

fn remove_trusted_generic_target_from(target: &Path, expected_parent: &Path) -> Result<()> {
    #[cfg(unix)]
    {
        let parent = open_trusted_operation_parent(target, true, false)?;
        validate_trusted_operation_parent(&parent, expected_parent)?;
        let name = target
            .file_name()
            .ok_or_else(|| eyre!("brew-cask: generic artifact target has no filename"))?;
        remove_all_at(&parent.fd, name)
    }
    #[cfg(not(unix))]
    {
        let _ = expected_parent;
        file::remove_all(target)
    }
}

#[cfg(unix)]
fn validate_trusted_operation_parent(
    parent: &TrustedOperationParent,
    expected_parent: &Path,
) -> Result<()> {
    let actual_parent = std::fs::canonicalize(parent.path()?)?;
    if actual_parent != expected_parent {
        bail!(
            "brew-cask: refusing operation through a changed generic artifact parent: {}",
            expected_parent.display()
        );
    }
    Ok(())
}

fn rename_trusted_generic_target(from: &Path, to: &Path, expected_parent: &Path) -> Result<()> {
    #[cfg(unix)]
    {
        if from.parent() != to.parent() {
            bail!("brew-cask: generic artifact backup changed directories");
        }
        let parent = open_trusted_operation_parent(from, true, false)?;
        validate_trusted_operation_parent(&parent, expected_parent)?;
        let from_name = from
            .file_name()
            .ok_or_else(|| eyre!("brew-cask: generic artifact source has no filename"))?;
        let to_name = to
            .file_name()
            .ok_or_else(|| eyre!("brew-cask: generic artifact target has no filename"))?;
        nix::fcntl::renameat(&parent.fd, from_name, &parent.fd, to_name)?;
        Ok(())
    }
    #[cfg(not(unix))]
    {
        let _ = expected_parent;
        file::rename(from, to)
    }
}

fn ditto(from: &Path, to: &Path) -> Result<()> {
    let status = std::process::Command::new("ditto")
        .arg(from)
        .arg(to)
        .status()
        .wrap_err("failed to run ditto")?;
    if !status.success() {
        bail!(
            "ditto failed copying {} to {}",
            from.display(),
            to.display()
        );
    }
    Ok(())
}

/// Run a helper with its working directory bound to `dir` via `fchdir`, so
/// relative arguments resolve from that exact directory inode.
///
/// Passing a pathname to a subprocess would let it re-resolve every component,
/// which a same-uid replacement can redirect. `fchdir` in the child pins
/// resolution to the descriptor mise already verified, so relative names cannot
/// escape the validated application directory.
#[cfg(unix)]
fn run_in_trusted_dir<Fd: std::os::fd::AsFd>(
    program: &str,
    args: &[&std::ffi::OsStr],
    dir: Fd,
) -> Result<std::process::ExitStatus> {
    use std::os::fd::AsRawFd;
    use std::os::unix::process::CommandExt;

    let raw = dir.as_fd().as_raw_fd();
    let mut cmd = std::process::Command::new(program);
    cmd.args(args);
    // SAFETY: `fchdir` is async-signal-safe and only alters the child's working
    // directory. `raw` stays open in the parent for the duration of the spawn,
    // and CLOEXEC (if set) only takes effect at exec, after pre_exec runs.
    unsafe {
        cmd.pre_exec(move || {
            if nix::libc::fchdir(raw) == -1 {
                return Err(std::io::Error::last_os_error());
            }
            Ok(())
        });
    }
    cmd.status()
        .wrap_err_with(|| format!("failed to run {program}"))
}

/// Copy the bundle `from` into a freshly created directory `name` inside `dir`,
/// without ever letting the copy resolve `name` as a pathname.
///
/// `name` is created with `mkdirat` and then opened with `openat`/`O_NOFOLLOW`,
/// so the destination is guaranteed to be a real directory this call created.
/// `ditto` is then pointed at that descriptor's working directory, so it writes
/// into the bound inode rather than resolving a relative name. Without this, a
/// same-uid process could create the predictable temporary name as a symlink
/// after the preceding removal and `ditto` would follow it, copying the
/// application outside the verified application directory.
///
/// A racing creation of `name` surfaces as `EEXIST` and fails closed rather than
/// being followed.
#[cfg(unix)]
fn ditto_into<Fd: std::os::fd::AsFd>(from: &Path, dir: Fd, name: &std::ffi::OsStr) -> Result<()> {
    nix::sys::stat::mkdirat(&dir, name, nix::sys::stat::Mode::S_IRWXU).wrap_err_with(|| {
        format!(
            "brew-cask: cannot create staging directory {}",
            Path::new(name).display()
        )
    })?;
    let destination = nix::fcntl::openat(
        &dir,
        name,
        nix::fcntl::OFlag::O_RDONLY
            | nix::fcntl::OFlag::O_DIRECTORY
            | nix::fcntl::OFlag::O_NOFOLLOW,
        nix::sys::stat::Mode::empty(),
    )
    .wrap_err_with(|| {
        format!(
            "brew-cask: cannot open staging directory {}",
            Path::new(name).display()
        )
    })?;
    let stat = nix::sys::stat::fstat(&destination)?;
    if stat.st_uid != nix::unistd::geteuid().as_raw() {
        bail!("brew-cask: staging directory is not owned by the current user");
    }
    // `ditto src dst` copies the *contents* of src into dst, so pointing it at
    // the bound directory reproduces the bundle in place.
    let status = run_in_trusted_dir(
        "ditto",
        &[from.as_os_str(), std::ffi::OsStr::new(".")],
        &destination,
    )?;
    if !status.success() {
        bail!(
            "ditto failed copying {} to {}",
            from.display(),
            Path::new(name).display()
        );
    }
    // Restore the bundle's own permissions, which the private staging mode hid.
    if let Ok(metadata) = from.symlink_metadata() {
        use std::os::unix::fs::PermissionsExt;
        let mode = nix::sys::stat::Mode::from_bits_truncate(
            metadata.permissions().mode() as nix::libc::mode_t
        );
        nix::sys::stat::fchmod(&destination, mode)?;
    }
    Ok(())
}

fn install_pkg(stage: &Path, pkg: &PkgArtifact) -> Result<()> {
    let source = find_file_artifact(stage, &pkg.source)
        .ok_or_else(|| eyre!("brew-cask: pkg artifact '{}' was not found", pkg.source))?;
    let args = vec![
        "-pkg".to_string(),
        source.display().to_string(),
        "-target".to_string(),
        "/".to_string(),
    ];
    sudo::run("installer", &args, &[])
}

fn stage_font(stage: &Path, caskroom: &Path, font: &FontArtifact) -> Result<()> {
    let caskroom_font = caskroom_font_path(caskroom, font)?;
    file::remove_all(&caskroom_font)?;
    if let Some(parent) = caskroom_font.parent() {
        file::create_dir_all(parent)?;
    }
    let source = find_file_artifact(stage, &font.source)
        .ok_or_else(|| eyre!("brew-cask: font artifact '{}' was not found", font.source))?;
    copy_cask_artifact(&source, &caskroom_font)?;
    Ok(())
}

fn link_font(caskroom: &Path, font: &FontArtifact) -> Result<()> {
    let caskroom_font = caskroom_font_path(caskroom, font)?;
    if !caskroom_font.is_file() {
        bail!("brew-cask: font artifact '{}' was not staged", font.source);
    }
    let target = font_target_path(font)?;
    if let Some(parent) = target.parent() {
        file::create_dir_all(parent)?;
    }
    // Atomic swap: rename existing font aside before copying the new one so
    // that a failure during copy leaves the old font intact.
    let old_target = target.with_extension(format!(
        "mise-old-{}",
        crate::hash::hash_to_str(&target.display().to_string())
    ));
    file::remove_all(&old_target)?;
    if target.exists() {
        file::rename(&target, &old_target)?;
    }
    if let Err(e) = copy_cask_artifact(&caskroom_font, &target) {
        if old_target.exists() {
            let _ = file::rename(&old_target, &target);
        }
        return Err(e);
    }
    file::remove_all(&old_target)?;
    Ok(())
}

fn caskroom_font_path(caskroom: &Path, font: &FontArtifact) -> Result<PathBuf> {
    let name = font_filename(font)?;
    Ok(caskroom.join(name))
}

fn font_filename(font: &FontArtifact) -> Result<String> {
    match &font.target {
        Some(target) => {
            let home = crate::dirs::HOME.to_string_lossy();
            let mut expanded = target.replace("$HOME", &home);
            if let Some(rest) = expanded.strip_prefix("~/") {
                expanded = home.to_string() + "/" + rest;
            } else if expanded == "~" {
                expanded = home.to_string();
            }
            let expanded_path = Path::new(&expanded);
            if expanded_path.is_absolute() {
                let macos_fonts_dir = crate::dirs::HOME.join("Library").join("Fonts");
                for fonts_dir in [font_dir(), macos_fonts_dir] {
                    if let Ok(relative) = expanded_path.strip_prefix(fonts_dir) {
                        return Ok(relative.to_string_lossy().to_string());
                    }
                }
                return expanded_path
                    .file_name()
                    .and_then(|n| n.to_str())
                    .map(str::to_string)
                    .ok_or_else(|| eyre!("brew-cask: invalid font target '{}'", target));
            }
            Ok(expanded)
        }
        None => Path::new(&font.source)
            .file_name()
            .and_then(|n| n.to_str())
            .map(str::to_string)
            .ok_or_else(|| eyre!("brew-cask: invalid font source '{}'", font.source)),
    }
}

fn font_target_paths(artifacts: &CaskArtifacts) -> Result<Vec<PathBuf>> {
    artifacts
        .fonts
        .iter()
        .map(font_target_path)
        .collect::<Result<Vec<_>>>()
}

fn previous_font_targets(cask: &Cask) -> Result<Vec<PathBuf>> {
    let Some(version) = installed_version(&cask.token) else {
        return Ok(Vec::new());
    };
    let version_dir = caskroom_version_dir(&cask.token, &version);
    Ok(read_receipt(&version_dir)?
        .map(|receipt| receipt.fonts)
        .unwrap_or_default())
}

fn remove_obsolete_fonts(
    cask: &Cask,
    previous_targets: &[PathBuf],
    current_targets: &[PathBuf],
) -> Result<()> {
    let token_dir = file::desymlink_path(&caskroom_token_dir(&cask.token));
    for target in previous_targets {
        if current_targets.contains(target) {
            continue;
        }
        if !target.is_file() {
            continue;
        }
        // Only remove the file if it was staged by us — check that it
        // resides under the platform font directory and the caskroom still has a
        // staged copy (from the previous version directory).
        let fonts_dir = font_dir();
        if !target.starts_with(&fonts_dir) {
            continue;
        }
        // Check if any version directory under the token dir contains this font
        // path, indicating it was staged by a previous version of this cask.
        let relative = target.strip_prefix(&fonts_dir).ok();
        let has_staged_copy = std::fs::read_dir(&token_dir)
            .ok()
            .into_iter()
            .flatten()
            .filter_map(|entry| entry.ok())
            .filter(|entry| entry.file_type().is_ok_and(|ft| ft.is_dir()))
            .any(|entry| relative.is_some_and(|path| entry.path().join(path).is_file()));
        if has_staged_copy {
            file::remove_file(target)?;
        }
    }
    Ok(())
}

fn font_target_path(font: &FontArtifact) -> Result<PathBuf> {
    let name = font_filename(font)?;
    let name_path = Path::new(&name);
    if name_path.is_absolute()
        || name_path
            .components()
            .any(|component| matches!(component, Component::ParentDir))
        || name_path.components().next().is_none()
    {
        bail!("brew-cask: invalid font target '{}'", name);
    }
    Ok(font_dir().join(name_path))
}

fn font_dir() -> PathBuf {
    if cfg!(target_os = "linux") {
        crate::env::XDG_DATA_HOME.join("fonts")
    } else {
        crate::dirs::HOME.join("Library").join("Fonts")
    }
}

#[cfg(test)]
fn execute_flight_steps(
    cask: &Cask,
    steps: &[FlightStep],
    staged_path: &Path,
    appdir: &Path,
    kind: &str,
) -> Result<()> {
    let mut targets = FlightTargetTransaction::default();
    execute_flight_steps_with_completion(
        cask,
        steps,
        staged_path,
        appdir,
        kind,
        &mut targets,
        |_, _| Ok(()),
    )?;
    targets.commit()
}

fn execute_flight_steps_recording(
    cask: &Cask,
    steps: &[FlightStep],
    staged_path: &Path,
    appdir: &Path,
    kind: &str,
    journal: &mut CaskTransactionJournal<'_>,
    targets: &mut FlightTargetTransaction,
) -> Result<()> {
    execute_flight_steps_with_completion(
        cask,
        steps,
        staged_path,
        appdir,
        kind,
        targets,
        |index, step| record_cask_action(journal, &format!("{kind}[{index}]:{}", step.kind())),
    )
}

fn execute_flight_steps_with_completion(
    cask: &Cask,
    steps: &[FlightStep],
    staged_path: &Path,
    appdir: &Path,
    kind: &str,
    targets: &mut FlightTargetTransaction,
    mut completed: impl FnMut(usize, &FlightStep) -> Result<()>,
) -> Result<()> {
    for (index, step) in steps.iter().enumerate() {
        execute_flight_step(cask, step, staged_path, appdir, targets).wrap_err_with(|| {
            format!("brew-cask:{}: failed to run structured {kind}", cask.token)
        })?;
        completed(index, step)?;
    }
    Ok(())
}

#[derive(Debug, Default)]
struct FlightTargetTransaction {
    backups: Vec<ArtifactLinkBackup>,
    receipt_caskroom: Option<PathBuf>,
    installed: Vec<PathBuf>,
    uninstall: BTreeMap<PathBuf, bool>,
    previous_symlinks: BTreeSet<PathBuf>,
    copied_files: BTreeSet<PathBuf>,
    previous_directories: BTreeSet<PathBuf>,
    installed_directories: Vec<PathBuf>,
    committed: bool,
}

#[derive(Debug, Serialize, Deserialize)]
struct FlightRecoveryRecord {
    target: PathBuf,
    #[serde(default)]
    backup: Option<PathBuf>,
    target_parent: PathBuf,
    #[serde(default)]
    backup_parent: Option<PathBuf>,
    #[serde(default)]
    receipt_caskroom: Option<PathBuf>,
    #[serde(default = "default_elevate_recovery")]
    elevate: bool,
}

fn default_elevate_recovery() -> bool {
    true
}

impl FlightTargetTransaction {
    fn protect(&mut self, target: &Path) -> Result<()> {
        self.protect_with_elevation(target, true)
    }

    fn protect_unprivileged(&mut self, target: &Path) -> Result<()> {
        self.protect_with_elevation(target, false)
    }

    fn protect_generic(&mut self, target: &Path) -> Result<Option<PathBuf>> {
        #[cfg(unix)]
        {
            match open_trusted_operation_parent(target, true, true) {
                Ok(parent) if trusted_parent_is_writable(&parent)? => {
                    self.protect_unprivileged(target)?;
                    Ok(None)
                }
                Ok(_) => {
                    let target = ensure_strict_elevated_target(target)?;
                    self.protect(&target)?;
                    Ok(Some(target))
                }
                Err(err) if is_permission_denied(&err) => {
                    let target = ensure_strict_elevated_target(target)?;
                    self.protect(&target)?;
                    Ok(Some(target))
                }
                Err(err) => Err(err),
            }
        }
        #[cfg(not(unix))]
        {
            self.protect(target)?;
            Ok(None)
        }
    }

    fn protect_with_elevation(&mut self, target: &Path, elevate: bool) -> Result<()> {
        if self.backups.iter().any(|entry| entry.target == target) {
            return Ok(());
        }
        ensure_no_unresolved_flight_recovery(target)?;
        let target_parent = resolved_parent(target)?;
        let backup = if target.symlink_metadata().is_ok() {
            let parent = flight_backup_parent(target)?;
            let backup = unused_flight_backup_path(parent, target)?;
            let recovery = flight_backup_recovery_path(&backup);
            let record = FlightRecoveryRecord {
                target: target.to_path_buf(),
                backup: Some(backup.clone()),
                target_parent: target_parent.clone(),
                backup_parent: Some(resolved_parent(&backup)?),
                receipt_caskroom: self.receipt_caskroom.clone(),
                elevate,
            };
            write_durable_file(&recovery, &serde_json::to_vec_pretty(&record)?)?;
            let rename = if elevate {
                rename_elevating(target, &backup)
            } else {
                rename_trusted_generic_target(target, &backup, &target_parent)
            };
            if let Err(err) = rename {
                let _ = file::remove_all(&recovery);
                return Err(err);
            }
            Some(backup)
        } else {
            let recovery = flight_absent_recovery_path(target);
            let record = FlightRecoveryRecord {
                target: target.to_path_buf(),
                backup: None,
                target_parent: target_parent.clone(),
                backup_parent: None,
                receipt_caskroom: self.receipt_caskroom.clone(),
                elevate,
            };
            write_durable_file(&recovery, &serde_json::to_vec_pretty(&record)?)?;
            None
        };
        let backup_parent = backup.as_deref().map(resolved_parent).transpose()?;
        self.backups.push(ArtifactLinkBackup {
            target: target.to_path_buf(),
            backup,
            target_parent,
            backup_parent,
            elevate,
        });
        Ok(())
    }

    fn record_installed(&mut self, target: PathBuf) {
        if !self.installed.contains(&target) {
            self.installed.push(target);
        }
    }

    fn record_installed_flight(&mut self, target: PathBuf, uninstall: bool) {
        self.record_installed(target.clone());
        self.uninstall.insert(target, uninstall);
    }

    fn installed_targets(&self) -> &[PathBuf] {
        &self.installed
    }

    fn uninstall_targets(&self) -> &BTreeMap<PathBuf, bool> {
        &self.uninstall
    }

    fn record_copied_files(&mut self, source: &Path, target: &Path) -> Result<()> {
        let metadata = source.symlink_metadata()?;
        if metadata.is_file() {
            self.copied_files.insert(file::desymlink_path(target));
            return Ok(());
        }
        for entry in WalkDir::new(source).follow_links(false) {
            let entry = entry?;
            if entry.file_type().is_file() {
                let relative = entry.path().strip_prefix(source)?;
                self.copied_files
                    .insert(file::desymlink_path(&target.join(relative)));
            }
        }
        Ok(())
    }

    fn copied_files(&self) -> &BTreeSet<PathBuf> {
        &self.copied_files
    }

    fn record_installed_directory(&mut self, target: PathBuf) {
        if !self.installed_directories.contains(&target) {
            self.installed_directories.push(target);
        }
    }

    fn installed_directories(&self) -> &[PathBuf] {
        &self.installed_directories
    }

    fn rollback(&mut self) -> Result<()> {
        let mut first_error = None;
        let mut failed = Vec::new();
        for entry in std::mem::take(&mut self.backups).into_iter().rev() {
            if let Err(err) = validate_backup_parents(&entry) {
                first_error.get_or_insert(err);
                failed.push(entry);
                continue;
            }
            let remove = if entry.elevate {
                remove_artifact_target_elevating(&entry.target)
            } else {
                remove_trusted_generic_target_from(&entry.target, &entry.target_parent)
            };
            if let Err(err) = remove {
                first_error.get_or_insert(err);
                failed.push(entry);
                continue;
            }
            if let Some(backup) = &entry.backup {
                let rename = if entry.elevate {
                    rename_elevating(backup, &entry.target)
                } else {
                    rename_trusted_generic_target(backup, &entry.target, &entry.target_parent)
                };
                if let Err(err) = rename {
                    first_error.get_or_insert(err);
                    failed.push(entry);
                    continue;
                }
            }
            if let Err(err) = file::remove_all(flight_target_recovery_path(&entry)) {
                warn!("brew-cask: failed to remove flight recovery record: {err:#}");
            }
        }
        failed.reverse();
        self.backups = failed;
        self.installed.clear();
        self.uninstall.clear();
        self.copied_files.clear();
        self.installed_directories.clear();
        if let Some(err) = first_error {
            Err(err)
        } else {
            Ok(())
        }
    }

    fn commit(&mut self) -> Result<()> {
        self.committed = true;
        let backups = std::mem::take(&mut self.backups);
        let mut first_error = None;
        for entry in backups {
            if let Err(err) = file::remove_all(flight_target_recovery_path(&entry)) {
                first_error.get_or_insert(err);
            }
            if let Some(backup) = &entry.backup {
                // Attempt both removals independently. Either a missing record
                // or a missing backup is enough to prevent stale recovery from
                // restoring pre-install data over a later target.
                let remove = if entry.elevate {
                    remove_artifact_target_elevating(backup)
                } else {
                    remove_trusted_generic_target_from(
                        backup,
                        entry.backup_parent.as_ref().unwrap(),
                    )
                };
                if let Err(err) = remove {
                    first_error.get_or_insert(err);
                }
            }
        }
        if let Some(err) = first_error {
            Err(err)
        } else {
            Ok(())
        }
    }
}

fn unused_flight_backup_path(parent: &Path, target: &Path) -> Result<PathBuf> {
    let stem = format!(
        ".mise-flight-backup-{}-{}",
        hash::hash_to_str(&target.display().to_string()),
        std::process::id()
    );
    for attempt in 0_u64.. {
        let backup = parent.join(format!("{stem}-{attempt}"));
        let recovery = flight_backup_recovery_path(&backup);
        let backup_missing = match backup.symlink_metadata() {
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
            Ok(_) => false,
            Err(err) => return Err(err.into()),
        };
        let recovery_missing = match recovery.symlink_metadata() {
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
            Ok(_) => false,
            Err(err) => return Err(err.into()),
        };
        if backup_missing && recovery_missing {
            return Ok(backup);
        }
    }
    unreachable!("the flight backup suffix space is exhausted")
}

fn flight_backup_recovery_path(backup: &Path) -> PathBuf {
    flight_recovery_root().join(format!(
        "{}.recovery",
        hash::hash_to_str(&backup.display().to_string())
    ))
}

fn flight_absent_recovery_path(target: &Path) -> PathBuf {
    flight_recovery_root().join(format!(
        "absent-{}.recovery",
        hash::hash_to_str(&target.display().to_string())
    ))
}

fn flight_target_recovery_path(entry: &ArtifactLinkBackup) -> PathBuf {
    entry
        .backup
        .as_deref()
        .map(flight_backup_recovery_path)
        .unwrap_or_else(|| flight_absent_recovery_path(&entry.target))
}

fn flight_recovery_root() -> PathBuf {
    crate::dirs::STATE.join("brew-cask").join("flight-recovery")
}

fn ensure_no_unresolved_flight_recovery(target: &Path) -> Result<()> {
    let Ok(entries) = std::fs::read_dir(flight_recovery_root()) else {
        return Ok(());
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path
            .extension()
            .is_none_or(|extension| extension != "recovery")
        {
            continue;
        }
        let Ok(body) = file::read_to_string(&path) else {
            continue;
        };
        let Ok(record) = serde_json::from_str::<FlightRecoveryRecord>(&body) else {
            continue;
        };
        if record.target == target {
            if let Some(backup) = record.backup {
                bail!(
                    "brew-cask: unresolved recovery for {} still preserves its original at {}",
                    target.display(),
                    backup.display()
                );
            }
            bail!(
                "brew-cask: unresolved recovery for newly created target {}",
                target.display()
            );
        }
    }
    Ok(())
}

fn recover_flight_backups() -> Result<()> {
    let root = flight_recovery_root();
    recover_flight_backups_in(&root)
}

fn recover_flight_backups_in(root: &Path) -> Result<()> {
    let Ok(entries) = std::fs::read_dir(root) else {
        return Ok(());
    };
    for entry in entries {
        let path = match entry {
            Ok(entry) => entry.path(),
            Err(err) => {
                warn!(
                    "brew-cask: failed to inspect a flight recovery entry in {}: {err:#}",
                    root.display()
                );
                continue;
            }
        };
        match path.extension().and_then(|extension| extension.to_str()) {
            Some("recovery") => recover_flight_backup_or_warn(&path),
            // Atomic record writes may leave their temporary file behind if
            // the process dies before rename. It is not a recovery record.
            Some("tmp") => {
                if let Err(err) = file::remove_all(&path) {
                    warn!(
                        "brew-cask: failed to remove stale flight recovery file {}: {err:#}",
                        path.display()
                    );
                }
            }
            _ => {}
        }
    }
    Ok(())
}

fn recover_flight_backup_or_warn(path: &Path) {
    if let Err(err) = recover_flight_backup(path) {
        warn!(
            "brew-cask: leaving flight recovery record {} for a later retry: {err:#}",
            path.display()
        );
    }
}

fn recover_flight_backup(path: &Path) -> Result<()> {
    let body = file::read_to_string(path)
        .wrap_err_with(|| format!("failed to read flight recovery record {}", path.display()))?;
    let record: FlightRecoveryRecord = serde_json::from_str(&body)
        .wrap_err_with(|| format!("invalid flight recovery record {}", path.display()))?;
    let backup = ArtifactLinkBackup {
        target: record.target,
        backup: record.backup,
        target_parent: record.target_parent,
        backup_parent: record.backup_parent,
        elevate: record.elevate,
    };
    validate_backup_parents(&backup)?;
    if let Some(backup_path) = &backup.backup {
        if backup_path.symlink_metadata().is_ok() {
            if backup.target.symlink_metadata().is_ok() {
                // A target created after the interrupted transaction may be user
                // data, a successfully activated replacement, or a replacement
                // that rollback failed to remove. Without enough information to
                // distinguish those cases, preserve both entries and leave the
                // original backup available for manual recovery.
                warn!(
                    "brew-cask: preserving interrupted flight target {} and its original backup {}",
                    backup.target.display(),
                    backup_path.display()
                );
                return Ok(());
            } else if backup.elevate {
                rename_elevating(backup_path, &backup.target)?;
            } else {
                rename_trusted_generic_target(backup_path, &backup.target, &backup.target_parent)?;
            }
        }
    } else if backup.target.symlink_metadata().is_ok()
        && !flight_target_claimed_by_receipt(&backup.target, record.receipt_caskroom.as_deref())?
    {
        if backup.elevate {
            remove_artifact_target_elevating(&backup.target)?;
        } else {
            remove_trusted_generic_target_from(&backup.target, &backup.target_parent)?;
        }
    }
    file::remove_all(path)?;
    Ok(())
}

fn flight_target_claimed_by_receipt(target: &Path, caskroom: Option<&Path>) -> Result<bool> {
    let Some(caskroom) = caskroom else {
        return Ok(false);
    };
    let Some(receipt) = read_receipt(caskroom)? else {
        return Ok(false);
    };
    if receipt.flight_directories.iter().any(|path| path == target) && target.is_dir() {
        return Ok(true);
    }
    for record in receipt
        .targets
        .iter()
        .filter(|record| record.path == target)
    {
        if cask_target_record_matches(record)? {
            return Ok(true);
        }
    }
    Ok(false)
}

fn resolved_parent(path: &Path) -> Result<PathBuf> {
    let parent = path
        .parent()
        .ok_or_else(|| eyre!("brew-cask: flight target has no parent"))?;
    Ok(path_with_resolved_existing_ancestor(parent))
}

fn validate_backup_parents(entry: &ArtifactLinkBackup) -> Result<()> {
    if resolved_parent(&entry.target)? != entry.target_parent
        || entry
            .backup
            .as_deref()
            .zip(entry.backup_parent.as_ref())
            .is_some_and(|(backup, expected)| {
                !resolved_parent(backup).is_ok_and(|current| current == *expected)
            })
    {
        bail!(
            "brew-cask: refusing to restore flight target through a changed parent: {}",
            entry.target.display()
        );
    }
    Ok(())
}

fn flight_backup_parent(target: &Path) -> Result<&Path> {
    if let Some(app) = target.ancestors().find(|ancestor| {
        ancestor
            .extension()
            .is_some_and(|extension| extension.eq_ignore_ascii_case("app"))
    }) {
        return app
            .parent()
            .ok_or_else(|| eyre!("brew-cask: app flight target has no parent"));
    }
    target
        .parent()
        .ok_or_else(|| eyre!("brew-cask: flight target has no parent"))
}

impl Drop for FlightTargetTransaction {
    fn drop(&mut self) {
        if !self.committed
            && let Err(err) = self.rollback()
        {
            warn!("brew-cask: failed to roll back flight targets: {err:#}");
        }
    }
}

impl FlightStep {
    fn kind(&self) -> &'static str {
        match self {
            Self::Move { .. } => "move",
            Self::Remove { .. } => "remove",
            Self::Copy { .. } => "copy",
            Self::Symlink { .. } => "symlink",
            Self::Run { .. } => "run",
            Self::TerminateProcess { .. } => "terminate_process",
        }
    }
}

fn execute_flight_step(
    cask: &Cask,
    step: &FlightStep,
    staged_path: &Path,
    appdir: &Path,
    targets: &mut FlightTargetTransaction,
) -> Result<()> {
    match step {
        FlightStep::Move {
            source,
            target,
            source_glob,
        } => {
            let sources = flight_sources(staged_path, source, *source_glob)?;
            let target = resolve_flight_path(staged_path, target)?;
            if sources.len() > 1 && !target.is_dir() {
                bail!(
                    "brew-cask: structured move with multiple sources requires a directory target"
                );
            }
            for source in sources {
                let target = if target.is_dir() {
                    target.join(source.file_name().ok_or_else(|| {
                        eyre!(
                            "brew-cask: structured move source '{}' has no file name",
                            source.display()
                        )
                    })?)
                } else {
                    target.clone()
                };
                if let Some(parent) = target.parent()
                    && !parent.as_os_str().is_empty()
                {
                    file::create_dir_all(parent)?;
                }
                file::remove_all(&target)?;
                file::rename(&source, &target)?;
            }
        }
        FlightStep::Remove { paths, recursive } => {
            for path in paths {
                for path in flight_paths(staged_path, path)? {
                    if *recursive {
                        file::remove_all(&path)?;
                    } else if path.symlink_metadata().is_ok() {
                        file::remove_file_or_dir(&path)?;
                    }
                }
            }
        }
        FlightStep::Copy {
            source,
            target,
            recursive,
            overwrite,
            source_glob,
            guards,
        } => {
            if !guards
                .iter()
                .map(|guard| flight_guard_matches(cask, guard, staged_path, appdir))
                .collect::<Result<Vec<_>>>()?
                .into_iter()
                .all(|matches| matches)
            {
                return Ok(());
            }
            let sources = flight_symlink_sources(cask, source, *source_glob, staged_path, appdir)?;
            let [source] = sources.as_slice() else {
                bail!("brew-cask: structured copy source must resolve to exactly one path");
            };
            if !source.exists() {
                bail!(
                    "brew-cask: structured copy source '{}' was not found",
                    source.display()
                );
            }
            if source.is_dir() && !recursive {
                bail!("brew-cask: structured directory copy requires recursive=true");
            }
            let target = resolve_flight_path_with_context(cask, target, staged_path, appdir)?;
            let external = !target.starts_with(staged_path);
            let target_metadata = target.symlink_metadata().ok();
            if target_metadata.is_some() {
                if !overwrite {
                    bail!(
                        "brew-cask: structured copy target '{}' already exists",
                        target.display()
                    );
                }
                if external {
                    targets.protect(&target)?;
                } else {
                    file::remove_all(&target)?;
                }
            }
            if let Some(parent) = target.parent() {
                create_dir_all_elevating(parent)?;
            }
            if external && target_metadata.is_none() {
                // Bind an absent target to its resolved parent only after
                // creating that parent so rollback can validate its identity.
                targets.protect(&target)?;
            }
            copy_cask_artifact(source, &target)?;
            if external {
                targets.record_copied_files(source, &target)?;
            }
            // External copy trees may be modified during normal use. The
            // transaction backup is sufficient for rollback; recording them
            // would fingerprint their contents and force later reinstalls.
        }
        FlightStep::Symlink {
            source,
            target,
            force,
            uninstall,
            source_glob,
            sudo,
            guards,
        } => {
            if !guards
                .iter()
                .map(|guard| flight_guard_matches(cask, guard, staged_path, appdir))
                .collect::<Result<Vec<_>>>()?
                .into_iter()
                .all(|matches| matches)
            {
                return Ok(());
            }
            let target = resolve_flight_path_with_context(cask, target, staged_path, appdir)?;
            let sources = flight_symlink_sources(cask, source, *source_glob, staged_path, appdir)?;
            if sources.is_empty() {
                bail!(
                    "brew-cask: structured symlink source '{}' did not match any paths",
                    source.path
                );
            }
            let target_metadata = target.symlink_metadata().ok();
            let target_is_real_dir = target_metadata
                .as_ref()
                .is_some_and(|metadata| metadata.is_dir());
            let target_is_dir = target_is_real_dir || sources.len() > 1;
            if sources.len() > 1 {
                let created_external_directory =
                    target_metadata.is_none() && !target.starts_with(staged_path);
                if target_metadata.is_some() && !target_is_real_dir {
                    if target.exists() && !force && !targets.previous_symlinks.contains(&target) {
                        bail!(
                            "brew-cask: structured symlink target '{}' already exists",
                            target.display()
                        );
                    }
                    targets.protect(&target)?;
                } else if created_external_directory {
                    // Record the absent directory itself so rollback removes
                    // the container after removing the links created below.
                    if let Some(parent) = target.parent() {
                        create_flight_dir_all(parent, *sudo)?;
                    }
                    targets.protect(&target)?;
                }
                create_flight_dir_all(&target, *sudo)?;
                if created_external_directory || targets.previous_directories.contains(&target) {
                    targets.record_installed_directory(target.clone());
                }
            }
            for source in sources {
                let link = if target_is_dir {
                    let source_name = Path::new(&source).file_name().ok_or_else(|| {
                        eyre!("brew-cask: structured symlink source has no file name")
                    })?;
                    target.join(source_name)
                } else {
                    target.clone()
                };
                let external = !link.starts_with(staged_path);
                let link_metadata = link.symlink_metadata().ok();
                if let Some(metadata) = &link_metadata {
                    if metadata.is_dir() {
                        bail!(
                            "brew-cask: refusing to replace structured symlink directory '{}'",
                            link.display()
                        );
                    }
                    if link.exists() && !force && !targets.previous_symlinks.contains(&link) {
                        bail!(
                            "brew-cask: structured symlink target '{}' already exists",
                            link.display()
                        );
                    }
                    if external {
                        targets.protect(&link)?;
                    } else if metadata.file_type().is_symlink() {
                        file::remove_file(&link)?;
                    } else {
                        file::remove_all(&link)?;
                    }
                }
                if let Some(parent) = link.parent() {
                    create_flight_dir_all(parent, *sudo)?;
                }
                if external && link_metadata.is_none() {
                    // Bind an absent target to its resolved parent only after
                    // creating that parent; otherwise rollback observes a
                    // different path identity and leaves the new link behind.
                    targets.protect(&link)?;
                }
                let source =
                    durable_internal_symlink_source(staged_path, &source, &link).unwrap_or(source);
                create_flight_symlink(&source, &link, *sudo)?;
                if external {
                    targets.record_installed_flight(link, *uninstall);
                }
            }
        }
        FlightStep::Run {
            command,
            args,
            env,
            sudo,
            guards,
        } => {
            if !guards
                .iter()
                .map(|guard| flight_guard_matches(cask, guard, staged_path, appdir))
                .collect::<Result<Vec<_>>>()?
                .into_iter()
                .all(|matches| matches)
            {
                return Ok(());
            }
            let command = resolve_flight_path_with_context(cask, command, staged_path, appdir)?;
            let command =
                expand_flight_template(cask, &command.to_string_lossy(), staged_path, appdir);
            let args = args
                .iter()
                .map(|arg| expand_flight_template(cask, arg, staged_path, appdir))
                .collect::<Vec<_>>();
            let env = env
                .iter()
                .map(|(key, value)| {
                    (
                        key.clone(),
                        expand_flight_template(cask, value, staged_path, appdir),
                    )
                })
                .collect::<Vec<_>>();
            if *sudo {
                sudo::run(&command, &args, &env)?;
            } else {
                let mut runner = CmdLineRunner::new(&command);
                for arg in &args {
                    runner = runner.arg(arg);
                }
                for (key, value) in &env {
                    runner = runner.env(key, value);
                }
                runner.raw(true).execute()?;
            }
        }
        FlightStep::TerminateProcess { .. } => {
            execute_terminate_process(
                step,
                staged_path,
                appdir,
                &cask.version,
                |command, args, sudo| {
                    if sudo {
                        sudo::run(&command.to_string_lossy(), args, &[])
                    } else {
                        let mut runner = CmdLineRunner::new(command);
                        for arg in args {
                            runner = runner.arg(arg);
                        }
                        runner.raw(true).execute()
                    }
                },
                std::thread::sleep,
            )?;
        }
    }
    Ok(())
}

fn execute_terminate_process(
    step: &FlightStep,
    staged_path: &Path,
    appdir: &Path,
    version: &str,
    mut run: impl FnMut(&Path, &[String], bool) -> Result<()>,
    mut sleep: impl FnMut(std::time::Duration),
) -> Result<()> {
    let FlightStep::TerminateProcess {
        name,
        match_mode,
        sudo,
        attempts,
        must_succeed,
        notices,
        failure_message,
    } = step
    else {
        bail!("brew-cask: internal non-terminate flight step");
    };
    let expand = |value: &str| expand_cask_template(value, staged_path, appdir, Some(version));
    for notice in notices {
        miseprintln!("{}", expand(notice));
    }
    let name = expand(name);
    let (command, args) = match *match_mode {
        ProcessMatch::Name => (Path::new("/usr/bin/killall"), vec![name]),
        ProcessMatch::Full => (Path::new("/usr/bin/pkill"), vec!["-f".to_string(), name]),
    };
    let mut last_error = None;
    for attempt in 0..*attempts {
        match run(command, &args, *sudo) {
            Ok(()) => return Ok(()),
            Err(err) => last_error = Some(err),
        }
        if attempt + 1 < *attempts {
            sleep(std::time::Duration::from_secs(1));
        }
    }
    if let Some(message) = failure_message.as_deref() {
        warn!("{}", expand(message));
    }
    if *must_succeed {
        return Err(last_error.unwrap_or_else(|| eyre!("failed to terminate process")));
    }
    Ok(())
}

fn flight_guard_matches(
    cask: &Cask,
    guard: &FlightGuard,
    staged_path: &Path,
    appdir: &Path,
) -> Result<bool> {
    match guard {
        FlightGuard::OnMacos => Ok(cfg!(target_os = "macos")),
        FlightGuard::OnLinux => Ok(cfg!(target_os = "linux")),
        FlightGuard::IfExists(path) => {
            Ok(resolve_flight_path_with_context(cask, path, staged_path, appdir)?.exists())
        }
        FlightGuard::UnlessExists(path) => {
            Ok(!resolve_flight_path_with_context(cask, path, staged_path, appdir)?.exists())
        }
    }
}

fn flight_symlink_sources(
    cask: &Cask,
    source: &FlightPath,
    source_glob: bool,
    staged_path: &Path,
    appdir: &Path,
) -> Result<Vec<PathBuf>> {
    if source_glob {
        if source.base != FlightPathBase::StagedPath {
            bail!("brew-cask: structured symlink globs must use staged_path");
        }
        let pattern = expand_flight_template(cask, &source.path, staged_path, appdir);
        return expand_staged_glob(staged_path, &pattern);
    }
    Ok(vec![resolve_flight_path_with_context(
        cask,
        source,
        staged_path,
        appdir,
    )?])
}

fn create_flight_symlink(source: &Path, target: &Path, sudo: FlightSudo) -> Result<()> {
    match sudo {
        FlightSudo::Never => file::make_symlink(source, target).map(|_| ()),
        FlightSudo::IfNeeded => make_symlink_elevating(source, target),
        FlightSudo::Always => sudo::run("/bin/ln", &symlink_command_args(source, target), &[]),
    }
}

fn create_flight_dir_all(target: &Path, sudo: FlightSudo) -> Result<()> {
    match sudo {
        FlightSudo::Never => file::create_dir_all(target),
        FlightSudo::IfNeeded => create_dir_all_elevating(target),
        FlightSudo::Always => sudo::run(
            "/bin/mkdir",
            &["-p".into(), "--".into(), target.display().to_string()],
            &[],
        ),
    }
}

fn flight_sources(
    staged_path: &Path,
    source: &FlightPath,
    source_glob: bool,
) -> Result<Vec<PathBuf>> {
    if !source_glob {
        let source = resolve_flight_path(staged_path, source)?;
        if !source.exists() {
            bail!(
                "brew-cask: structured move source '{}' was not found",
                source.display()
            );
        }
        return Ok(vec![source]);
    }
    // Homebrew marks move sources as globs explicitly; non-glob move sources
    // may contain literal glob-like characters and should be resolved literally.
    let sources = expand_staged_glob(staged_path, &source.path)?;
    if sources.is_empty() {
        bail!(
            "brew-cask: structured move source '{}' was not found",
            source.path
        );
    }
    Ok(sources)
}

fn flight_paths(staged_path: &Path, path: &FlightPath) -> Result<Vec<PathBuf>> {
    if !is_flight_glob(&path.path) {
        return Ok(vec![resolve_flight_path(staged_path, path)?]);
    }
    // Remove steps do not have a `source_glob` flag, so path globs are detected
    // from the path syntax instead.
    expand_staged_glob(staged_path, &path.path)
}

fn expand_staged_glob(staged_path: &Path, pattern: &str) -> Result<Vec<PathBuf>> {
    let mut matches = Vec::new();
    let escaped_root = glob::Pattern::escape(staged_path.to_string_lossy().as_ref());
    for pattern in expand_braces(pattern) {
        validate_flight_relative_path(&pattern)?;
        let rooted_pattern = Path::new(&escaped_root)
            .join(Path::new(&pattern))
            .to_string_lossy()
            .to_string();
        for path in glob::glob_with(
            &rooted_pattern,
            glob::MatchOptions {
                require_literal_separator: true,
                ..Default::default()
            },
        )
        .wrap_err_with(|| format!("brew-cask: invalid structured flight glob '{pattern}'"))?
        {
            let path = path?;
            if !path.starts_with(staged_path) {
                bail!(
                    "brew-cask: structured flight glob '{}' matched outside staged path",
                    pattern
                );
            }
            matches.push(path);
        }
    }
    matches.sort();
    matches.dedup();
    Ok(matches)
}

fn is_flight_glob(path: &str) -> bool {
    path.chars()
        .any(|c| matches!(c, '*' | '?' | '[' | ']' | '{' | '}'))
}

fn resolve_flight_path(staged_path: &Path, path: &FlightPath) -> Result<PathBuf> {
    match path.base {
        FlightPathBase::StagedPath => {}
        _ => bail!("brew-cask: structured file operation must use staged_path"),
    }
    let relative = Path::new(&path.path);
    validate_flight_relative_path(&path.path)?;
    Ok(staged_path.join(relative))
}

fn resolve_flight_path_with_context(
    cask: &Cask,
    path: &FlightPath,
    staged_path: &Path,
    appdir: &Path,
) -> Result<PathBuf> {
    let expanded = expand_flight_template(cask, &path.path, staged_path, appdir);
    match path.base {
        FlightPathBase::StagedPath => {
            validate_flight_relative_path(&expanded)?;
            Ok(staged_path.join(expanded))
        }
        FlightPathBase::AppDir => {
            validate_flight_relative_path(&expanded)?;
            Ok(appdir.join(expanded))
        }
        FlightPathBase::HomebrewPrefix => {
            validate_flight_relative_path(&expanded)?;
            Ok(prefix::prefix().join(expanded))
        }
        FlightPathBase::Literal => Ok(PathBuf::from(expanded)),
    }
}

fn expand_flight_template(cask: &Cask, value: &str, staged_path: &Path, appdir: &Path) -> String {
    let caskroom_path = caskroom_token_dir(&cask.token);
    let version_major = cask
        .version
        .split(['.', ','])
        .next()
        .unwrap_or(&cask.version);
    let value = value
        .replace("{{version.major}}", version_major)
        .replace("{{caskroom_path}}", &caskroom_path.to_string_lossy());
    expand_cask_template(&value, staged_path, appdir, Some(&cask.version))
}

fn expand_cask_template(
    value: &str,
    staged_path: &Path,
    appdir: &Path,
    version: Option<&str>,
) -> String {
    let prefix = prefix::prefix();
    let mut value = value
        .replace("$HOMEBREW_PREFIX", &prefix.to_string_lossy())
        .replace("$APPDIR", &appdir.to_string_lossy())
        .replace("$HOME", &crate::dirs::HOME.to_string_lossy())
        .replace("{{HOMEBREW_PREFIX}}", &prefix.to_string_lossy())
        .replace("{{staged_path}}", &staged_path.to_string_lossy())
        .replace("{{appdir}}", &appdir.to_string_lossy());
    if let Some(version) = version {
        value = value.replace("{{version}}", version);
    }
    if let Some(rest) = value.strip_prefix("~/") {
        value = crate::dirs::HOME.join(rest).to_string_lossy().to_string();
    }
    value
}

fn validate_flight_relative_path(path: &str) -> Result<()> {
    let path = Path::new(path);
    if path.is_absolute()
        || path
            .components()
            .any(|component| matches!(component, Component::ParentDir))
    {
        bail!(
            "brew-cask: invalid structured flight path '{}'",
            path.display()
        );
    }
    Ok(())
}

fn expand_braces(pattern: &str) -> Vec<String> {
    let Some(start) = pattern.find('{') else {
        return vec![pattern.to_string()];
    };
    let Some(end_offset) = pattern[start + 1..].find('}') else {
        return vec![pattern.to_string()];
    };
    let end = start + 1 + end_offset;
    let prefix = &pattern[..start];
    let suffix = &pattern[end + 1..];
    let mut expanded = Vec::new();
    for alternative in pattern[start + 1..end].split(',') {
        for suffix in expand_braces(suffix) {
            expanded.push(format!("{prefix}{alternative}{suffix}"));
        }
    }
    expanded
}

fn stage_completion(
    stage: &Path,
    caskroom: &Path,
    cask: &Cask,
    apps: &[AppArtifact],
    completion: &CompletionArtifact,
) -> Result<()> {
    let target = completion.target_path()?;
    let caskroom_completion = caskroom_completion_path(caskroom, &target)?;
    let source = find_completion_source(stage, caskroom, cask, apps, &completion.source)?
        .ok_or_else(|| {
            eyre!(
                "brew-cask: {} completion artifact '{}' was not found",
                completion.shell.name(),
                completion.source
            )
        })?;
    if !file::same_file(&source, &caskroom_completion) {
        file::remove_all(&caskroom_completion)?;
        if let Some(parent) = caskroom_completion.parent() {
            file::create_dir_all(parent)?;
        }
        file::copy(&source, &caskroom_completion)?;
    }
    Ok(())
}

fn stage_generated_completions(
    stage: &Path,
    caskroom: &Path,
    cask: &Cask,
    apps: &[AppArtifact],
    completion: &GeneratedCompletionArtifact,
) -> Result<()> {
    let executable = find_generated_completion_executable(stage, caskroom, cask, apps, completion)?;
    if executable.starts_with(stage) || executable.starts_with(caskroom) {
        file::make_executable(&executable)?;
    }
    let base_name = completion.resolved_base_name(cask);
    for shell in &completion.shells {
        let target = generated_completion_target_path(*shell, &base_name)?;
        let caskroom_completion = caskroom_completion_path(caskroom, &target)?;
        if let Some(parent) = caskroom_completion.parent() {
            file::create_dir_all(parent)?;
        }
        let output = generate_completion_output(&executable, completion, *shell)?;
        crate::file::write(caskroom_completion, output)?;
    }
    Ok(())
}

fn link_completion(
    cask: &Cask,
    artifacts: &CaskArtifacts,
    caskroom: &Path,
    target: &Path,
) -> Result<()> {
    let caskroom_completion = caskroom_completion_path(caskroom, target)?;
    if !caskroom_completion.is_file() {
        bail!(
            "brew-cask: completion artifact '{}' was not staged",
            target.display()
        );
    }
    if let Some(parent) = target.parent() {
        create_dir_all_elevating(parent)?;
    }
    ensure_completion_target_replaceable(cask, artifacts, target)?;
    make_symlink_elevating(&caskroom_completion, target)?;
    Ok(())
}

fn ensure_completion_target_replaceable(
    cask: &Cask,
    artifacts: &CaskArtifacts,
    target: &Path,
) -> Result<()> {
    let Ok(metadata) = target.symlink_metadata() else {
        return Ok(());
    };
    if !metadata.file_type().is_symlink() {
        bail!(
            "brew-cask: completion target '{}' already exists and is not owned by cask '{}'",
            target.display(),
            cask.token
        );
    }
    let link_target = std::fs::read_link(target)?;
    let resolved = resolve_symlink_target(target, link_target);
    let token_dir = caskroom_token_dir(&cask.token);
    if path_starts_with_resolved_root(&resolved, &token_dir) {
        return Ok(());
    }
    for completion in &artifacts.completions {
        if completion.target_path()? != target {
            continue;
        }
        if let Some(source) = appdir_artifact_source(&completion.source, &artifacts.apps)?
            && file::same_file(&resolved, &source)
        {
            return Ok(());
        }
    }
    bail!(
        "brew-cask: completion target '{}' already points to '{}' and is not owned by cask '{}'",
        target.display(),
        resolved.display(),
        cask.token
    )
}

fn find_completion_source(
    stage: &Path,
    caskroom: &Path,
    cask: &Cask,
    apps: &[AppArtifact],
    source: &str,
) -> Result<Option<PathBuf>> {
    for root in [caskroom, stage] {
        if let Some(source) = generated_caskroom_artifact(root, cask, source)
            && source.is_file()
        {
            return Ok(Some(source));
        }
    }
    if let Some(source) = appdir_artifact_source(source, apps)? {
        return Ok(Some(source));
    }
    Ok(absolute_prefixed_source(source)
        .filter(|source| source.is_file())
        .or_else(|| find_file_artifact(caskroom, source))
        .or_else(|| find_file_artifact(stage, source)))
}

fn find_generated_completion_executable(
    stage: &Path,
    caskroom: &Path,
    cask: &Cask,
    apps: &[AppArtifact],
    completion: &GeneratedCompletionArtifact,
) -> Result<PathBuf> {
    let executable = &completion.executable;
    if let Some(source) = generated_caskroom_artifact(caskroom, cask, executable)
        && source.is_file()
    {
        return Ok(source);
    }
    if let Some(source) = generated_caskroom_artifact(stage, cask, executable)
        && source.is_file()
    {
        return Ok(source);
    }
    if let Some(source) = appdir_artifact_source(executable, apps)? {
        return Ok(source);
    }
    if let Some(source) = absolute_prefixed_source(executable) {
        if let Ok(relative) = source.strip_prefix(prefix::prefix()) {
            let caskroom_source = caskroom.join(relative);
            if caskroom_source.is_file() {
                return Ok(caskroom_source);
            }
        }
        if source.is_file() {
            return Ok(source);
        }
    }
    if let Some(source) = find_generated_completion_file(caskroom, executable)? {
        return Ok(source);
    }
    if let Some(source) = find_generated_completion_file(stage, executable)? {
        return Ok(source);
    }
    Err(eyre!(
        "brew-cask: completion executable '{}' was not found",
        executable
    ))
}

fn appdir_artifact_source(source: &str, apps: &[AppArtifact]) -> Result<Option<PathBuf>> {
    let Some(relative) = source.strip_prefix("$APPDIR/") else {
        return Ok(None);
    };
    let relative = Path::new(relative);
    if relative.components().next().is_none()
        || relative
            .components()
            .any(|component| !matches!(component, Component::Normal(_)))
    {
        bail!("brew-cask: APPDIR artifact '{source}' must stay below Applications");
    }
    let Some(Component::Normal(bundle)) = relative.components().next() else {
        return Ok(None);
    };
    let suffix = relative.components().skip(1).collect::<PathBuf>();
    let mut matches = Vec::new();
    for app in apps {
        let target = app_target_path(app.target_name())?;
        let bundle = Path::new(bundle);
        if !path_ends_with_ignore_ascii_case(Path::new(&app.source), bundle)
            && !path_ends_with_ignore_ascii_case(&target, bundle)
        {
            continue;
        }
        let path = target.join(&suffix);
        if path.is_file() {
            matches.push(path);
        }
    }
    matches.sort();
    matches.dedup();
    match matches.as_slice() {
        [] => Ok(None),
        [path] => Ok(Some(path.clone())),
        _ => bail!(
            "brew-cask: APPDIR artifact '{}' is ambiguous: {}",
            source,
            matches
                .iter()
                .map(|path| path.display().to_string())
                .collect::<Vec<_>>()
                .join(", ")
        ),
    }
}

fn find_generated_completion_file(root: &Path, executable: &str) -> Result<Option<PathBuf>> {
    let executable_path = Path::new(executable);
    let direct = root.join(executable_path);
    if direct.is_file() {
        return Ok(Some(direct));
    }
    let matches = find_file_artifacts(root, executable_path);
    match matches.as_slice() {
        [] => Ok(None),
        [path] => Ok(Some(path.clone())),
        _ => bail!(
            "brew-cask: completion executable '{}' is ambiguous: {}",
            executable,
            matches
                .iter()
                .map(|path| path.display().to_string())
                .collect::<Vec<_>>()
                .join(", ")
        ),
    }
}

fn find_file_artifacts(root: &Path, name: &Path) -> Vec<PathBuf> {
    let mut matches = WalkDir::new(root)
        .into_iter()
        .filter_entry(|entry| entry.file_name() != "__MACOSX")
        .filter_map(|entry| entry.ok())
        .map(|entry| entry.into_path())
        .filter(|path| {
            path.strip_prefix(root)
                .is_ok_and(|relative| relative.ends_with(name))
                && path.is_file()
        })
        .collect::<Vec<_>>();
    matches.sort();
    matches.dedup();
    matches
}

fn generate_completion_output(
    executable: &Path,
    completion: &GeneratedCompletionArtifact,
    shell: CompletionShell,
) -> Result<String> {
    let mut command = std::process::Command::new(executable);
    command.args(&completion.args);
    command.env("SHELL", shell.name());
    let (shell_args, shell_env) = completion_shell_parameter(
        completion.shell_parameter_format.as_deref(),
        shell,
        executable,
    );
    command.args(shell_args);
    for (key, value) in shell_env {
        command.env(key, value);
    }
    let output = command.output().wrap_err_with(|| {
        format!(
            "failed to generate {} completions from {}",
            shell.name(),
            executable.display()
        )
    })?;
    if !output.status.success() {
        bail!(
            "brew-cask: failed to generate {} completions from {}: {}",
            shell.name(),
            executable.display(),
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

fn completion_shell_parameter(
    format: Option<&str>,
    shell: CompletionShell,
    executable: &Path,
) -> (Vec<String>, Vec<(String, String)>) {
    let shell_parameter = shell.parameter_name().to_string();
    match format {
        None => (vec![shell_parameter], Vec::new()),
        Some("arg") => (vec![format!("--shell={shell_parameter}")], Vec::new()),
        Some("clap") => (Vec::new(), vec![("COMPLETE".to_string(), shell_parameter)]),
        Some("click") => {
            let program = executable
                .file_name()
                .and_then(|name| name.to_str())
                .unwrap_or_default()
                .to_uppercase()
                .replace('-', "_");
            (
                Vec::new(),
                vec![(
                    format!("_{program}_COMPLETE"),
                    format!("{shell_parameter}_source"),
                )],
            )
        }
        Some("cobra") => (vec!["completion".to_string(), shell_parameter], Vec::new()),
        Some("flag") => (vec![format!("--{shell_parameter}")], Vec::new()),
        Some("none") => (Vec::new(), Vec::new()),
        Some("typer") => (
            vec!["--show-completion".to_string(), shell_parameter],
            vec![(
                "_TYPER_COMPLETE_TEST_DISABLE_SHELL_DETECTION".to_string(),
                "1".to_string(),
            )],
        ),
        Some(format) => (vec![format!("{format}{shell_parameter}")], Vec::new()),
    }
}

fn absolute_prefixed_source(source: &str) -> Option<PathBuf> {
    let prefix = prefix::prefix();
    let source = source.replace("$HOMEBREW_PREFIX", &prefix.to_string_lossy());
    let source = PathBuf::from(source);
    source.is_absolute().then_some(source)
}

fn completion_target_path(shell: CompletionShell, target_name: &str) -> Result<PathBuf> {
    let prefix = prefix::prefix();
    let prefix_str = prefix.to_string_lossy();
    let target_name = target_name.replace("$HOMEBREW_PREFIX", prefix_str.as_ref());
    let path = PathBuf::from(&target_name);
    let target = if path.is_absolute() {
        path
    } else if target_name.contains('/') {
        prefix.join(path)
    } else {
        default_completion_dir(shell).join(completion_filename(shell, &target_name)?)
    };
    if target
        .components()
        .any(|component| matches!(component, Component::ParentDir))
    {
        bail!(
            "brew-cask: completion target '{}' must not contain '..'",
            target.display()
        );
    }
    if !target.starts_with(&prefix) {
        bail!(
            "brew-cask: completion target '{}' must be under {}",
            target.display(),
            prefix.display()
        );
    }
    Ok(target)
}

fn generated_completion_target_path(shell: CompletionShell, base_name: &str) -> Result<PathBuf> {
    match shell {
        CompletionShell::Pwsh => {
            let name = format!("_{}.ps1", base_name);
            completion_target_path(shell, &name)
        }
        _ => completion_target_path(shell, base_name),
    }
}

fn default_completion_dir(shell: CompletionShell) -> PathBuf {
    let prefix = prefix::prefix();
    match shell {
        CompletionShell::Bash => prefix.join("etc/bash_completion.d"),
        CompletionShell::Fish => prefix.join("share/fish/vendor_completions.d"),
        CompletionShell::Zsh => prefix.join("share/zsh/site-functions"),
        CompletionShell::Pwsh => prefix.join("share/pwsh/completions"),
    }
}

fn completion_filename(shell: CompletionShell, target_name: &str) -> Result<String> {
    let filename = Path::new(target_name)
        .file_name()
        .and_then(|name| name.to_str())
        .ok_or_else(|| eyre!("brew-cask: invalid completion target '{target_name}'"))?;
    let stem = Path::new(filename)
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or(filename);
    let normalized = match shell {
        CompletionShell::Bash => stem.to_string(),
        CompletionShell::Fish => {
            if filename.ends_with(".fish") {
                filename.to_string()
            } else {
                format!("{stem}.fish")
            }
        }
        CompletionShell::Zsh => {
            if filename.starts_with('_') {
                filename.to_string()
            } else {
                format!("_{stem}")
            }
        }
        CompletionShell::Pwsh => {
            if filename.ends_with(".ps1") {
                filename.to_string()
            } else {
                format!("{stem}.ps1")
            }
        }
    };
    if normalized.is_empty() {
        bail!("brew-cask: invalid completion target '{target_name}'");
    }
    Ok(normalized)
}

fn caskroom_completion_path(caskroom: &Path, target: &Path) -> Result<PathBuf> {
    let prefix = prefix::prefix();
    let relative = target.strip_prefix(&prefix).map_err(|_| {
        eyre!(
            "brew-cask: completion target '{}' must be under {}",
            target.display(),
            prefix.display()
        )
    })?;
    if relative.components().next().is_none() {
        bail!(
            "brew-cask: invalid completion target '{}'",
            target.display()
        );
    }
    Ok(caskroom.join(relative))
}

fn completion_target_paths(cask: &Cask, artifacts: &CaskArtifacts) -> Result<Vec<PathBuf>> {
    let mut targets = artifacts
        .completions
        .iter()
        .map(CompletionArtifact::target_path)
        .collect::<Result<Vec<_>>>()?;
    for generated in &artifacts.generated_completions {
        targets.extend(generated.target_paths(cask)?);
    }
    targets.sort();
    targets.dedup();
    Ok(targets)
}

fn previous_completion_targets(cask: &Cask) -> Result<Vec<PathBuf>> {
    let Some(version) = installed_version(&cask.token) else {
        return Ok(Vec::new());
    };
    let version_dir = caskroom_version_dir(&cask.token, &version);
    Ok(read_receipt(&version_dir)?
        .map(|receipt| receipt.completions)
        .unwrap_or_default())
}

fn remove_obsolete_completions(
    cask: &Cask,
    previous_targets: &[PathBuf],
    current_targets: &[PathBuf],
) -> Result<()> {
    let token_dir = caskroom_token_dir(&cask.token);
    let prefix = prefix::prefix();
    for target in previous_targets {
        if current_targets.contains(target) || !target.starts_with(&prefix) {
            continue;
        }
        let Ok(metadata) = target.symlink_metadata() else {
            continue;
        };
        if !metadata.file_type().is_symlink() {
            continue;
        }
        let Ok(link_target) = std::fs::read_link(target) else {
            continue;
        };
        let resolved = resolve_symlink_target(target, link_target);
        if path_starts_with_resolved_root(&resolved, &token_dir) {
            file::remove_file(target)?;
        }
    }
    Ok(())
}

fn stage_binary(
    stage: &Path,
    caskroom: &Path,
    cask: &Cask,
    apps: &[AppArtifact],
    binary: &BinaryArtifact,
) -> Result<()> {
    let appdir = cask_appdir(apps)?;
    let caskroom_binary = caskroom_binary_path(caskroom, &appdir, binary)?;
    file::remove_all(&caskroom_binary)?;
    if let Some(parent) = caskroom_binary.parent() {
        file::create_dir_all(parent)?;
    }
    if binary.source.contains("$APPDIR") {
        // $APPDIR is the Applications directory where install_app placed the bundle.
        // Symlink into the installed app so the CLI wrapper can trace back to find the app.
        let app_binary = appdir_artifact_source(&binary.source, apps)?.ok_or_else(|| {
            eyre!(
                "brew-cask: binary artifact '{}' was not found",
                binary.source
            )
        })?;
        file::make_symlink(&app_binary, &caskroom_binary)?;
    } else {
        let source = find_binary_source(stage, caskroom, cask, binary)?;
        if source.starts_with(stage) || source.starts_with(caskroom) {
            file::copy(&source, &caskroom_binary)?;
            file::make_executable(&caskroom_binary)?;
        } else {
            file::make_symlink(&source, &caskroom_binary)?;
        }
    }
    Ok(())
}

fn stage_command_wrapper(
    caskroom: &Path,
    appdir: &Path,
    cask: &Cask,
    wrapper: &CommandWrapperArtifact,
) -> Result<()> {
    let target = wrapper.caskroom_path(caskroom);
    file::remove_all(&target)?;
    if let Some(parent) = target.parent() {
        file::create_dir_all(parent)?;
    }
    let content = match (&wrapper.content, &wrapper.executable) {
        (Some(content), None) => expand_command_wrapper_content(content, appdir),
        (None, Some(executable)) => {
            let executable = expand_command_wrapper_value(executable, appdir, cask);
            let args = wrapper
                .args
                .iter()
                .map(|arg| expand_command_wrapper_value(arg, appdir, cask))
                .map(|arg| shell_escape::unix::escape(Cow::Owned(arg)).into_owned())
                .collect::<Vec<_>>();
            let env = wrapper
                .env
                .iter()
                .map(|(key, value)| {
                    let value = expand_command_wrapper_value(value, appdir, cask);
                    Ok(format!(
                        "{key}={}",
                        shell_escape::unix::escape(Cow::Owned(value))
                    ))
                })
                .collect::<Result<Vec<_>>>()?;
            let mut command = Vec::new();
            command.extend(env);
            command.push("exec".to_string());
            command.push(shell_escape::unix::escape(Cow::Owned(executable)).into_owned());
            command.extend(args);
            command.push("\"$@\"".to_string());
            format!("#!/bin/bash\n{}\n", command.join(" "))
        }
        _ => bail!(
            "brew-cask: command_wrapper '{}' must set exactly one of content or executable",
            wrapper.name
        ),
    };
    file::write(&target, content)?;
    file::make_executable(&target)?;
    Ok(())
}

fn expand_command_wrapper_content(value: &str, appdir: &Path) -> String {
    value
        .replace("$HOMEBREW_PREFIX", &prefix::prefix().to_string_lossy())
        .replace("$APPDIR", &appdir.to_string_lossy())
}

fn expand_command_wrapper_value(value: &str, appdir: &Path, cask: &Cask) -> String {
    let staged_path = caskroom_version_dir(&cask.token, &cask.version);
    expand_cask_template(value, &staged_path, appdir, Some(&cask.version))
}

fn is_shell_env_name(value: &str) -> bool {
    let mut chars = value.chars();
    chars
        .next()
        .is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
        && chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}

fn find_binary_source(
    stage: &Path,
    caskroom: &Path,
    cask: &Cask,
    binary: &BinaryArtifact,
) -> Result<PathBuf> {
    // Homebrew API often records preflight/postflight wrappers as
    // `$HOMEBREW_PREFIX/Caskroom/<token>/<version>/<name>`. Map that final
    // path onto:
    //   1) temp caskroom (postflight runs with staged_path = temp caskroom)
    //   2) extract stage (preflight runs with staged_path = extract stage; e.g. VLC)
    for root in [caskroom, stage] {
        if let Some(source) = generated_caskroom_artifact(root, cask, &binary.source)
            && source.is_file()
        {
            return Ok(source);
        }
    }
    if let Some(source) = absolute_binary_source(&binary.source)
        && source.is_file()
    {
        return Ok(source);
    }
    find_file_artifact(caskroom, &binary.source)
        .or_else(|| find_file_artifact(stage, &binary.source))
        .ok_or_else(|| {
            eyre!(
                "brew-cask: binary artifact '{}' was not found",
                binary.source
            )
        })
}

fn absolute_binary_source(source: &str) -> Option<PathBuf> {
    let prefix = prefix::prefix();
    let source = source.replace("$HOMEBREW_PREFIX", &prefix.to_string_lossy());
    let source = PathBuf::from(source);
    source.is_absolute().then_some(source)
}

fn generated_caskroom_artifact(root: &Path, cask: &Cask, source: &str) -> Option<PathBuf> {
    let prefix = prefix::prefix();
    let source = source.replace("$HOMEBREW_PREFIX", &prefix.to_string_lossy());
    let source = PathBuf::from(source);
    let final_caskroom = caskroom_version_dir(&cask.token, &cask.version);
    let relative = source.strip_prefix(final_caskroom).ok()?;
    if relative
        .components()
        .any(|component| matches!(component, Component::ParentDir))
    {
        return None;
    }
    Some(root.join(relative))
}

fn resolve_symlink_target(link: &Path, target: PathBuf) -> PathBuf {
    if target.is_absolute() {
        target
    } else {
        link.parent()
            .map(|parent| parent.join(&target))
            .unwrap_or(target)
    }
}

fn durable_internal_symlink_source(stage: &Path, source: &Path, link: &Path) -> Option<PathBuf> {
    if !source.is_absolute()
        || staged_relative_path(stage, source).is_none()
        || staged_relative_path(stage, link).is_none()
    {
        return None;
    }
    relative_path_between(link.parent()?, source)
}

fn relative_path_between(from: &Path, to: &Path) -> Option<PathBuf> {
    let from = lexically_normalized_path(from);
    let to = lexically_normalized_path(to);
    let from_components = from.components().collect::<Vec<_>>();
    let to_components = to.components().collect::<Vec<_>>();
    let common = from_components
        .iter()
        .zip(&to_components)
        .take_while(|(from, to)| from == to)
        .count();
    let mut relative = PathBuf::new();
    for component in &from_components[common..] {
        match component {
            Component::Normal(_) => relative.push(".."),
            Component::CurDir => {}
            _ => return None,
        }
    }
    for component in &to_components[common..] {
        match component {
            Component::Normal(_) | Component::CurDir => relative.push(component.as_os_str()),
            _ => return None,
        }
    }
    Some(if relative.as_os_str().is_empty() {
        PathBuf::from(".")
    } else {
        relative
    })
}

fn lexically_normalized_path(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::CurDir => {}
            Component::ParentDir => {
                if !normalized.pop() {
                    normalized.push(component);
                }
            }
            _ => normalized.push(component),
        }
    }
    normalized
}

fn staged_relative_path(stage: &Path, path: &Path) -> Option<PathBuf> {
    let stage = lexically_normalized_path(stage);
    let path = lexically_normalized_path(path);
    path.strip_prefix(&stage)
        .or_else(|_| path.strip_prefix(file::desymlink_path(&stage)))
        .ok()
        .map(Path::to_path_buf)
}

fn path_starts_with_resolved_root(path: &Path, root: &Path) -> bool {
    path_with_resolved_existing_ancestor(path).starts_with(file::desymlink_path(root))
}

fn path_with_resolved_existing_ancestor(path: &Path) -> PathBuf {
    let mut base = path;
    let mut suffix = PathBuf::new();
    loop {
        if base.symlink_metadata().is_ok() {
            return file::desymlink_path(base).join(suffix);
        }
        let Some(name) = base.file_name() else {
            return path.to_path_buf();
        };
        suffix = Path::new(name).join(suffix);
        let Some(parent) = base.parent() else {
            return path.to_path_buf();
        };
        base = parent;
    }
}

fn cask_appdir(apps: &[AppArtifact]) -> Result<PathBuf> {
    let prefix_app_dir = prefix::prefix().join("Applications");
    for app in apps {
        if app_target_path(app.target_name())?.starts_with(&prefix_app_dir) {
            return Ok(prefix_app_dir);
        }
    }
    target_app_dir()
}

fn link_binary(caskroom: &Path, appdir: &Path, binary: &BinaryArtifact) -> Result<()> {
    let caskroom_binary = caskroom_binary_path(caskroom, appdir, binary)?;
    if !caskroom_binary.is_file() {
        if caskroom_binary
            .symlink_metadata()
            .is_ok_and(|metadata| metadata.file_type().is_symlink())
        {
            let target = std::fs::read_link(&caskroom_binary)?;
            bail!(
                "brew-cask: binary artifact '{}' was staged but symlink target '{}' does not exist",
                binary.source,
                target.display()
            );
        }
        bail!(
            "brew-cask: binary artifact '{}' was not staged",
            binary.source
        );
    }
    let target = binary.target_path(appdir)?;
    if let Some(parent) = target.parent() {
        create_dir_all_elevating(parent)?;
    }
    make_symlink_elevating(&caskroom_binary, &target)?;
    Ok(())
}

fn link_command_wrapper(caskroom: &Path, wrapper: &CommandWrapperArtifact) -> Result<()> {
    let source = wrapper.caskroom_path(caskroom);
    if !source.is_file() {
        bail!(
            "brew-cask: command wrapper '{}' was not staged",
            wrapper.name
        );
    }
    let target = wrapper.target_path()?;
    if let Some(parent) = target.parent() {
        create_dir_all_elevating(parent)?;
    }
    make_symlink_elevating(&source, &target)?;
    Ok(())
}

fn caskroom_binary_path(
    caskroom: &Path,
    appdir: &Path,
    binary: &BinaryArtifact,
) -> Result<PathBuf> {
    let target = binary.target_path(appdir)?;
    let roots = if is_appdir_binary_target(&binary.target_name()?) {
        let mut roots = allowed_appdir_roots()?;
        roots.extend(allowed_binary_target_roots());
        roots
    } else {
        allowed_binary_target_roots()
    };
    let relative = roots
        .iter()
        .find_map(|root| target.strip_prefix(root).ok())
        .ok_or_else(|| {
            eyre!(
                "brew-cask: binary target '{}' must be under {}",
                target.display(),
                allowed_binary_target_roots_display(&roots)
            )
        })?;
    if relative.components().next().is_none() {
        bail!(
            "brew-cask: invalid binary target '{}'",
            binary.target_name()?
        );
    }
    Ok(caskroom.join(relative))
}

fn cask_artifacts(cask: &Cask) -> Result<CaskArtifacts> {
    let mut artifacts = CaskArtifacts::default();
    for artifact in &cask.artifacts {
        let artifact_type = artifact_type(artifact);
        if let Some(steps) = parse_flight_steps(cask, artifact, "preflight_steps")? {
            artifacts.preflight_steps.extend(steps);
            continue;
        }
        if let Some(steps) = parse_flight_steps(cask, artifact, "postflight_steps")? {
            artifacts.postflight_steps.extend(steps);
            continue;
        }
        if is_non_install_artifact(&artifact_type) {
            collect_pkg_receipt_ids(artifact, &mut artifacts.pkg_ids);
            continue;
        }
        if let Some(app) = parse_app_artifact(artifact) {
            artifacts.apps.push(app);
            continue;
        }
        if let Some(binary) = parse_binary_artifact(artifact) {
            artifacts.binaries.push(binary);
            continue;
        }
        if let Some(wrapper) = parse_command_wrapper_artifact(artifact)? {
            artifacts.command_wrappers.push(wrapper);
            continue;
        }
        if let Some(pkg) = parse_pkg_artifact(artifact)? {
            artifacts.pkgs.push(pkg);
            continue;
        }
        if let Some(installer) = parse_installer_artifact(artifact)? {
            artifacts.installers.push(installer);
            continue;
        }
        if let Some(artifact) = parse_generic_artifact(artifact)? {
            artifacts.generic.push(artifact);
            continue;
        }
        if let Some(font) = parse_font_artifact(artifact) {
            artifacts.fonts.push(font);
            continue;
        }
        if let Some(completion) = parse_completion_artifact(artifact)? {
            artifacts.completions.push(completion);
            continue;
        }
        if let Some(generated) = parse_generated_completion_artifact(artifact)? {
            artifacts.generated_completions.push(generated);
            continue;
        }
        bail!(
            "brew-cask:{}: unsupported artifact type {}",
            cask.token,
            artifact_type
        );
    }
    if artifacts.apps.is_empty()
        && artifacts.binaries.is_empty()
        && artifacts.command_wrappers.is_empty()
        && artifacts.pkgs.is_empty()
        && artifacts.installers.is_empty()
        && artifacts.generic.is_empty()
        && artifacts.fonts.is_empty()
        && artifacts.completions.is_empty()
        && artifacts.generated_completions.is_empty()
    {
        bail!(
            "brew-cask:{}: no supported install artifact found",
            cask.token
        );
    }
    artifacts.pkg_ids.sort();
    artifacts.pkg_ids.dedup();
    if artifacts.pkgs.is_empty() {
        artifacts.pkg_ids.clear();
    } else if artifacts.pkg_ids.is_empty() {
        bail!(
            "brew-cask:{}: pkg artifacts require pkgutil ids in uninstall metadata",
            cask.token
        );
    }
    Ok(artifacts)
}

fn validate_platform_support(cask: &Cask, artifacts: &CaskArtifacts) -> Result<()> {
    #[cfg(target_os = "linux")]
    {
        let font_only = !artifacts.fonts.is_empty()
            && artifacts.apps.is_empty()
            && artifacts.binaries.is_empty()
            && artifacts.command_wrappers.is_empty()
            && artifacts.pkgs.is_empty()
            && artifacts.installers.is_empty()
            && artifacts.generic.is_empty()
            && artifacts.completions.is_empty()
            && artifacts.generated_completions.is_empty()
            && artifacts.preflight_steps.is_empty()
            && artifacts.postflight_steps.is_empty()
            && !has_lifecycle_hook(cask, "preflight")
            && !has_lifecycle_hook(cask, "postflight");
        if !font_only {
            bail!(
                "brew-cask:{}: only font-only casks without lifecycle hooks are supported on linux",
                cask.token
            );
        }
    }
    #[cfg(not(target_os = "linux"))]
    let _ = (cask, artifacts);
    Ok(())
}

fn platform_unavailable_state(cask: &Cask, artifacts: &CaskArtifacts) -> Option<PackageState> {
    validate_platform_support(cask, artifacts)
        .err()
        .map(|err| PackageState::unavailable(err.to_string()))
}

fn artifact_target(value: &Value, values: &[Value]) -> Option<String> {
    values
        .get(1)
        .and_then(|v| v.as_object())
        .and_then(|o| o.get("target"))
        .or_else(|| value.as_object().and_then(|o| o.get("target")))
        .and_then(Value::as_str)
        .map(str::to_string)
}

fn parse_app_artifact(value: &Value) -> Option<AppArtifact> {
    let app = value.as_object()?.get("app")?;
    match app {
        Value::String(source) => Some(AppArtifact {
            source: source.clone(),
            target: None,
        }),
        Value::Array(values) => {
            let source = values.first()?.as_str()?.to_string();
            let target = artifact_target(value, values);
            Some(AppArtifact { source, target })
        }
        _ => None,
    }
}

fn parse_binary_artifact(value: &Value) -> Option<BinaryArtifact> {
    let binary = value.as_object()?.get("binary")?;
    match binary {
        Value::String(source) => Some(BinaryArtifact {
            source: source.clone(),
            target: value
                .as_object()
                .and_then(|o| o.get("target"))
                .and_then(Value::as_str)
                .map(str::to_string),
        }),
        Value::Array(values) => {
            let source = values.first()?.as_str()?.to_string();
            let target = artifact_target(value, values);
            Some(BinaryArtifact { source, target })
        }
        _ => None,
    }
}

fn parse_command_wrapper_artifact(value: &Value) -> Result<Option<CommandWrapperArtifact>> {
    let Some(wrapper) = value.as_object().and_then(|o| o.get("command_wrapper")) else {
        return Ok(None);
    };
    let values = wrapper
        .as_array()
        .ok_or_else(|| eyre!("brew-cask: command_wrapper metadata must be an array"))?;
    let name = values
        .first()
        .and_then(Value::as_str)
        .ok_or_else(|| eyre!("brew-cask: command_wrapper requires a command name"))?;
    let name_path = Path::new(name);
    if name_path.file_name().and_then(|name| name.to_str()) != Some(name)
        || matches!(name, "." | "..")
    {
        bail!("brew-cask: command_wrapper requires a command name without path components");
    }
    let options = values
        .get(1)
        .and_then(Value::as_object)
        .ok_or_else(|| eyre!("brew-cask: command_wrapper requires options"))?;
    let mut unsupported = options
        .keys()
        .filter(|key| !matches!(key.as_str(), "content" | "executable" | "args" | "env"))
        .cloned()
        .collect::<Vec<_>>();
    unsupported.sort();
    if !unsupported.is_empty() {
        bail!(
            "brew-cask: command_wrapper has unsupported option {}",
            unsupported.join(", ")
        );
    }
    let content = options
        .get("content")
        .and_then(Value::as_str)
        .map(str::to_string);
    let executable = options
        .get("executable")
        .and_then(Value::as_str)
        .map(str::to_string);
    match (content.is_some(), executable.is_some()) {
        (false, false) => {
            bail!("brew-cask: command_wrapper requires content or executable")
        }
        (true, true) => {
            bail!("brew-cask: command_wrapper requires content or executable, not both")
        }
        _ => {}
    }
    let args = options
        .get("args")
        .map(|args| {
            args.as_array()
                .ok_or_else(|| eyre!("brew-cask: command_wrapper args must be an array"))?
                .iter()
                .map(|arg| {
                    arg.as_str()
                        .map(str::to_string)
                        .ok_or_else(|| eyre!("brew-cask: command_wrapper args must be strings"))
                })
                .collect::<Result<Vec<_>>>()
        })
        .transpose()?
        .unwrap_or_default();
    let env = options
        .get("env")
        .map(|env| {
            env.as_object()
                .ok_or_else(|| eyre!("brew-cask: command_wrapper env must be an object"))?
                .iter()
                .map(|(key, value)| {
                    if !is_shell_env_name(key) {
                        bail!("brew-cask: invalid command_wrapper environment name '{key}'");
                    }
                    value
                        .as_str()
                        .map(|value| (key.clone(), value.to_string()))
                        .ok_or_else(|| {
                            eyre!("brew-cask: command_wrapper environment values must be strings")
                        })
                })
                .collect::<Result<BTreeMap<_, _>>>()
        })
        .transpose()?
        .unwrap_or_default();
    if content.is_some() && (!args.is_empty() || !env.is_empty()) {
        bail!("brew-cask: command_wrapper args and env require executable");
    }
    Ok(Some(CommandWrapperArtifact {
        name: name.to_string(),
        target: artifact_target(value, values),
        content,
        executable,
        args,
        env,
    }))
}

fn parse_pkg_artifact(value: &Value) -> Result<Option<PkgArtifact>> {
    let Some(pkg) = value.as_object().and_then(|o| o.get("pkg")) else {
        return Ok(None);
    };
    match pkg {
        Value::String(source) => Ok(Some(PkgArtifact {
            source: source.clone(),
        })),
        Value::Array(values) => {
            if values.len() > 1 {
                bail!("brew-cask: pkg installer choices are not supported yet");
            }
            Ok(values
                .first()
                .and_then(Value::as_str)
                .map(|source| PkgArtifact {
                    source: source.to_string(),
                }))
        }
        _ => Ok(None),
    }
}

fn parse_installer_artifact(value: &Value) -> Result<Option<InstallerArtifact>> {
    let Some(installer) = value.as_object().and_then(|object| object.get("installer")) else {
        return Ok(None);
    };
    let values = installer
        .as_array()
        .ok_or_else(|| eyre!("brew-cask: installer metadata must be an array"))?;
    let script = values
        .first()
        .and_then(Value::as_object)
        .and_then(|value| value.get("script"))
        .and_then(Value::as_object)
        .ok_or_else(|| eyre!("brew-cask: only script installers are supported"))?;
    reject_unsupported_artifact_fields("installer script", script, &["executable", "args"])?;
    let executable = script
        .get("executable")
        .and_then(Value::as_str)
        .ok_or_else(|| eyre!("brew-cask: installer script requires an executable"))?;
    let args = script
        .get("args")
        .map(|args| {
            args.as_array()
                .ok_or_else(|| eyre!("brew-cask: installer script args must be an array"))?
                .iter()
                .map(|arg| {
                    arg.as_str()
                        .map(str::to_string)
                        .ok_or_else(|| eyre!("brew-cask: installer script args must be strings"))
                })
                .collect::<Result<Vec<_>>>()
        })
        .transpose()?
        .unwrap_or_default();
    Ok(Some(InstallerArtifact {
        executable: executable.to_string(),
        args,
    }))
}

fn reject_unsupported_artifact_fields(
    context: &str,
    object: &serde_json::Map<String, Value>,
    allowed: &[&str],
) -> Result<()> {
    let unsupported = object
        .keys()
        .filter(|key| !allowed.contains(&key.as_str()))
        .cloned()
        .collect::<Vec<_>>();
    if !unsupported.is_empty() {
        bail!(
            "brew-cask: unsupported {context} field {}",
            unsupported.join(", ")
        );
    }
    Ok(())
}

fn parse_generic_artifact(value: &Value) -> Result<Option<GenericArtifact>> {
    let Some(artifact) = value.as_object().and_then(|object| object.get("artifact")) else {
        return Ok(None);
    };
    let values = artifact
        .as_array()
        .ok_or_else(|| eyre!("brew-cask: artifact metadata must be an array"))?;
    let source = values
        .first()
        .and_then(Value::as_str)
        .ok_or_else(|| eyre!("brew-cask: artifact requires a source"))?;
    let target = artifact_target(value, values)
        .ok_or_else(|| eyre!("brew-cask: artifact requires a target"))?;
    Ok(Some(GenericArtifact {
        source: source.to_string(),
        target,
    }))
}

fn parse_font_artifact(value: &Value) -> Option<FontArtifact> {
    let font = value.as_object()?.get("font")?;
    match font {
        Value::String(source) => Some(FontArtifact {
            source: source.clone(),
            target: None,
        }),
        Value::Array(values) => {
            let source = values.first()?.as_str()?.to_string();
            let target = artifact_target(value, values);
            Some(FontArtifact { source, target })
        }
        _ => None,
    }
}

fn parse_completion_artifact(value: &Value) -> Result<Option<CompletionArtifact>> {
    for (key, shell) in [
        ("bash_completion", CompletionShell::Bash),
        ("fish_completion", CompletionShell::Fish),
        ("zsh_completion", CompletionShell::Zsh),
    ] {
        let Some(completion) = value.as_object().and_then(|o| o.get(key)) else {
            continue;
        };
        return parse_declared_completion_artifact(value, completion, shell);
    }
    Ok(None)
}

fn parse_declared_completion_artifact(
    value: &Value,
    completion: &Value,
    shell: CompletionShell,
) -> Result<Option<CompletionArtifact>> {
    match completion {
        Value::String(source) => Ok(Some(CompletionArtifact {
            shell,
            source: source.clone(),
            target: value
                .as_object()
                .and_then(|o| o.get("target"))
                .and_then(Value::as_str)
                .map(str::to_string),
        })),
        Value::Array(values) => {
            let Some(source) = values.first().and_then(Value::as_str) else {
                return Ok(None);
            };
            Ok(Some(CompletionArtifact {
                shell,
                source: source.to_string(),
                target: artifact_target(value, values),
            }))
        }
        _ => Ok(None),
    }
}

fn parse_generated_completion_artifact(
    value: &Value,
) -> Result<Option<GeneratedCompletionArtifact>> {
    let Some(generated) = value
        .as_object()
        .and_then(|o| o.get("generate_completions_from_executable"))
    else {
        return Ok(None);
    };
    let Value::Array(values) = generated else {
        return Ok(None);
    };
    if values.is_empty() {
        bail!("brew-cask: generate_completions_from_executable requires an executable");
    }
    let options = values.last().and_then(Value::as_object);
    let command_values = if options.is_some() {
        &values[..values.len() - 1]
    } else {
        values.as_slice()
    };
    let executable = command_values
        .first()
        .and_then(Value::as_str)
        .ok_or_else(|| {
            eyre!("brew-cask: generate_completions_from_executable requires an executable")
        })?
        .to_string();
    let args = command_values
        .iter()
        .skip(1)
        .map(|value| {
            value.as_str().map(str::to_string).ok_or_else(|| {
                eyre!("brew-cask: generate_completions_from_executable arguments must be strings")
            })
        })
        .collect::<Result<Vec<_>>>()?;
    let shell_parameter_format = options
        .and_then(|o| o.get("shell_parameter_format"))
        .and_then(Value::as_str)
        .map(str::to_string);
    let shells = options
        .and_then(|o| o.get("shells"))
        .and_then(Value::as_array)
        .map(|shells| {
            shells
                .iter()
                .map(|shell| {
                    let shell = shell.as_str().ok_or_else(|| {
                        eyre!("brew-cask: completion shell names must be strings")
                    })?;
                    CompletionShell::parse(shell)
                        .ok_or_else(|| eyre!("brew-cask: unsupported completion shell '{shell}'"))
                })
                .collect::<Result<Vec<_>>>()
        })
        .transpose()?
        .unwrap_or_else(|| default_generated_completion_shells(shell_parameter_format.as_deref()));
    if shells.is_empty() {
        bail!("brew-cask: generate_completions_from_executable requires at least one shell");
    }
    Ok(Some(GeneratedCompletionArtifact {
        executable,
        args,
        base_name: options
            .and_then(|o| o.get("base_name"))
            .and_then(Value::as_str)
            .map(str::to_string),
        shell_parameter_format,
        shells,
    }))
}

fn default_generated_completion_shells(format: Option<&str>) -> Vec<CompletionShell> {
    match format {
        Some("cobra") | Some("typer") => vec![
            CompletionShell::Bash,
            CompletionShell::Zsh,
            CompletionShell::Fish,
            CompletionShell::Pwsh,
        ],
        _ => vec![
            CompletionShell::Bash,
            CompletionShell::Zsh,
            CompletionShell::Fish,
        ],
    }
}

fn parse_flight_steps(cask: &Cask, value: &Value, kind: &str) -> Result<Option<Vec<FlightStep>>> {
    let Some(metadata) = value.as_object().and_then(|o| o.get(kind)) else {
        return Ok(None);
    };
    let groups = metadata.as_array().ok_or_else(|| {
        eyre!(
            "brew-cask:{}: unsupported {kind} metadata format",
            cask.token
        )
    })?;
    let mut steps = Vec::new();
    for group in groups {
        let group = group.as_object().ok_or_else(|| {
            eyre!(
                "brew-cask:{}: unsupported {kind} metadata format",
                cask.token
            )
        })?;
        reject_unsupported_flight_fields(cask, kind, "step group", group, &["steps"])?;
        let group_steps = group
            .get("steps")
            .and_then(Value::as_array)
            .ok_or_else(|| {
                eyre!(
                    "brew-cask:{}: unsupported {kind} metadata format",
                    cask.token
                )
            })?;
        for step in group_steps {
            steps.push(parse_flight_step(cask, kind, step)?);
        }
    }
    Ok(Some(steps))
}

fn parse_flight_step(cask: &Cask, kind: &str, value: &Value) -> Result<FlightStep> {
    let object = value.as_object().ok_or_else(|| {
        eyre!(
            "brew-cask:{}: unsupported {kind} step metadata format",
            cask.token
        )
    })?;
    let step_type = object.get("type").and_then(Value::as_str).ok_or_else(|| {
        eyre!(
            "brew-cask:{}: unsupported {kind} step metadata format",
            cask.token
        )
    })?;
    match step_type {
        "move" => {
            reject_unsupported_flight_fields(
                cask,
                kind,
                "move step",
                object,
                &["type", "source", "target", "source_glob"],
            )?;
            Ok(FlightStep::Move {
                source: parse_flight_path(cask, kind, "source", object.get("source"))?,
                target: parse_flight_path(cask, kind, "target", object.get("target"))?,
                source_glob: object
                    .get("source_glob")
                    .and_then(Value::as_bool)
                    .unwrap_or(false),
            })
        }
        "remove" => {
            reject_unsupported_flight_fields(
                cask,
                kind,
                "remove step",
                object,
                &["type", "paths", "recursive"],
            )?;
            let paths = object
                .get("paths")
                .and_then(Value::as_array)
                .ok_or_else(|| {
                    eyre!(
                        "brew-cask:{}: unsupported {kind} remove step metadata format",
                        cask.token
                    )
                })?
                .iter()
                .map(|path| parse_flight_path(cask, kind, "paths", Some(path)))
                .collect::<Result<Vec<_>>>()?;
            Ok(FlightStep::Remove {
                paths,
                recursive: object
                    .get("recursive")
                    .and_then(Value::as_bool)
                    .unwrap_or(false),
            })
        }
        "copy" => {
            reject_unsupported_flight_fields(
                cask,
                kind,
                "copy step",
                object,
                &[
                    "type",
                    "source",
                    "target",
                    "recursive",
                    "overwrite",
                    "source_glob",
                    "guards",
                ],
            )?;
            Ok(FlightStep::Copy {
                source: parse_context_flight_path_value(
                    cask,
                    kind,
                    "copy source",
                    object.get("source"),
                )?,
                target: parse_context_flight_path_value(
                    cask,
                    kind,
                    "copy target",
                    object.get("target"),
                )?,
                recursive: parse_optional_flight_bool(cask, kind, object, "recursive", false)?,
                overwrite: parse_optional_flight_bool(cask, kind, object, "overwrite", true)?,
                source_glob: parse_optional_flight_bool(cask, kind, object, "source_glob", false)?,
                guards: parse_flight_guards(cask, kind, object.get("guards"))?,
            })
        }
        "symlink" => {
            reject_unsupported_flight_fields(
                cask,
                kind,
                "symlink step",
                object,
                &[
                    "type",
                    "source",
                    "target",
                    "force",
                    "uninstall",
                    "source_glob",
                    "sudo",
                    "guards",
                ],
            )?;
            Ok(FlightStep::Symlink {
                source: parse_context_flight_path_value(
                    cask,
                    kind,
                    "symlink source",
                    object.get("source"),
                )?,
                target: parse_context_flight_path_value(
                    cask,
                    kind,
                    "symlink target",
                    object.get("target"),
                )?,
                force: parse_optional_flight_bool(cask, kind, object, "force", false)?,
                uninstall: parse_optional_flight_bool(cask, kind, object, "uninstall", false)?,
                source_glob: parse_optional_flight_bool(cask, kind, object, "source_glob", false)?,
                sudo: parse_flight_sudo(cask, kind, object.get("sudo"))?,
                guards: parse_flight_guards(cask, kind, object.get("guards"))?,
            })
        }
        "run" => {
            reject_unsupported_flight_fields(
                cask,
                kind,
                "run step",
                object,
                &[
                    "type",
                    "command",
                    "args",
                    "env",
                    "sudo",
                    "guards",
                    "network_access",
                ],
            )?;
            let args = object
                .get("args")
                .map(|args| {
                    args.as_array()
                        .ok_or_else(|| {
                            eyre!(
                                "brew-cask:{}: unsupported {kind} run args metadata format",
                                cask.token
                            )
                        })?
                        .iter()
                        .map(|arg| {
                            arg.as_str().map(str::to_string).ok_or_else(|| {
                                eyre!(
                                    "brew-cask:{}: unsupported {kind} run argument metadata format",
                                    cask.token
                                )
                            })
                        })
                        .collect::<Result<Vec<_>>>()
                })
                .transpose()?
                .unwrap_or_default();
            let env = object
                .get("env")
                .map(|env| {
                    env.as_object()
                        .ok_or_else(|| {
                            eyre!(
                                "brew-cask:{}: unsupported {kind} run env metadata format",
                                cask.token
                            )
                        })?
                        .iter()
                        .map(|(key, value)| {
                            value
                                .as_str()
                                .map(|value| (key.clone(), value.to_string()))
                                .ok_or_else(|| {
                                    eyre!(
                                        "brew-cask:{}: unsupported {kind} run env value metadata format",
                                        cask.token
                                    )
                                })
                        })
                        .collect::<Result<BTreeMap<_, _>>>()
                })
                .transpose()?
                .unwrap_or_default();
            let guards = parse_flight_guards(cask, kind, object.get("guards"))?;
            Ok(FlightStep::Run {
                command: parse_run_command(cask, kind, object.get("command"))?,
                args,
                env,
                sudo: object.get("sudo").and_then(Value::as_bool).unwrap_or(false),
                guards,
            })
        }
        "terminate_process" => {
            reject_unsupported_flight_fields(
                cask,
                kind,
                "terminate_process step",
                object,
                &[
                    "type",
                    "name",
                    "match",
                    "sudo",
                    "attempts",
                    "must_succeed",
                    "notices",
                    "failure_message",
                ],
            )?;
            let name = object.get("name").and_then(Value::as_str).ok_or_else(|| {
                eyre!(
                    "brew-cask:{}: {kind} terminate_process name must be a string",
                    cask.token
                )
            })?;
            if name.is_empty() {
                bail!(
                    "brew-cask:{}: {kind} terminate_process name must not be empty",
                    cask.token
                );
            }
            let match_mode = match object.get("match") {
                None => ProcessMatch::Name,
                Some(Value::String(value)) if value == "name" => ProcessMatch::Name,
                Some(Value::String(value)) if value == "full" => ProcessMatch::Full,
                _ => bail!(
                    "brew-cask:{}: {kind} terminate_process match must be name or full",
                    cask.token
                ),
            };
            let sudo = parse_optional_flight_bool(cask, kind, object, "sudo", false)?;
            let must_succeed =
                parse_optional_flight_bool(cask, kind, object, "must_succeed", false)?;
            let attempts = match object.get("attempts") {
                None => 1,
                Some(value) => value
                    .as_u64()
                    .and_then(|value| usize::try_from(value).ok())
                    .filter(|value| *value > 0)
                    .ok_or_else(|| {
                        eyre!(
                            "brew-cask:{}: {kind} terminate_process attempts must be a positive integer",
                            cask.token
                        )
                    })?,
            };
            let notices = match object.get("notices") {
                None => Vec::new(),
                Some(Value::Array(values)) => values
                    .iter()
                    .map(|value| {
                        value.as_str().map(str::to_string).ok_or_else(|| {
                            eyre!(
                                "brew-cask:{}: {kind} terminate_process notices must be strings",
                                cask.token
                            )
                        })
                    })
                    .collect::<Result<Vec<_>>>()?,
                Some(_) => bail!(
                    "brew-cask:{}: {kind} terminate_process notices must be an array",
                    cask.token
                ),
            };
            let failure_message = match object.get("failure_message") {
                None | Some(Value::Null) => None,
                Some(Value::String(value)) => Some(value.clone()),
                Some(_) => bail!(
                    "brew-cask:{}: {kind} terminate_process failure_message must be a string",
                    cask.token
                ),
            };
            Ok(FlightStep::TerminateProcess {
                name: name.to_string(),
                match_mode,
                sudo,
                attempts,
                must_succeed,
                notices,
                failure_message,
            })
        }
        _ => bail!(
            "brew-cask:{}: unsupported {kind} step type {}",
            cask.token,
            step_type
        ),
    }
}

fn parse_flight_sudo(cask: &Cask, kind: &str, value: Option<&Value>) -> Result<FlightSudo> {
    match value {
        None | Some(Value::Bool(false)) => Ok(FlightSudo::Never),
        Some(Value::Bool(true)) => Ok(FlightSudo::Always),
        Some(Value::String(value)) if value == "if_needed" => Ok(FlightSudo::IfNeeded),
        _ => bail!("brew-cask:{}: unsupported {kind} sudo setting", cask.token),
    }
}

fn parse_flight_guards(cask: &Cask, kind: &str, value: Option<&Value>) -> Result<Vec<FlightGuard>> {
    value
        .map(|guards| {
            guards
                .as_array()
                .ok_or_else(|| {
                    eyre!(
                        "brew-cask:{}: unsupported {kind} guards metadata format",
                        cask.token
                    )
                })?
                .iter()
                .map(|guard| parse_flight_guard(cask, kind, guard))
                .collect::<Result<Vec<_>>>()
        })
        .transpose()
        .map(|guards| guards.unwrap_or_default())
}

fn parse_optional_flight_bool(
    cask: &Cask,
    kind: &str,
    object: &serde_json::Map<String, Value>,
    field: &str,
    default: bool,
) -> Result<bool> {
    match object.get(field) {
        None => Ok(default),
        Some(Value::Bool(value)) => Ok(*value),
        Some(_) => bail!(
            "brew-cask:{}: {kind} terminate_process {field} must be a boolean",
            cask.token
        ),
    }
}

fn parse_run_command(cask: &Cask, kind: &str, value: Option<&Value>) -> Result<FlightPath> {
    let object = value.and_then(Value::as_object).ok_or_else(|| {
        eyre!(
            "brew-cask:{}: unsupported {kind} run command metadata format",
            cask.token
        )
    })?;
    reject_unsupported_flight_fields(cask, kind, "run command", object, &["base", "path"])?;
    let path = object.get("path").and_then(Value::as_str).ok_or_else(|| {
        eyre!(
            "brew-cask:{}: unsupported {kind} run command path",
            cask.token
        )
    })?;
    let base = match object.get("base").and_then(Value::as_str) {
        Some("staged_path") => FlightPathBase::StagedPath,
        Some("appdir") => FlightPathBase::AppDir,
        Some("homebrew_prefix") => FlightPathBase::HomebrewPrefix,
        Some(base) => bail!(
            "brew-cask:{}: unsupported {kind} run command base {}",
            cask.token,
            base
        ),
        None => FlightPathBase::Literal,
    };
    let path_value = Path::new(path);
    let invalid_absolute_path = base == FlightPathBase::Literal
        && !path_value.is_absolute()
        && path_value.components().count() > 1;
    let invalid_based_path = matches!(
        base,
        FlightPathBase::StagedPath | FlightPathBase::AppDir | FlightPathBase::HomebrewPrefix
    ) && (path_value.is_absolute()
        || path_value
            .components()
            .any(|component| matches!(component, Component::ParentDir)));
    if invalid_absolute_path || invalid_based_path {
        bail!(
            "brew-cask:{}: invalid {kind} run command path {}",
            cask.token,
            path
        );
    }
    Ok(FlightPath {
        base,
        path: path.to_string(),
    })
}

fn parse_flight_guard(cask: &Cask, kind: &str, value: &Value) -> Result<FlightGuard> {
    let object = value.as_object().ok_or_else(|| {
        eyre!(
            "brew-cask:{}: unsupported {kind} run guard metadata format",
            cask.token
        )
    })?;
    reject_unsupported_flight_fields(
        cask,
        kind,
        "run guard",
        object,
        &["condition", "value", "base", "path", "id"],
    )?;
    match object.get("condition").and_then(Value::as_str) {
        Some("on") => match object.get("value").and_then(Value::as_str) {
            Some("macos") => Ok(FlightGuard::OnMacos),
            Some("linux") => Ok(FlightGuard::OnLinux),
            Some(value) => bail!(
                "brew-cask:{}: unsupported {kind} run guard platform {}",
                cask.token,
                value
            ),
            None => bail!(
                "brew-cask:{}: unsupported {kind} run guard platform",
                cask.token
            ),
        },
        Some(condition @ ("if_exists" | "unless_exists")) => {
            let path = parse_context_flight_path(cask, kind, "run guard", object)?;
            if condition == "if_exists" {
                Ok(FlightGuard::IfExists(path))
            } else {
                Ok(FlightGuard::UnlessExists(path))
            }
        }
        Some(condition) => bail!(
            "brew-cask:{}: unsupported {kind} run guard condition {}",
            cask.token,
            condition
        ),
        None => bail!(
            "brew-cask:{}: unsupported {kind} run guard condition",
            cask.token
        ),
    }
}

fn parse_context_flight_path(
    cask: &Cask,
    kind: &str,
    field: &str,
    object: &serde_json::Map<String, Value>,
) -> Result<FlightPath> {
    let path = object
        .get("path")
        .and_then(Value::as_str)
        .ok_or_else(|| eyre!("brew-cask:{}: unsupported {kind} {field} path", cask.token))?;
    let base = match object.get("base").and_then(Value::as_str) {
        Some("staged_path") => FlightPathBase::StagedPath,
        Some("appdir") => FlightPathBase::AppDir,
        Some("homebrew_prefix") => FlightPathBase::HomebrewPrefix,
        Some("relative") => FlightPathBase::Literal,
        Some(base) => bail!(
            "brew-cask:{}: unsupported {kind} {field} base {}",
            cask.token,
            base
        ),
        None => FlightPathBase::Literal,
    };
    Ok(FlightPath {
        base,
        path: path.to_string(),
    })
}

fn parse_context_flight_path_value(
    cask: &Cask,
    kind: &str,
    field: &str,
    value: Option<&Value>,
) -> Result<FlightPath> {
    let object = value.and_then(Value::as_object).ok_or_else(|| {
        eyre!(
            "brew-cask:{}: unsupported {kind} {field} metadata format",
            cask.token
        )
    })?;
    reject_unsupported_flight_fields(cask, kind, field, object, &["base", "path"])?;
    parse_context_flight_path(cask, kind, field, object)
}

fn reject_unsupported_flight_fields(
    cask: &Cask,
    kind: &str,
    context: &str,
    object: &serde_json::Map<String, Value>,
    allowed: &[&str],
) -> Result<()> {
    let mut unsupported = object
        .keys()
        .filter(|key| !allowed.contains(&key.as_str()))
        .cloned()
        .collect::<Vec<_>>();
    unsupported.sort();
    if !unsupported.is_empty() {
        bail!(
            "brew-cask:{}: unsupported {kind} {context} field {}",
            cask.token,
            unsupported.join(", ")
        );
    }
    Ok(())
}

fn parse_flight_path(
    cask: &Cask,
    kind: &str,
    field: &str,
    value: Option<&Value>,
) -> Result<FlightPath> {
    let object = value.and_then(Value::as_object).ok_or_else(|| {
        eyre!(
            "brew-cask:{}: unsupported {kind} {field} metadata format",
            cask.token
        )
    })?;
    let base = match object.get("base").and_then(Value::as_str) {
        Some("staged_path") => FlightPathBase::StagedPath,
        Some(base) => bail!(
            "brew-cask:{}: unsupported {kind} {field} base {}",
            cask.token,
            base
        ),
        None => bail!("brew-cask:{}: unsupported {kind} {field} base", cask.token),
    };
    let path = object
        .get("path")
        .and_then(Value::as_str)
        .ok_or_else(|| eyre!("brew-cask:{}: unsupported {kind} {field} path", cask.token))?;
    if validate_flight_relative_path(path).is_err() {
        bail!(
            "brew-cask:{}: invalid {kind} {field} path {}",
            cask.token,
            path
        )
    }
    Ok(FlightPath {
        base,
        path: path.to_string(),
    })
}

fn collect_pkg_receipt_ids(value: &Value, pkg_ids: &mut Vec<String>) {
    let Some(object) = value.as_object() else {
        return;
    };
    let Some(metadata) = object.get("uninstall") else {
        return;
    };
    let values: Vec<&Value> = match metadata {
        Value::Array(values) => values.iter().collect(),
        value => vec![value],
    };
    for value in values {
        let Some(pkgutil) = value.as_object().and_then(|o| o.get("pkgutil")) else {
            continue;
        };
        match pkgutil {
            Value::String(id) => pkg_ids.push(id.clone()),
            Value::Array(ids) => {
                pkg_ids.extend(ids.iter().filter_map(Value::as_str).map(str::to_string))
            }
            _ => {}
        }
    }
}

fn find_app(root: &Path, name: &str) -> Option<PathBuf> {
    // Directory predicate inside the walk so a same-named file cannot shadow.
    find_artifact_matching(root, name, |path| path.is_dir())
}

fn find_file_artifact(root: &Path, name: &str) -> Option<PathBuf> {
    find_artifact_matching(root, name, |path| path.is_file())
}

/// Exact path suffix match first, then ASCII case-insensitive suffix (e.g. cask
/// `yaak.app` vs DMG `Yaak.app`). `pred` runs only after a name hit.
fn find_artifact_matching(
    root: &Path,
    name: &str,
    pred: impl Fn(&Path) -> bool,
) -> Option<PathBuf> {
    let name_path = Path::new(name);
    let mut case_insensitive = None;
    for entry in WalkDir::new(root)
        .into_iter()
        .filter_entry(|entry| entry.file_name() != "__MACOSX")
        .filter_map(|entry| entry.ok())
    {
        let path = entry.path();
        let Ok(relative) = path.strip_prefix(root) else {
            continue;
        };
        // Cheap path-string checks first; only `stat` via `pred` on name hits
        // (large .app trees have thousands of non-matching entries).
        if relative.ends_with(name_path) {
            if pred(path) {
                return Some(entry.into_path());
            }
        } else if case_insensitive.is_none()
            && path_ends_with_ignore_ascii_case(relative, name_path)
            && pred(path)
        {
            case_insensitive = Some(entry.into_path());
        }
    }
    case_insensitive
}

/// True when `path`'s trailing components match `suffix` with ASCII
/// case-insensitive comparison of normal path components.
fn path_ends_with_ignore_ascii_case(path: &Path, suffix: &Path) -> bool {
    if suffix.as_os_str().is_empty() {
        return false;
    }
    let mut path_iter = path.components().rev();
    for b in suffix.components().rev() {
        let Some(a) = path_iter.next() else {
            return false;
        };
        let matches = match (a, b) {
            (Component::Normal(a), Component::Normal(b)) => match (a.to_str(), b.to_str()) {
                (Some(a), Some(b)) => a.eq_ignore_ascii_case(b),
                _ => a == b,
            },
            _ => a == b,
        };
        if !matches {
            return false;
        }
    }
    true
}

fn app_target_path(target_name: &str) -> Result<PathBuf> {
    let app_dir = target_app_dir()?;
    if target_name.contains('\0') {
        bail!("brew-cask: app target contains NUL");
    }
    if target_name.contains('/') {
        let target = target_name.replace("$HOMEBREW_PREFIX", &prefix::prefix().to_string_lossy());
        let path = PathBuf::from(target);
        if path
            .components()
            .any(|component| matches!(component, Component::ParentDir))
        {
            bail!("brew-cask: app target '{target_name}' must not contain '..'");
        }
        if path.is_absolute() {
            let prefix_app_dir = prefix::prefix().join("Applications");
            if path.starts_with(&app_dir) || path.starts_with(&prefix_app_dir) {
                return Ok(path);
            }
            // Casks routinely hardcode an absolute `/Applications/Foo.app`
            // target. When an override appdir is configured, relocate such a
            // target into it (preserving any subdirectories) rather than
            // rejecting it. `$HOMEBREW_PREFIX`-anchored targets are handled by
            // the check above and are never relocated.
            if app_dir != Path::new(DEFAULT_APP_DIR)
                && let Ok(rest) = path.strip_prefix(DEFAULT_APP_DIR)
            {
                return Ok(app_dir.join(rest));
            }
            bail!(
                "brew-cask: app target '{target_name}' must be under {}",
                app_dir.display()
            );
        }
        bail!("brew-cask: app target '{target_name}' must be an absolute path");
    }
    Ok(app_dir.join(target_name))
}

/// The directory `app` artifacts are linked into: `/Applications` unless
/// [`APP_DIR_ENV`] overrides it.
///
/// The override is validated here rather than at the point of use because
/// `app_target_path` treats the result as a containment boundary for symlinks
/// that may be created with elevated privileges. An empty value falls back to
/// the default so that exporting `MISE_BREW_CASK_OPT_APPDIR=` cannot disable
/// that boundary: `Path::starts_with("")` is true for every path.
fn target_app_dir() -> Result<PathBuf> {
    let Ok(dir) = crate::env::var(APP_DIR_ENV) else {
        return Ok(PathBuf::from(DEFAULT_APP_DIR));
    };
    if dir.is_empty() {
        return Ok(PathBuf::from(DEFAULT_APP_DIR));
    }
    let dir = PathBuf::from(dir);
    if !dir.is_absolute() {
        bail!(
            "brew-cask: {APP_DIR_ENV} '{}' must be an absolute path",
            dir.display()
        );
    }
    if dir
        .components()
        .any(|component| matches!(component, Component::ParentDir))
    {
        bail!(
            "brew-cask: {APP_DIR_ENV} '{}' must not contain '..'",
            dir.display()
        );
    }
    // Resolve the override to a real absolute path: canonicalize its longest
    // existing prefix and re-append the components that do not exist yet. This
    // makes the appdir a symlink-free containment boundary — privileged cask
    // mutations then operate on resolved paths and cannot be redirected through
    // a symlinked component — and it collapses every spelling of the filesystem
    // root (`/`, `//`, `/.`, a symlink to `/`, ...) to `/` so they can all be
    // rejected together.
    let resolved = resolve_appdir(&dir);
    if !resolved
        .components()
        .any(|component| matches!(component, Component::Normal(_)))
    {
        bail!(
            "brew-cask: {APP_DIR_ENV} '{}' must not resolve to the filesystem root",
            dir.display()
        );
    }
    Ok(resolved)
}

/// Resolve `dir` by canonicalizing its longest existing ancestor and
/// re-appending the not-yet-existing tail. Symlinks in the existing portion are
/// followed, so the result is a real path the caller can safely use as a
/// containment boundary. Falls back to `dir` unchanged if nothing along the
/// path can be canonicalized (not expected for an absolute path, where `/`
/// always resolves).
fn resolve_appdir(dir: &Path) -> PathBuf {
    for ancestor in dir.ancestors() {
        if let Ok(real) = ancestor.canonicalize() {
            let tail = dir.strip_prefix(ancestor).unwrap_or(Path::new(""));
            return real.join(tail);
        }
    }
    dir.to_path_buf()
}

fn app_bundle_name(target_name: &str) -> Result<&str> {
    Path::new(target_name)
        .file_name()
        .and_then(|name| name.to_str())
        .ok_or_else(|| eyre!("brew-cask: invalid app target '{target_name}'"))
}

/// Roots that a cask's `binary` artifact may legitimately symlink into.
///
/// The Homebrew prefix (`/opt/homebrew` on arm64, `/usr/local` on Intel) is
/// always allowed. `/usr/local` is additionally allowed even on arm64 because
/// some casks (e.g. docker-desktop) hardcode absolute `/usr/local/bin` targets
/// so their CLIs land on PATH regardless of architecture. Homebrew honors those
/// targets, so mise does too.
fn allowed_binary_target_roots() -> Vec<PathBuf> {
    let prefix = prefix::prefix();
    let mut roots = vec![prefix.clone()];
    let usr_local = PathBuf::from("/usr/local");
    if prefix != usr_local {
        roots.push(usr_local);
    }
    roots
}

fn allowed_appdir_roots() -> Result<Vec<PathBuf>> {
    let mut roots = vec![PathBuf::from(DEFAULT_APP_DIR)];
    for root in [target_app_dir()?, prefix::prefix().join("Applications")] {
        if !roots.contains(&root) {
            roots.push(root);
        }
    }
    Ok(roots)
}

fn is_appdir_binary_target(target_name: &str) -> bool {
    target_name.starts_with("$APPDIR/")
}

fn allowed_binary_target_roots_display(roots: &[PathBuf]) -> String {
    roots
        .iter()
        .map(|root| root.display().to_string())
        .collect::<Vec<_>>()
        .join(" or ")
}

fn binary_target_path(target_name: &str, appdir: &Path) -> Result<PathBuf> {
    if target_name.contains('\0') {
        bail!("brew-cask: binary target contains NUL");
    }
    if let Some(relative) = target_name.strip_prefix("$APPDIR/") {
        let relative = Path::new(relative);
        if relative.components().next().is_none()
            || relative
                .components()
                .any(|component| !matches!(component, Component::Normal(_)))
        {
            bail!("brew-cask: binary $APPDIR target '{target_name}' must stay below Applications");
        }
        if !allowed_appdir_roots()?.iter().any(|root| root == appdir) {
            bail!("brew-cask: invalid appdir '{}'", appdir.display());
        }
        return Ok(appdir.join(relative));
    }
    if target_name.contains("$APPDIR") {
        bail!("brew-cask: $APPDIR must prefix a binary target");
    }
    let prefix = prefix::prefix();
    let prefix_str = prefix.to_string_lossy();
    let target_name = target_name.replace("$HOMEBREW_PREFIX", prefix_str.as_ref());
    let path = PathBuf::from(&target_name);
    let target = if path.is_absolute() {
        path
    } else if target_name.contains('/') {
        prefix.join(path)
    } else {
        prefix.join("bin").join(path)
    };
    if target
        .components()
        .any(|component| matches!(component, Component::ParentDir))
    {
        bail!(
            "brew-cask: binary target '{}' must not contain '..'",
            target.display()
        );
    }
    let roots = allowed_binary_target_roots();
    if !roots.iter().any(|root| target.starts_with(root)) {
        bail!(
            "brew-cask: binary target '{}' must be under {}",
            target.display(),
            allowed_binary_target_roots_display(&roots)
        );
    }
    Ok(target)
}

fn installed_version(token: &str) -> Option<String> {
    let versions = installed_versions(token);
    match versions.as_slice() {
        [version] => Some(version.clone()),
        [] => None,
        _ => {
            warn!("brew-cask:{token}: multiple Caskroom versions found; reinstall to reconcile");
            None
        }
    }
}

fn installed_versions(token: &str) -> Vec<String> {
    let dir = caskroom_token_dir(token);
    let Ok(entries) = std::fs::read_dir(dir) else {
        return Vec::new();
    };
    entries
        .filter_map(|entry| entry.ok())
        .filter_map(|entry| {
            let name = entry.file_name().into_string().ok()?;
            entry
                .file_type()
                .ok()
                .filter(|ft| ft.is_dir() && name != ".metadata" && !name.starts_with(".mise-tmp-"))
                .map(|_| name)
        })
        .collect()
}

fn homebrew_metadata_present(token: &str) -> bool {
    caskroom_token_dir(token)
        .join(".metadata")
        .symlink_metadata()
        .is_ok()
}

fn pkg_id_installed(pkg_id: &str) -> Result<bool> {
    #[cfg(not(target_os = "macos"))]
    bail!("brew-cask: pkgutil receipt check for '{pkg_id}' is only available on macOS");

    #[cfg(target_os = "macos")]
    let output = std::process::Command::new("pkgutil")
        .arg("--pkg-info")
        .arg(pkg_id)
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()?;
    #[cfg(target_os = "macos")]
    Ok(output.success())
}

fn pkg_ids_installed(pkg_ids: &[String]) -> Result<bool> {
    for pkg_id in pkg_ids {
        if !pkg_id_installed(pkg_id)? {
            return Ok(false);
        }
    }
    Ok(true)
}

fn binary_targets(artifacts: &CaskArtifacts) -> Result<Vec<PathBuf>> {
    let appdir = cask_appdir(&artifacts.apps)?;
    artifacts
        .binaries
        .iter()
        .map(|binary| binary.target_path(&appdir))
        .chain(
            artifacts
                .command_wrappers
                .iter()
                .map(CommandWrapperArtifact::target_path),
        )
        .collect::<Result<Vec<_>>>()
}

fn previous_binary_targets(cask: &Cask) -> Result<Vec<PathBuf>> {
    let Some(version) = installed_version(&cask.token) else {
        return Ok(Vec::new());
    };
    let version_dir = caskroom_version_dir(&cask.token, &version);
    Ok(read_receipt(&version_dir)?
        .map(|receipt| receipt.binaries)
        .unwrap_or_default())
}

fn previous_flight_symlink_targets(cask: &Cask) -> Result<Vec<PathBuf>> {
    let Some(version) = installed_version(&cask.token) else {
        return Ok(Vec::new());
    };
    let version_dir = caskroom_version_dir(&cask.token, &version);
    let Some(receipt) = read_receipt(&version_dir)? else {
        return Ok(Vec::new());
    };
    receipt_flight_symlink_targets(&receipt)
}

fn previous_flight_directory_targets(cask: &Cask) -> Result<Vec<PathBuf>> {
    let Some(version) = installed_version(&cask.token) else {
        return Ok(Vec::new());
    };
    let version_dir = caskroom_version_dir(&cask.token, &version);
    let Some(receipt) = read_receipt(&version_dir)? else {
        return Ok(Vec::new());
    };
    Ok(receipt.flight_directories)
}

fn remove_obsolete_flight_directories(
    previous: &BTreeSet<PathBuf>,
    current: &[PathBuf],
) -> Result<()> {
    for directory in previous {
        if !current.contains(directory) {
            remove_empty_directory_elevating(directory)?;
        }
    }
    Ok(())
}

fn receipt_flight_symlink_targets(receipt: &CaskReceipt) -> Result<Vec<PathBuf>> {
    let standard_targets = receipt
        .apps
        .iter()
        .chain(&receipt.binaries)
        .chain(&receipt.fonts)
        .chain(&receipt.completions)
        .collect::<BTreeSet<_>>();
    let mut targets = Vec::new();
    for record in &receipt.targets {
        if record.fingerprint.kind == CaskTargetKind::Symlink
            && !standard_targets.contains(&record.path)
            && record.uninstall.unwrap_or(true)
            && cask_target_record_matches(record)?
        {
            targets.push(record.path.clone());
        }
    }
    Ok(targets)
}

fn remove_obsolete_binary_links(
    cask: &Cask,
    previous_targets: &[PathBuf],
    current_targets: &[PathBuf],
) -> Result<()> {
    let token_dir = file::desymlink_path(&caskroom_token_dir(&cask.token));
    for target in previous_targets {
        if current_targets.contains(target) {
            continue;
        }
        let Ok(metadata) = target.symlink_metadata() else {
            continue;
        };
        if !metadata.file_type().is_symlink() {
            continue;
        }
        let Ok(link_target) = std::fs::read_link(target) else {
            continue;
        };
        let resolved = if link_target.is_absolute() {
            link_target
        } else {
            target
                .parent()
                .map(|parent| parent.join(&link_target))
                .unwrap_or(link_target)
        };
        if file::desymlink_path(&resolved).starts_with(&token_dir) {
            remove_artifact_target_elevating(target)?;
        }
    }
    Ok(())
}

#[cfg(not(test))]
fn installed_cask_version(cask: &Cask, artifacts: &CaskArtifacts) -> Result<Option<String>> {
    installed_cask_version_in(cask, artifacts, &crate::dirs::STATE)
}

#[cfg(test)]
fn installed_cask_version(cask: &Cask, artifacts: &CaskArtifacts) -> Result<Option<String>> {
    installed_cask_version_in(cask, artifacts, &prefix::prefix().join(".mise-test-state"))
}

fn installed_cask_version_in(
    cask: &Cask,
    _artifacts: &CaskArtifacts,
    state_dir: &Path,
) -> Result<Option<String>> {
    if cask_journal_pending_in(state_dir, &cask.token) {
        return Ok(None);
    }
    let Some(version) = installed_version(&cask.token) else {
        return Ok(None);
    };
    let version_dir = caskroom_version_dir(&cask.token, &version);
    match read_receipt(&version_dir)? {
        Some(receipt) => {
            if receipt.schema_version > 3 {
                return Ok(None);
            }
            if receipt.schema_version >= 2 {
                let targets_match = receipt
                    .targets
                    .iter()
                    .map(cask_target_record_matches)
                    .collect::<Result<Vec<_>>>()?
                    .into_iter()
                    .all(|matches| matches);
                let pkgs_installed = pkg_ids_installed(&receipt.pkg_ids)?;
                return Ok((targets_match && pkgs_installed).then_some(receipt.version));
            }

            // Legacy receipts remain usable only from the historical facts they
            // actually contain. Never fill omitted fields from today's API.
            let targets_exist = receipt
                .apps
                .iter()
                .chain(&receipt.binaries)
                .chain(&receipt.fonts)
                .chain(&receipt.completions)
                .all(|target| target.exists());
            let pkgs_installed = pkg_ids_installed(&receipt.pkg_ids)?;
            Ok((targets_exist && pkgs_installed).then_some(receipt.version))
        }
        None => Ok(None),
    }
}

fn cask_prune_blocker(cask: &Cask, artifacts: &CaskArtifacts) -> Option<String> {
    if !artifacts.pkgs.is_empty() {
        return Some("pkg artifacts require uninstall support".to_string());
    }
    if !artifacts.installers.is_empty() {
        return Some("installer artifacts may have untracked side effects".to_string());
    }
    if !artifacts.command_wrappers.is_empty() {
        return Some("command wrapper artifacts are not supported for pruning".to_string());
    }
    if !artifacts.generic.is_empty() {
        return Some("generic artifacts may install external trees".to_string());
    }
    if !artifacts.preflight_steps.is_empty()
        || !artifacts.postflight_steps.is_empty()
        || has_lifecycle_hook(cask, "preflight")
        || has_lifecycle_hook(cask, "postflight")
    {
        return Some("install lifecycle actions may have untracked side effects".to_string());
    }
    if cask.artifacts.iter().any(|artifact| {
        matches!(
            artifact_type(artifact).as_str(),
            "uninstall"
                | "uninstall_preflight"
                | "uninstall_preflight_steps"
                | "uninstall_postflight"
                | "uninstall_postflight_steps"
        )
    }) {
        return Some("uninstall lifecycle actions are not supported".to_string());
    }
    None
}

fn write_receipt_with_flight_targets(
    caskroom: &Path,
    cask: &Cask,
    artifacts: &CaskArtifacts,
    flight_targets: &[PathBuf],
    flight_uninstall_targets: &BTreeMap<PathBuf, bool>,
    flight_directories: &[PathBuf],
) -> Result<()> {
    let mut target_paths = artifacts
        .apps
        .iter()
        .map(|app| app_target_path(app.target_name()))
        .collect::<Result<Vec<_>>>()?;
    target_paths.extend(binary_targets(artifacts)?);
    target_paths.extend(
        artifacts
            .fonts
            .iter()
            .map(font_target_path)
            .collect::<Result<Vec<_>>>()?,
    );
    target_paths.extend(completion_target_paths(cask, artifacts)?);
    target_paths.extend(flight_targets.iter().cloned());
    target_paths.sort();
    target_paths.dedup();
    let targets = target_paths
        .iter()
        .map(|path| {
            Ok(CaskTargetRecord {
                path: path.clone(),
                fingerprint: cask_target_fingerprint(path)?,
                uninstall: flight_uninstall_targets.get(path).copied(),
            })
        })
        .collect::<Result<Vec<_>>>()?;
    let prune_blocker = cask_prune_blocker(cask, artifacts);
    let receipt = CaskReceipt {
        schema_version: 3,
        version: cask.version.clone(),
        apps: artifacts
            .apps
            .iter()
            .map(|app| app_target_path(app.target_name()))
            .collect::<Result<Vec<_>>>()?,
        binaries: binary_targets(artifacts)?,
        fonts: artifacts
            .fonts
            .iter()
            .map(font_target_path)
            .collect::<Result<Vec<_>>>()?,
        completions: completion_target_paths(cask, artifacts)?,
        flight_directories: flight_directories.to_vec(),
        generic: generic_artifact_targets(artifacts)?,
        pkg_ids: artifacts.pkg_ids.clone(),
        targets,
        prune_safe: prune_blocker.is_none(),
        prune_blocker,
    };
    let body = toml::to_string_pretty(&receipt)?;
    write_durable_file(&caskroom.join(".mise-cask.toml"), body.as_bytes())?;
    Ok(())
}

fn cask_target_record_matches(record: &CaskTargetRecord) -> Result<bool> {
    let Ok(actual) = cask_target_fingerprint(&record.path) else {
        return Ok(false);
    };
    Ok(actual == record.fingerprint)
}

fn cask_target_fingerprint(path: &Path) -> Result<CaskTargetFingerprint> {
    let metadata = path
        .symlink_metadata()
        .wrap_err_with(|| format!("failed to fingerprint {}", path.display()))?;
    if metadata.file_type().is_symlink() {
        let target = std::fs::read_link(path)?;
        return Ok(CaskTargetFingerprint {
            kind: CaskTargetKind::Symlink,
            digest: hex::encode(Sha256::digest(target.as_os_str().as_encoded_bytes())),
        });
    }
    if metadata.is_file() {
        return Ok(CaskTargetFingerprint {
            kind: CaskTargetKind::File,
            digest: hash::file_hash_sha256(path, None)?,
        });
    }
    if metadata.is_dir() {
        return Ok(CaskTargetFingerprint {
            kind: CaskTargetKind::Directory,
            digest: cask_directory_digest(path)?,
        });
    }
    bail!("brew-cask: unsupported target type '{}'", path.display())
}

/// Content identity intentionally excludes timestamps, ownership, and modes.
/// It hashes stable relative paths, entry kinds, file bytes, and link targets
/// without following symlinks.
fn cask_directory_digest(root: &Path) -> Result<String> {
    let mut entries = WalkDir::new(root)
        .follow_links(false)
        .into_iter()
        .collect::<std::result::Result<Vec<_>, _>>()?;
    entries.sort_by(|a, b| a.path().cmp(b.path()));
    let mut digest = Sha256::new();
    for entry in entries {
        let path = entry.path();
        let relative = path.strip_prefix(root)?;
        if relative.as_os_str().is_empty() {
            continue;
        }
        let metadata = path.symlink_metadata()?;
        digest.update([if metadata.file_type().is_symlink() {
            b'l'
        } else if metadata.is_dir() {
            b'd'
        } else if metadata.is_file() {
            b'f'
        } else {
            bail!(
                "brew-cask: unsupported directory entry '{}'",
                path.display()
            );
        }]);
        hash_digest_field(&mut digest, relative.as_os_str().as_encoded_bytes());
        if metadata.file_type().is_symlink() {
            let target = std::fs::read_link(path)?;
            hash_digest_field(&mut digest, target.as_os_str().as_encoded_bytes());
        } else if metadata.is_dir() {
        } else if metadata.is_file() {
            hash_digest_field(&mut digest, hash::file_hash_sha256(path, None)?.as_bytes());
        }
    }
    Ok(hex::encode(digest.finalize()))
}

fn hash_digest_field(digest: &mut Sha256, value: &[u8]) {
    digest.update((value.len() as u64).to_le_bytes());
    digest.update(value);
}

fn cask_journal_path_in(state_dir: &Path, token: &str, version: &str) -> PathBuf {
    state_dir
        .join("brew-cask")
        .join(token)
        .join(format!("{version}.json"))
}

fn cask_journal_pending_in(state_dir: &Path, token: &str) -> bool {
    state_dir
        .join("brew-cask")
        .join(token)
        .read_dir()
        .is_ok_and(|mut entries| entries.next().is_some())
}

fn write_cask_journal(journal: &CaskTransactionJournal<'_>) -> Result<()> {
    write_cask_journal_in(&crate::dirs::STATE, journal)
}

fn write_cask_journal_in(state_dir: &Path, journal: &CaskTransactionJournal<'_>) -> Result<()> {
    let path = cask_journal_path_in(state_dir, journal.token, journal.version);
    let body = serde_json::to_vec_pretty(journal)?;
    write_durable_file(&path, &body)
}

fn record_cask_action(journal: &mut CaskTransactionJournal<'_>, action: &str) -> Result<()> {
    record_cask_action_in(&crate::dirs::STATE, journal, action)
}

fn record_cask_action_in(
    state_dir: &Path,
    journal: &mut CaskTransactionJournal<'_>,
    action: &str,
) -> Result<()> {
    journal.completed.push(action.to_string());
    write_cask_journal_in(state_dir, journal)
}

fn remove_cask_journals(token: &str) -> Result<()> {
    remove_cask_journals_in(&crate::dirs::STATE, token)
}

fn remove_cask_journals_in(state_dir: &Path, token: &str) -> Result<()> {
    let path = state_dir.join("brew-cask").join(token);
    if path.symlink_metadata().is_ok() {
        file::remove_all(&path)?;
        if let Some(parent) = path.parent() {
            std::fs::File::open(parent)?.sync_all()?;
        }
    }
    Ok(())
}

fn write_durable_file(path: &Path, body: &[u8]) -> Result<()> {
    let parent = path
        .parent()
        .ok_or_else(|| eyre!("brew-cask: durable file has no parent"))?;
    file::create_dir_all(parent)?;
    let tmp = path.with_extension("tmp");
    {
        let mut output = std::fs::File::create(&tmp)?;
        std::io::Write::write_all(&mut output, body)?;
        output.sync_all()?;
    }
    std::fs::rename(&tmp, path)?;
    std::fs::File::open(parent)?.sync_all()?;
    Ok(())
}

fn read_receipt(caskroom: &Path) -> Result<Option<CaskReceipt>> {
    let path = caskroom.join(".mise-cask.toml");
    if !path.exists() {
        return Ok(None);
    }
    let body = crate::file::read_to_string(&path)?;
    toml::from_str(&body)
        .map(Some)
        .wrap_err_with(|| format!("failed to parse {}", path.display()))
}

pub async fn cask_prune_plan(configured: &[PackageRequest]) -> Result<CaskPrunePlan> {
    let mut keep = BTreeSet::new();
    for request in configured {
        keep.insert(fetch_cask(request).await?.token);
    }
    cask_prune_plan_from_tokens(&keep, &crate::dirs::STATE)
}

fn cask_prune_plan_from_tokens(keep: &BTreeSet<String>, state_dir: &Path) -> Result<CaskPrunePlan> {
    let mut plan = CaskPrunePlan::default();
    let mut candidates = Vec::new();
    let mut claims = BTreeMap::<PathBuf, BTreeSet<String>>::new();
    let mut claims_complete = true;
    let caskroom = prefix::prefix().join("Caskroom");
    let Ok(tokens) = std::fs::read_dir(&caskroom) else {
        return Ok(plan);
    };

    for entry in tokens {
        let entry = match entry {
            Ok(entry) => entry,
            Err(err) => {
                claims_complete = false;
                plan.skipped.push(CaskPruneSkip {
                    token: "Caskroom".to_string(),
                    reason: format!("Caskroom entry could not be read: {err}"),
                });
                continue;
            }
        };
        let kind = match entry.file_type() {
            Ok(kind) => kind,
            Err(err) => {
                claims_complete = false;
                plan.skipped.push(CaskPruneSkip {
                    token: entry.file_name().to_string_lossy().to_string(),
                    reason: format!("Caskroom entry type could not be read: {err}"),
                });
                continue;
            }
        };
        if !kind.is_dir() {
            continue;
        }
        let Some(token) = entry.file_name().to_str().map(str::to_string) else {
            claims_complete = false;
            plan.skipped.push(CaskPruneSkip {
                token: entry.file_name().to_string_lossy().to_string(),
                reason: "Caskroom token name is not valid UTF-8".to_string(),
            });
            continue;
        };
        if token.starts_with('.') {
            continue;
        }
        let configured = keep.contains(&token);
        let Ok(version_entries) = std::fs::read_dir(entry.path()) else {
            claims_complete = false;
            if !configured {
                plan.skipped.push(CaskPruneSkip {
                    token,
                    reason: "Caskroom directory could not be read".to_string(),
                });
            }
            continue;
        };
        let mut versions = Vec::new();
        let mut version_error = None;
        for version in version_entries {
            let version = match version {
                Ok(version) => version,
                Err(err) => {
                    claims_complete = false;
                    version_error =
                        Some(format!("Caskroom version entry could not be read: {err}"));
                    continue;
                }
            };
            let kind = match version.file_type() {
                Ok(kind) => kind,
                Err(err) => {
                    claims_complete = false;
                    version_error = Some(format!(
                        "Caskroom version entry type could not be read: {err}"
                    ));
                    continue;
                }
            };
            if kind.is_dir() && !version.file_name().to_string_lossy().starts_with('.') {
                versions.push(version);
            }
        }
        if let Some(reason) = version_error {
            if !configured {
                plan.skipped.push(CaskPruneSkip { token, reason });
            }
            continue;
        }
        let mut receipts = BTreeMap::new();
        let mut receipt_error = None;
        for version in &versions {
            match read_receipt(&version.path()) {
                Ok(Some(receipt)) => {
                    for target in &receipt.targets {
                        claims
                            .entry(target.path.clone())
                            .or_default()
                            .insert(token.clone());
                    }
                    receipts.insert(version.path(), receipt);
                }
                Ok(None) => {}
                Err(err) => {
                    claims_complete = false;
                    receipt_error =
                        Some(format!("mise ownership receipt could not be read: {err:#}"));
                }
            }
        }
        if let Some(reason) = receipt_error {
            if !configured {
                plan.skipped.push(CaskPruneSkip { token, reason });
            }
            continue;
        }
        if entry.path().join(".metadata").symlink_metadata().is_ok() {
            if !configured {
                plan.skipped.push(CaskPruneSkip {
                    token,
                    reason: "Homebrew owns this cask".to_string(),
                });
            }
            continue;
        }
        let [version] = versions.as_slice() else {
            if !configured {
                plan.skipped.push(CaskPruneSkip {
                    token,
                    reason: "expected exactly one installed Caskroom version".to_string(),
                });
            }
            continue;
        };
        let version_dir = version.path();
        let Some(receipt) = receipts.remove(&version_dir) else {
            if !configured {
                plan.skipped.push(CaskPruneSkip {
                    token,
                    reason: "mise ownership receipt is missing".to_string(),
                });
            }
            continue;
        };
        if configured {
            continue;
        }
        if cask_journal_pending_in(state_dir, &token) {
            plan.skipped.push(CaskPruneSkip {
                token,
                reason: "an incomplete cask transaction is pending".to_string(),
            });
            continue;
        }
        if receipt.schema_version != 3 {
            plan.skipped.push(CaskPruneSkip {
                token,
                reason: "receipt predates safe prune metadata; upgrade or reinstall first"
                    .to_string(),
            });
            continue;
        }
        if !receipt.prune_safe {
            let reason = receipt
                .prune_blocker
                .clone()
                .unwrap_or_else(|| "receipt does not permit pruning".to_string());
            plan.skipped.push(CaskPruneSkip { token, reason });
            continue;
        }
        let version = version.file_name().to_string_lossy().to_string();
        let candidate = CaskPruneCandidate {
            token,
            version,
            version_dir,
            receipt,
        };
        if let Err(reason) = validate_cask_prune_candidate(&candidate) {
            plan.skipped.push(CaskPruneSkip {
                token: candidate.token,
                reason: format!("recorded artifacts cannot be removed safely: {reason:#}"),
            });
            continue;
        }
        candidates.push(candidate);
    }

    for candidate in candidates {
        if !claims_complete {
            plan.skipped.push(CaskPruneSkip {
                token: candidate.token,
                reason: "cask ownership receipts could not be indexed completely".to_string(),
            });
            continue;
        }
        let shared = candidate
            .receipt
            .targets
            .iter()
            .filter_map(|target| {
                claims
                    .get(&target.path)
                    .filter(|tokens| tokens.len() > 1)
                    .map(|_| target.path.clone())
            })
            .collect::<Vec<_>>();
        if shared.is_empty() {
            plan.remove.push(candidate);
        } else {
            plan.skipped.push(CaskPruneSkip {
                token: candidate.token,
                reason: format!(
                    "recorded artifact target is also claimed by another cask: {}",
                    shared
                        .iter()
                        .map(|path| path.display().to_string())
                        .collect::<Vec<_>>()
                        .join(", ")
                ),
            });
        }
    }
    plan.remove.sort_by(|a, b| a.token.cmp(&b.token));
    plan.skipped.sort_by(|a, b| a.token.cmp(&b.token));
    Ok(plan)
}

pub fn apply_cask_prune_plan(plan: &CaskPrunePlan, dry_run: bool) -> Result<usize> {
    apply_cask_prune_plan_in(plan, dry_run, &crate::dirs::STATE)
}

fn apply_cask_prune_plan_in(
    plan: &CaskPrunePlan,
    dry_run: bool,
    state_dir: &Path,
) -> Result<usize> {
    if dry_run {
        for candidate in &plan.remove {
            miseprintln!("remove brew-cask:{}@{}", candidate.token, candidate.version);
        }
        return Ok(0);
    }

    let _caskroom_lock = lock_caskroom()?;
    let mut removed = 0;
    for candidate in &plan.remove {
        if let Err(reason) = validate_cask_prune_candidate(candidate)
            .and_then(|_| validate_cask_prune_claims(candidate))
        {
            warn!(
                "brew-cask:{}: skipped because recorded artifacts changed after planning: {reason:#}",
                candidate.token
            );
            continue;
        }
        let mut journal = CaskTransactionJournal {
            schema_version: 1,
            token: &candidate.token,
            version: &candidate.version,
            completed: Vec::new(),
        };
        write_cask_journal_in(state_dir, &journal)?;
        for (index, target) in candidate.receipt.targets.iter().enumerate() {
            remove_artifact_target_elevating(&target.path)?;
            record_cask_action_in(state_dir, &mut journal, &format!("prune_target[{index}]"))?;
        }
        file::remove_all(&candidate.version_dir)?;
        record_cask_action_in(state_dir, &mut journal, "prune_caskroom")?;
        if let Some(token_dir) = candidate.version_dir.parent() {
            file::remove_dir(token_dir)?;
        }
        remove_cask_journals_in(state_dir, &candidate.token)?;
        removed += 1;
    }
    Ok(removed)
}

fn lock_caskroom() -> Result<fslock::LockFile> {
    let caskroom = prefix::prefix().join("Caskroom");
    file::create_dir_all(&caskroom)?;
    let path = caskroom.join(".mise.lock");
    let mut lock = fslock::LockFile::open(&path)?;
    if !lock.try_lock()? {
        debug!("waiting for brew-cask lock on {}", path.display());
        lock.lock()?;
    }
    Ok(lock)
}

fn validate_cask_prune_claims(candidate: &CaskPruneCandidate) -> Result<()> {
    let caskroom = prefix::prefix().join("Caskroom");
    let mut claims = BTreeMap::<PathBuf, BTreeSet<String>>::new();
    for entry in std::fs::read_dir(&caskroom)? {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }
        let token = entry.file_name().to_string_lossy().to_string();
        for version in std::fs::read_dir(entry.path())? {
            let version = version?;
            if !version.file_type()?.is_dir()
                || version.file_name().to_string_lossy().starts_with('.')
            {
                continue;
            }
            if let Some(receipt) = read_receipt(&version.path())? {
                for target in receipt.targets {
                    claims.entry(target.path).or_default().insert(token.clone());
                }
            }
        }
    }
    for target in &candidate.receipt.targets {
        if claims
            .get(&target.path)
            .is_some_and(|tokens| tokens.iter().any(|token| token != &candidate.token))
        {
            bail!(
                "artifact target is now claimed by another cask: {}",
                target.path.display()
            );
        }
    }
    Ok(())
}

fn validate_cask_prune_candidate(candidate: &CaskPruneCandidate) -> Result<()> {
    if homebrew_metadata_present(&candidate.token) {
        bail!("Homebrew now owns this cask");
    }
    let receipt = &candidate.receipt;
    if read_receipt(&candidate.version_dir)?.as_ref() != Some(receipt) {
        bail!("ownership receipt has changed");
    }
    if receipt.schema_version != 3 || !receipt.prune_safe || !receipt.pkg_ids.is_empty() {
        bail!("receipt is not marked safe for direct-artifact pruning");
    }
    let records = receipt
        .targets
        .iter()
        .map(|record| (record.path.clone(), record))
        .collect::<BTreeMap<_, _>>();
    let expected = receipt
        .apps
        .iter()
        .chain(&receipt.binaries)
        .chain(&receipt.fonts)
        .chain(&receipt.completions)
        .cloned()
        .collect::<BTreeSet<_>>();
    if expected.is_empty()
        || records.len() != receipt.targets.len()
        || records.len() != expected.len()
    {
        bail!("receipt target inventory is incomplete or duplicated");
    }
    if records.keys().any(|path| !expected.contains(path)) {
        bail!("receipt target inventory contains an unclassified path");
    }

    for path in &receipt.apps {
        let record = records
            .get(path)
            .ok_or_else(|| eyre!("missing app target record"))?;
        if record.fingerprint.kind != CaskTargetKind::Directory
            || !allowed_appdir_roots()?
                .iter()
                .any(|root| path_is_below(path, root))
            || !path.file_name().is_some_and(|name| {
                staged_app_matches_target(record, &candidate.version_dir.join(name))
            })
        {
            bail!(
                "app target is outside an allowed Applications directory: {}",
                path.display()
            );
        }
    }
    for path in &receipt.binaries {
        let record = records
            .get(path)
            .ok_or_else(|| eyre!("missing binary target record"))?;
        if record.fingerprint.kind != CaskTargetKind::Symlink
            || !allowed_binary_target_roots()
                .iter()
                .any(|root| path_is_below(path, root))
            || !symlink_resolves_below(path, &candidate.version_dir)
        {
            bail!(
                "binary target is not an owned Caskroom symlink: {}",
                path.display()
            );
        }
    }
    for path in &receipt.fonts {
        let record = records
            .get(path)
            .ok_or_else(|| eyre!("missing font target record"))?;
        let fonts = font_dir();
        if record.fingerprint.kind != CaskTargetKind::File
            || !path_is_below(path, &fonts)
            || !path.strip_prefix(&fonts).is_ok_and(|relative| {
                staged_target_matches(record, &candidate.version_dir.join(relative))
            })
        {
            bail!(
                "font target is outside the platform font directory: {}",
                path.display()
            );
        }
    }
    let completion_roots = [
        CompletionShell::Bash,
        CompletionShell::Fish,
        CompletionShell::Zsh,
        CompletionShell::Pwsh,
    ]
    .map(default_completion_dir);
    for path in &receipt.completions {
        let record = records
            .get(path)
            .ok_or_else(|| eyre!("missing completion target record"))?;
        if record.fingerprint.kind != CaskTargetKind::Symlink
            || !completion_roots
                .iter()
                .any(|root| path_is_below(path, root))
            || !symlink_resolves_below(path, &candidate.version_dir)
        {
            bail!(
                "completion target is not an owned Caskroom symlink: {}",
                path.display()
            );
        }
    }
    for record in &receipt.targets {
        if !cask_target_record_matches(record)? {
            bail!("artifact target has changed: {}", record.path.display());
        }
    }
    Ok(())
}

fn path_is_below(path: &Path, root: &Path) -> bool {
    path.strip_prefix(root)
        .is_ok_and(|relative| relative.components().next().is_some())
}

fn staged_target_matches(record: &CaskTargetRecord, staged: &Path) -> bool {
    cask_target_fingerprint(staged).is_ok_and(|fingerprint| fingerprint == record.fingerprint)
}

fn staged_app_matches_target(record: &CaskTargetRecord, staged: &Path) -> bool {
    let Ok(metadata) = staged.symlink_metadata() else {
        return false;
    };
    if !metadata.file_type().is_symlink() {
        // Preserve pruning support for casks installed before app artifacts
        // switched from retained copies to Homebrew-compatible symlinks.
        return staged_target_matches(record, staged);
    }
    std::fs::read_link(staged)
        .map(|target| resolve_symlink_target(staged, target) == record.path)
        .unwrap_or(false)
}

fn symlink_resolves_below(path: &Path, root: &Path) -> bool {
    let Ok(target) = std::fs::read_link(path) else {
        return false;
    };
    let target = if target.is_absolute() {
        target
    } else {
        path.parent().unwrap_or_else(|| Path::new("/")).join(target)
    };
    path_starts_with_resolved_root(&target, root)
}

fn caskroom_token_dir(token: &str) -> PathBuf {
    prefix::prefix().join("Caskroom").join(token)
}

fn caskroom_version_dir(token: &str, version: &str) -> PathBuf {
    caskroom_token_dir(token).join(version)
}

fn caskroom_tmp_dir(cask: &Cask) -> PathBuf {
    let key = format!("{}-{}", cask.token, cask.version);
    caskroom_token_dir(&cask.token).join(format!(".mise-tmp-{}", hash::hash_to_str(&key)))
}

fn caskroom_backup_dir(cask: &Cask) -> PathBuf {
    let key = format!("{}-{}", cask.token, cask.version);
    caskroom_token_dir(&cask.token).join(format!(".mise-backup-{}", hash::hash_to_str(&key)))
}

#[derive(Debug)]
struct ArtifactLinkBackup {
    target: PathBuf,
    backup: Option<PathBuf>,
    target_parent: PathBuf,
    backup_parent: Option<PathBuf>,
    elevate: bool,
}

#[derive(Debug)]
struct ArtifactLinkTransaction {
    backups: Vec<ArtifactLinkBackup>,
}

impl ArtifactLinkTransaction {
    fn begin(mut targets: Vec<PathBuf>) -> Result<Self> {
        targets.sort();
        targets.dedup();
        let mut transaction = Self {
            backups: Vec::with_capacity(targets.len()),
        };
        for target in targets {
            let entry = (|| -> Result<ArtifactLinkBackup> {
                let backup = if target.symlink_metadata().is_ok() {
                    let parent = target
                        .parent()
                        .ok_or_else(|| eyre!("brew-cask: artifact target has no parent"))?;
                    let backup = parent.join(format!(
                        ".mise-link-backup-{}",
                        hash::hash_to_str(&target.display().to_string())
                    ));
                    remove_artifact_target_elevating(&backup)?;
                    rename_elevating(&target, &backup)?;
                    Some(backup)
                } else {
                    None
                };
                let target_parent = resolved_parent(&target)?;
                let backup_parent = backup.as_deref().map(resolved_parent).transpose()?;
                Ok(ArtifactLinkBackup {
                    target,
                    backup,
                    target_parent,
                    backup_parent,
                    elevate: true,
                })
            })();
            match entry {
                Ok(entry) => transaction.backups.push(entry),
                Err(err) => {
                    if let Err(rollback_err) = transaction.rollback() {
                        return Err(err.wrap_err(format!(
                            "failed to restore artifact targets after backup failed: {rollback_err:#}"
                        )));
                    }
                    return Err(err);
                }
            }
        }
        Ok(transaction)
    }

    fn rollback(&mut self) -> Result<()> {
        let mut first_error = None;
        for entry in self.backups.iter().rev() {
            match remove_artifact_target_elevating(&entry.target) {
                Ok(()) => {
                    if let Some(backup) = &entry.backup
                        && let Err(err) = rename_elevating(backup, &entry.target)
                    {
                        first_error.get_or_insert(err);
                    }
                }
                Err(err) => {
                    first_error.get_or_insert(err);
                }
            }
        }
        if let Some(err) = first_error {
            Err(err)
        } else {
            self.backups.clear();
            Ok(())
        }
    }

    fn commit(&mut self) -> Result<()> {
        for entry in &self.backups {
            if let Some(backup) = &entry.backup {
                remove_artifact_target_elevating(backup)?;
            }
        }
        self.backups.clear();
        Ok(())
    }
}

fn replace_caskroom(
    cask: &Cask,
    staged: &Path,
    destination: &Path,
    link_artifacts: impl FnOnce() -> Result<()>,
) -> Result<()> {
    let backup = caskroom_backup_dir(cask);
    file::remove_all(&backup)?;
    let had_previous = destination.symlink_metadata().is_ok();
    if had_previous {
        file::rename(destination, &backup)?;
    }
    if let Err(err) = file::rename(staged, destination) {
        if had_previous {
            file::rename(&backup, destination)?;
        }
        return Err(err);
    }
    if let Err(err) = link_artifacts() {
        let rollback = (|| -> Result<()> {
            file::remove_all(destination)?;
            if had_previous {
                file::rename(&backup, destination)?;
            }
            Ok(())
        })();
        if let Err(rollback_err) = rollback {
            return Err(err.wrap_err(format!(
                "failed to restore previous cask after activation failed: {rollback_err:#}"
            )));
        }
        return Err(err);
    }
    file::remove_all(backup)?;
    Ok(())
}

fn remove_stale_versions(token_dir: &Path, current_version: &str) -> Result<()> {
    let Ok(entries) = std::fs::read_dir(token_dir) else {
        return Ok(());
    };
    for entry in entries.filter_map(|entry| entry.ok()) {
        let name = entry.file_name();
        if entry.file_type().is_ok_and(|ft| ft.is_dir())
            && name.to_str() != Some(current_version)
            && name != ".metadata"
        {
            file::remove_all(entry.path())?;
        }
    }
    Ok(())
}

fn archive_filename(raw: &str) -> Option<String> {
    let url = url::Url::parse(raw).ok()?;
    url.path_segments()?.next_back().map(str::to_string)
}

fn split_tap_name(name: &str) -> Option<(&str, &str, &str)> {
    super::api::split_tap_name(name)
}

fn artifact_type(value: &Value) -> String {
    value
        .as_object()
        .and_then(|o| o.keys().next())
        .cloned()
        .unwrap_or_else(|| "unknown".to_string())
}

fn is_non_install_artifact(kind: &str) -> bool {
    matches!(
        kind,
        "caveats"
            | "conflicts_with"
            | "depends_on"
            | "manpage"
            | "postflight"
            | "preflight"
            | "uninstall_postflight_steps"
            | "uninstall_preflight_steps"
            | "uninstall"
            | "uninstall_postflight"
            | "uninstall_preflight"
            | "zap"
    )
}

fn has_lifecycle_hook(cask: &Cask, hook: &str) -> bool {
    cask.artifacts
        .iter()
        .any(|artifact| artifact_type(artifact) == hook)
}

#[cfg(test)]
mod tests {
    use super::*;
    #[cfg(unix)]
    use std::os::unix::fs::PermissionsExt;
    use std::sync::Mutex;

    use crate::test::EnvVarGuard;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    struct BrewPrefixGuard {
        previous: Option<String>,
    }

    impl BrewPrefixGuard {
        fn set(prefix: &Path) -> Self {
            let previous = crate::env::var("MISE_SYSTEM_BREW_PREFIX").ok();
            crate::env::set_var("MISE_SYSTEM_BREW_PREFIX", prefix);
            Self { previous }
        }
    }

    impl Drop for BrewPrefixGuard {
        fn drop(&mut self) {
            match &self.previous {
                Some(previous) => crate::env::set_var("MISE_SYSTEM_BREW_PREFIX", previous),
                None => crate::env::remove_var("MISE_SYSTEM_BREW_PREFIX"),
            }
        }
    }

    /// A temporary directory whose ancestors pass `ensure_trusted_appdir`'s
    /// trust checks.
    ///
    /// The system temp directory is unusable for these tests on Linux because
    /// `/tmp` is world-writable (mode 1777) and the trusted walk correctly
    /// refuses to operate through it. Real application directories
    /// (`/Applications`, `~/Applications`) are never world-writable, so anchor
    /// the fixture under the test home instead.
    fn trusted_tempdir() -> Result<tempfile::TempDir> {
        let base = &*crate::env::HOME;
        file::create_dir_all(base)?;
        Ok(tempfile::Builder::new()
            .prefix(".mise-cask-appdir-")
            .tempdir_in(base)?)
    }

    fn run_cask_shim(
        ruby: &Path,
        shim: &Path,
        cask: &Path,
        staged_path: &Path,
        version: &str,
    ) -> std::io::Result<std::process::Output> {
        run_cask_shim_hook(ruby, shim, cask, staged_path, version, "preflight")
    }

    fn run_cask_shim_hook(
        ruby: &Path,
        shim: &Path,
        cask: &Path,
        staged_path: &Path,
        version: &str,
        hook: &str,
    ) -> std::io::Result<std::process::Output> {
        std::process::Command::new(ruby)
            .arg(shim)
            .env("LANG", "zz_ZZ.UTF-8")
            .env("MISE_BREW_CASK_FILE", cask)
            .env("MISE_BREW_CASK_TOKEN", "example")
            .env("MISE_BREW_CASK_VERSION", version)
            .env("MISE_BREW_CASK_STAGED_PATH", staged_path)
            .env("MISE_BREW_CASK_APPDIR", staged_path)
            .env("MISE_BREW_PREFIX", staged_path)
            .env("MISE_BREW_CASK_HOOK", hook)
            .output()
    }

    fn test_cask(token: &str, version: &str) -> Cask {
        Cask {
            token: token.to_string(),
            aliases: Vec::new(),
            old_tokens: Vec::new(),
            version: version.to_string(),
            url: "https://example.com/example.zip".to_string(),
            url_specs: CaskUrlSpecs::default(),
            sha256: Some("no_check".to_string()),
            artifacts: Vec::new(),
            depends_on: CaskDependencies::default(),
            conflicts_with: CaskConflicts::default(),
            ruby_source_path: None,
            ruby_source_checksum: None,
            tap_git_head: None,
            raw_base: None,
        }
    }

    fn write_test_app_receipt(cask: &Cask, app_name: &str) -> Result<PathBuf> {
        let app = AppArtifact {
            source: app_name.to_string(),
            target: Some(format!("$HOMEBREW_PREFIX/Applications/{app_name}")),
        };
        let target = app_target_path(app.target_name())?;
        file::create_dir_all(&target)?;
        file::write(target.join("version"), "1.0.0")?;
        let version_dir = caskroom_version_dir(&cask.token, &cask.version);
        file::create_dir_all(version_dir.join(app_name))?;
        file::write(version_dir.join(app_name).join("version"), "1.0.0")?;
        write_receipt_with_flight_targets(
            &version_dir,
            cask,
            &CaskArtifacts {
                apps: vec![app],
                ..Default::default()
            },
            &[],
            &BTreeMap::new(),
            &[],
        )?;
        Ok(target)
    }

    #[test]
    fn validates_requested_cask_identity_and_trusted_aliases() -> Result<()> {
        let cask = test_cask("current", "1.0.0");
        validate_cask_identity(&cask, "current", true)?;
        assert!(validate_cask_identity(&cask, "different", true).is_err());

        let mut aliased = cask.clone();
        aliased.old_tokens = vec!["old-name".to_string()];
        validate_cask_identity(&aliased, "old-name", true)?;
        assert!(validate_cask_identity(&aliased, "old-name", false).is_err());
        Ok(())
    }

    #[test]
    fn rejects_unsafe_cask_identity_components() {
        for value in ["", ".", "..", ".metadata", ".mise-tmp-x", "a/b", "a\0b"] {
            assert!(validate_cask_path_component("token", value).is_err());
        }
        assert!(validate_cask_path_component("token", "zed@preview").is_ok());
        assert!(validate_cask_path_component("version", "1.2.3,456").is_ok());
    }

    #[test]
    fn detects_any_foreign_homebrew_metadata_object() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let metadata = tmp.path().join("Caskroom/example/.metadata");
        assert!(!homebrew_metadata_present("example"));
        file::create_dir_all(&metadata)?;
        assert!(homebrew_metadata_present("example"));
        file::remove_all(&metadata)?;
        crate::file::write(&metadata, "foreign")?;
        assert!(homebrew_metadata_present("example"));
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn directory_fingerprint_tracks_tree_content_and_links() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let root = tmp.path().join("Example.app");
        file::create_dir_all(root.join("Contents/Resources"))?;
        crate::file::write(root.join("Contents/app"), "one")?;
        crate::file::write(root.join("Contents/Resources/config"), "config")?;
        std::os::unix::fs::symlink("app", root.join("Contents/current"))?;
        let original = cask_target_fingerprint(&root)?;
        assert_eq!(original, cask_target_fingerprint(&root)?);

        crate::file::write(root.join("Contents/app"), "two")?;
        assert_ne!(original, cask_target_fingerprint(&root)?);
        crate::file::write(root.join("Contents/app"), "one")?;
        assert_eq!(original, cask_target_fingerprint(&root)?);

        crate::file::write(root.join("Contents/added"), "added")?;
        assert_ne!(original, cask_target_fingerprint(&root)?);
        file::remove_file(root.join("Contents/added"))?;
        assert_eq!(original, cask_target_fingerprint(&root)?);

        file::remove_file(root.join("Contents/current"))?;
        std::os::unix::fs::symlink("Resources/config", root.join("Contents/current"))?;
        assert_ne!(original, cask_target_fingerprint(&root)?);

        file::remove_all(&root)?;
        file::create_dir_all(root.join("Contents/Resources"))?;
        crate::file::write(root.join("Contents/app"), "replacement")?;
        assert_ne!(original, cask_target_fingerprint(&root)?);
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn staged_app_accepts_target_symlink_and_legacy_copy() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let target = tmp.path().join("Applications/Example.app");
        file::create_dir_all(target.join("Contents"))?;
        file::write(target.join("Contents/app"), "content")?;
        let record = CaskTargetRecord {
            path: target.clone(),
            fingerprint: cask_target_fingerprint(&target)?,
            uninstall: None,
        };

        let staged_link = tmp.path().join("Caskroom/example/1.0.0/Example.app");
        file::create_dir_all(staged_link.parent().unwrap())?;
        file::make_symlink(&target, &staged_link)?;
        assert!(staged_app_matches_target(&record, &staged_link));

        file::remove_file(&staged_link)?;
        file::copy_dir_all_preserve_symlinks(&target, &staged_link)?;
        assert!(staged_app_matches_target(&record, &staged_link));

        file::remove_all(&staged_link)?;
        file::make_symlink(&tmp.path().join("Applications/Other.app"), &staged_link)?;
        assert!(!staged_app_matches_target(&record, &staged_link));
        Ok(())
    }

    #[test]
    fn completed_receipt_detects_app_bundle_drift() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("example", "1.0.0");
        let app = AppArtifact {
            source: "Example.app".to_string(),
            target: Some("$HOMEBREW_PREFIX/Applications/Example.app".to_string()),
        };
        let artifacts = CaskArtifacts {
            apps: vec![app.clone()],
            ..Default::default()
        };
        let app_target = app_target_path(app.target_name())?;
        file::create_dir_all(app_target.join("Contents"))?;
        crate::file::write(app_target.join("Contents/app"), "original")?;
        let caskroom = caskroom_version_dir(&cask.token, &cask.version);
        file::create_dir_all(&caskroom)?;
        write_receipt_with_flight_targets(
            &caskroom,
            &cask,
            &artifacts,
            &[],
            &BTreeMap::new(),
            &[],
        )?;
        assert_eq!(
            installed_cask_version(&cask, &artifacts)?,
            Some(cask.version.clone())
        );
        crate::file::write(app_target.join("Contents/app"), "changed")?;
        assert_eq!(installed_cask_version(&cask, &artifacts)?, None);
        Ok(())
    }

    #[test]
    fn parses_firefox_command_wrapper_artifact() -> Result<()> {
        let mut cask = test_cask("firefox", "153.0.1");
        cask.artifacts = vec![
            serde_json::json!({
                "app": ["Firefox.app"],
                "target": "/Applications/Firefox.app"
            }),
            serde_json::json!({
                "command_wrapper": [
                    "firefox",
                    {"executable": "$APPDIR/Firefox.app/Contents/MacOS/firefox"}
                ],
                "target": "$HOMEBREW_PREFIX/bin/firefox"
            }),
        ];

        let artifacts = cask_artifacts(&cask)?;
        assert_eq!(
            artifacts.command_wrappers,
            vec![CommandWrapperArtifact {
                name: "firefox".to_string(),
                target: Some("$HOMEBREW_PREFIX/bin/firefox".to_string()),
                content: None,
                executable: Some("$APPDIR/Firefox.app/Contents/MacOS/firefox".to_string()),
                args: Vec::new(),
                env: BTreeMap::new(),
            }]
        );
        Ok(())
    }

    #[test]
    fn rejects_command_wrapper_invalid_environment_name() {
        let value = serde_json::json!({
            "command_wrapper": [
                "example",
                {
                    "executable": "/usr/bin/example",
                    "env": {"INVALID-NAME": "value"}
                }
            ]
        });

        let err = parse_command_wrapper_artifact(&value)
            .unwrap_err()
            .to_string();
        assert!(err.contains("invalid command_wrapper environment name 'INVALID-NAME'"));
    }

    #[test]
    #[cfg(unix)]
    fn stages_command_wrapper_with_args_env_and_expanded_paths() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let _guard = BrewPrefixGuard::set(&prefix);
        let cask = test_cask("firefox", "153.0.1");
        let caskroom = prefix.join("Caskroom/firefox/.mise-tmp");
        let final_caskroom = caskroom_version_dir(&cask.token, &cask.version);
        let appdir = tmp.path().join("Applications");
        let wrapper = CommandWrapperArtifact {
            name: "firefox".to_string(),
            target: Some("$HOMEBREW_PREFIX/bin/firefox".to_string()),
            content: None,
            executable: Some("$APPDIR/Firefox.app/Contents/MacOS/firefox".to_string()),
            args: vec![
                "--profile".to_string(),
                "two words".to_string(),
                "{{version}}".to_string(),
            ],
            env: BTreeMap::from([
                ("FIREFOX_MODE".to_string(), "mise test".to_string()),
                ("FIREFOX_ROOT".to_string(), "{{staged_path}}".to_string()),
            ]),
        };

        stage_command_wrapper(&caskroom, &appdir, &cask, &wrapper)?;
        file::rename(&caskroom, &final_caskroom)?;
        link_command_wrapper(&final_caskroom, &wrapper)?;

        let staged = final_caskroom.join(".homebrew-command-wrappers/firefox");
        let contents = file::read_to_string(&staged)?;
        assert!(contents.starts_with("#!/bin/bash\n"));
        assert!(contents.contains("FIREFOX_MODE='mise test'"));
        assert!(contents.contains(&format!(
            "FIREFOX_ROOT={}",
            final_caskroom.to_string_lossy()
        )));
        let executable = appdir.join("Firefox.app/Contents/MacOS/firefox");
        assert!(contents.contains(executable.to_string_lossy().as_ref()));
        assert!(contents.contains("--profile 'two words' 153.0.1 \"$@\""));
        assert!(staged.metadata()?.permissions().mode() & 0o111 != 0);
        assert_eq!(std::fs::read_link(wrapper.target_path()?)?, staged);
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn stages_command_wrapper_with_literal_content() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let _guard = BrewPrefixGuard::set(&prefix);
        let cask = test_cask("example", "1.0.0");
        let caskroom = prefix.join("Caskroom/example/1.0.0");
        let wrapper = CommandWrapperArtifact {
            name: "example".to_string(),
            target: None,
            content: Some(
                "#!/bin/sh\nHOME=$HOME\nSTAGE={{staged_path}}\nexec '$HOMEBREW_PREFIX/bin/example' \"$@\"\n"
                    .to_string(),
            ),
            executable: None,
            args: Vec::new(),
            env: BTreeMap::new(),
        };

        stage_command_wrapper(&caskroom, Path::new("/Applications"), &cask, &wrapper)?;

        assert_eq!(
            file::read_to_string(caskroom.join(".homebrew-command-wrappers/example"))?,
            format!(
                "#!/bin/sh\nHOME=$HOME\nSTAGE={{{{staged_path}}}}\nexec '{}/bin/example' \"$@\"\n",
                prefix.display()
            )
        );
        Ok(())
    }

    #[test]
    fn parses_structured_flight_steps() -> Result<()> {
        let mut cask = test_cask("wezterm@nightly", "latest");
        cask.artifacts = vec![
            serde_json::json!({
                "preflight_steps": [{
                    "steps": [
                        {
                            "type": "move",
                            "source_glob": true,
                            "source": {
                                "base": "staged_path",
                                "path": "{WezTerm-*,wezterm-*}/WezTerm.app"
                            },
                            "target": {
                                "base": "staged_path",
                                "path": "."
                            }
                        },
                        {
                            "type": "remove",
                            "recursive": true,
                            "paths": [
                                {"base": "staged_path", "path": "WezTerm-*"},
                                {"base": "staged_path", "path": "wezterm-*"}
                            ]
                        }
                    ]
                }]
            }),
            serde_json::json!({"app": "WezTerm.app"}),
        ];

        assert_eq!(
            cask_artifacts(&cask)?,
            CaskArtifacts {
                apps: vec![AppArtifact {
                    source: "WezTerm.app".to_string(),
                    target: None,
                }],
                preflight_steps: vec![
                    FlightStep::Move {
                        source: FlightPath {
                            base: FlightPathBase::StagedPath,
                            path: "{WezTerm-*,wezterm-*}/WezTerm.app".to_string(),
                        },
                        target: FlightPath {
                            base: FlightPathBase::StagedPath,
                            path: ".".to_string(),
                        },
                        source_glob: true,
                    },
                    FlightStep::Remove {
                        paths: vec![
                            FlightPath {
                                base: FlightPathBase::StagedPath,
                                path: "WezTerm-*".to_string(),
                            },
                            FlightPath {
                                base: FlightPathBase::StagedPath,
                                path: "wezterm-*".to_string(),
                            }
                        ],
                        recursive: true,
                    }
                ],
                ..Default::default()
            }
        );
        Ok(())
    }

    #[test]
    fn parses_orbstack_structured_run_step() -> Result<()> {
        let mut cask = test_cask("orbstack", "2.2.1,20628");
        cask.artifacts = vec![
            serde_json::json!({
                "app": ["OrbStack.app"],
                "target": "/Applications/OrbStack.app"
            }),
            serde_json::json!({
                "postflight_steps": [{
                    "steps": [{
                        "command": {
                            "base": "appdir",
                            "path": "OrbStack.app/Contents/MacOS/bin/orbctl"
                        },
                        "type": "run",
                        "args": ["_internal", "brew-postflight"]
                    }]
                }]
            }),
        ];

        let artifacts = cask_artifacts(&cask)?;
        assert_eq!(
            artifacts.postflight_steps,
            vec![FlightStep::Run {
                command: FlightPath {
                    base: FlightPathBase::AppDir,
                    path: "OrbStack.app/Contents/MacOS/bin/orbctl".to_string(),
                },
                args: vec!["_internal".to_string(), "brew-postflight".to_string()],
                env: BTreeMap::new(),
                sudo: false,
                guards: Vec::new(),
            }]
        );
        Ok(())
    }

    #[test]
    fn parses_structured_symlink_steps() -> Result<()> {
        let mut cask = test_cask("docker-desktop", "4.86.0,236216");
        cask.artifacts = vec![
            serde_json::json!({"app": "Docker.app"}),
            serde_json::json!({
                "postflight_steps": [{
                    "steps": [{
                        "type": "symlink",
                        "source": {"path": "{{appdir}}/Docker.app/Contents/Resources/bin/kubectl"},
                        "target": {"path": "/usr/local/bin/kubectl"},
                        "force": true,
                        "uninstall": true,
                        "sudo": "if_needed",
                        "guards": [{
                            "condition": "unless_exists",
                            "path": "/usr/local/bin/kubectl",
                            "id": "1"
                        }]
                    }]
                }]
            }),
        ];

        let artifacts = cask_artifacts(&cask)?;
        assert!(matches!(
            artifacts.postflight_steps.as_slice(),
            [FlightStep::Symlink {
                force: true,
                uninstall: true,
                sudo: FlightSudo::IfNeeded,
                guards,
                ..
            }] if guards.len() == 1
        ));
        Ok(())
    }

    #[test]
    fn parses_gcloud_copy_installer_and_run_metadata() -> Result<()> {
        let mut cask = test_cask("gcloud-cli", "580.0.0");
        cask.artifacts = vec![
            serde_json::json!({
                "preflight_steps": [{"steps": [{
                    "type": "copy",
                    "source": {"base": "staged_path", "path": "google-cloud-sdk/."},
                    "target": {"base": "homebrew_prefix", "path": "share/google-cloud-sdk"},
                    "recursive": true
                }]}]
            }),
            serde_json::json!({
                "installer": [{"script": {
                    "executable": "google-cloud-sdk/install.sh",
                    "args": ["--quiet", "--install-python", "false"]
                }}]
            }),
            serde_json::json!({"binary": "google-cloud-sdk/bin/gcloud"}),
            serde_json::json!({
                "postflight_steps": [{"steps": [{
                    "type": "run",
                    "command": {"base": "homebrew_prefix", "path": "share/google-cloud-sdk/bin/gcloud"},
                    "args": ["version"],
                    "network_access": true
                }]}]
            }),
        ];

        let artifacts = cask_artifacts(&cask)?;
        assert!(matches!(
            artifacts.preflight_steps.as_slice(),
            [FlightStep::Copy {
                recursive: true,
                overwrite: true,
                ..
            }]
        ));
        assert_eq!(
            artifacts.installers,
            [InstallerArtifact {
                executable: "google-cloud-sdk/install.sh".to_string(),
                args: vec![
                    "--quiet".to_string(),
                    "--install-python".to_string(),
                    "false".to_string()
                ],
            }]
        );
        assert!(matches!(
            artifacts.postflight_steps.as_slice(),
            [FlightStep::Run { .. }]
        ));
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn installer_script_is_made_executable_before_running() -> Result<()> {
        use std::os::unix::fs::PermissionsExt;

        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("prefix");
        let _guard = BrewPrefixGuard::set(&prefix);
        file::create_dir_all(prefix.join("bin"))?;
        file::create_dir_all(prefix.join("sbin"))?;
        let stage = tmp.path().join("stage");
        file::create_dir_all(&stage)?;
        let script = stage.join("install.sh");
        let marker = tmp.path().join("installed");
        file::write(&script, "#!/bin/sh\nprintf '%s' \"$PATH\" > \"$1\"\n")?;
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o644))?;
        let installer = InstallerArtifact {
            executable: "install.sh".to_string(),
            args: vec![marker.display().to_string()],
        };

        run_installer_artifact(&stage, &installer, &BTreeSet::new())?;

        let installed_path = file::read_to_string(marker)?;
        let installed_paths = std::env::split_paths(std::ffi::OsStr::new(&installed_path))
            .take(2)
            .collect::<Vec<_>>();
        assert_eq!(installed_paths, [prefix.join("bin"), prefix.join("sbin")]);
        assert_ne!(script.metadata()?.permissions().mode() & 0o111, 0);
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn installer_script_rejects_paths_outside_stage() -> Result<()> {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir()?;
        let stage = tmp.path().join("stage");
        file::create_dir_all(&stage)?;
        let outside = tmp.path().join("outside.sh");
        file::write(&outside, "#!/bin/sh\nexit 0\n")?;
        std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o644))?;
        file::make_symlink(&outside, &stage.join("linked.sh"))?;

        for executable in [
            outside.display().to_string(),
            "../outside.sh".to_string(),
            "linked.sh".to_string(),
        ] {
            let err = run_installer_artifact(
                &stage,
                &InstallerArtifact {
                    executable,
                    args: Vec::new(),
                },
                &BTreeSet::new(),
            )
            .unwrap_err()
            .to_string();

            assert!(err.contains("outside trusted installer roots"));
            assert_eq!(outside.metadata()?.permissions().mode() & 0o111, 0);
        }
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn installer_script_accepts_preflight_copied_root() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("prefix");
        let _guard = BrewPrefixGuard::set(&prefix);
        file::create_dir_all(prefix.join("bin"))?;
        file::create_dir_all(prefix.join("sbin"))?;
        let stage = tmp.path().join("stage");
        file::create_dir_all(&stage)?;
        let copied = prefix.join("share/example");
        file::create_dir_all(&copied)?;
        let marker = tmp.path().join("installed");
        file::write(
            copied.join("install.sh"),
            "#!/bin/sh\nprintf installed > \"$1\"\n",
        )?;
        file::make_symlink(&copied, &stage.join("payload"))?;

        let copied_files = BTreeSet::from([file::desymlink_path(&copied.join("install.sh"))]);
        run_installer_artifact(
            &stage,
            &InstallerArtifact {
                executable: "payload/install.sh".to_string(),
                args: vec![marker.display().to_string()],
            },
            &copied_files,
        )?;

        assert_eq!(file::read_to_string(marker)?, "installed");
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn installer_script_rejects_unrecorded_file_beneath_copied_target() -> Result<()> {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir()?;
        let stage = tmp.path().join("stage");
        file::create_dir_all(&stage)?;
        let broad_target = tmp.path().join("prefix");
        file::create_dir_all(broad_target.join("bin"))?;
        let outside = broad_target.join("bin/existing.sh");
        file::write(&outside, "#!/bin/sh\nexit 0\n")?;
        std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o644))?;
        file::make_symlink(&broad_target, &stage.join("payload"))?;
        let copied_files = BTreeSet::from([broad_target.join("copied.txt")]);

        let err = run_installer_artifact(
            &stage,
            &InstallerArtifact {
                executable: "payload/bin/existing.sh".to_string(),
                args: Vec::new(),
            },
            &copied_files,
        )
        .unwrap_err()
        .to_string();

        assert!(err.contains("outside trusted installer roots"));
        assert_eq!(outside.metadata()?.permissions().mode() & 0o111, 0);
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn installer_mutations_are_included_in_durable_symlink_sources() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("prefix");
        let _guard = BrewPrefixGuard::set(&prefix);
        file::create_dir_all(prefix.join("bin"))?;
        file::create_dir_all(prefix.join("sbin"))?;
        let stage = tmp.path().join("stage");
        let source = stage.join("payload");
        file::create_dir_all(&source)?;
        let script = stage.join("install.sh");
        file::write(&script, "#!/bin/sh\nprintf mutated > \"$1\"\n")?;
        let installer = InstallerArtifact {
            executable: "install.sh".to_string(),
            args: vec![source.join("generated").display().to_string()],
        };
        let target = tmp.path().join("share/example");
        file::create_dir_all(target.parent().unwrap())?;
        file::make_symlink(&source, &target)?;
        let mut targets = FlightTargetTransaction::default();
        targets.record_installed(target);
        let temporary_caskroom = tmp.path().join("Caskroom/example/.mise-tmp");

        run_installers_before_durabilizing(
            &stage,
            &temporary_caskroom,
            &[installer],
            &mut targets,
            |_| Ok(()),
        )?;

        assert_eq!(
            file::read_to_string(temporary_caskroom.join(".homebrew-staged/payload/generated"))?,
            "mutated"
        );
        Ok(())
    }

    #[test]
    fn structured_copy_restores_external_target_without_status_tracking() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("prefix");
        let _guard = BrewPrefixGuard::set(&prefix);
        let stage = tmp.path().join("stage");
        let source = stage.join("google-cloud-sdk");
        file::create_dir_all(&source)?;
        file::write(source.join("gcloud"), "sdk")?;
        let target = prefix.join("share/google-cloud-sdk");
        file::create_dir_all(&target)?;
        file::write(target.join("old"), "old")?;
        let step = FlightStep::Copy {
            source: FlightPath {
                base: FlightPathBase::StagedPath,
                path: "google-cloud-sdk/.".to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::HomebrewPrefix,
                path: "share/google-cloud-sdk".to_string(),
            },
            recursive: true,
            overwrite: true,
            source_glob: false,
            guards: Vec::new(),
        };
        let mut targets = FlightTargetTransaction::default();
        execute_flight_steps_with_completion(
            &test_cask("gcloud-cli", "580.0.0"),
            &[step],
            &stage,
            Path::new("/Applications"),
            "preflight_steps",
            &mut targets,
            |_, _| Ok(()),
        )?;
        assert!(target.join("gcloud").is_file());
        assert!(!target.join("old").exists());
        assert!(targets.installed_targets().is_empty());
        assert_eq!(
            targets.copied_files(),
            &BTreeSet::from([file::desymlink_path(&target.join("gcloud"))])
        );
        targets.rollback()?;
        assert_eq!(file::read_to_string(target.join("old"))?, "old");
        Ok(())
    }

    #[test]
    fn structured_copy_rollback_removes_target_with_created_parent() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("prefix");
        let _guard = BrewPrefixGuard::set(&prefix);
        let stage = tmp.path().join("stage");
        let source = stage.join("payload");
        file::create_dir_all(&source)?;
        file::write(source.join("installed"), "content")?;
        let target = prefix.join("share/new-parent/payload");
        let copy = FlightStep::Copy {
            source: FlightPath {
                base: FlightPathBase::StagedPath,
                path: "payload/.".to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::HomebrewPrefix,
                path: "share/new-parent/payload".to_string(),
            },
            recursive: true,
            overwrite: true,
            source_glob: false,
            guards: Vec::new(),
        };
        let fail = FlightStep::Copy {
            source: FlightPath {
                base: FlightPathBase::StagedPath,
                path: "missing".to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::HomebrewPrefix,
                path: "share/unused".to_string(),
            },
            recursive: false,
            overwrite: true,
            source_glob: false,
            guards: Vec::new(),
        };

        let err = execute_flight_steps(
            &test_cask("example", "1.0.0"),
            &[copy, fail],
            &stage,
            Path::new("/Applications"),
            "preflight_steps",
        )
        .unwrap_err();

        assert!(format!("{err:#}").contains("was not found"));
        assert!(target.symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    fn cask_metadata_accepts_null_dependencies_and_conflicts() -> Result<()> {
        let cask: Cask = serde_json::from_value(serde_json::json!({
            "token": "example",
            "version": "1.0.0",
            "url": "https://example.com/example.zip",
            "depends_on": null,
            "conflicts_with": null
        }))?;
        assert!(cask.depends_on.formula.is_empty());
        assert!(cask.conflicts_with.cask.is_empty());
        Ok(())
    }

    #[test]
    fn structured_symlink_preserves_relative_source() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let appdir = tmp.path().join("Applications");
        let target = appdir.join("MeshLab2025.07.app/Contents/MacOS/MeshLab");
        file::create_dir_all(
            target
                .parent()
                .ok_or_else(|| eyre!("missing target parent"))?,
        )?;
        let step = FlightStep::Symlink {
            source: FlightPath {
                base: FlightPathBase::Literal,
                path: "meshlab".to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::AppDir,
                path: "MeshLab{{version}}.app/Contents/MacOS/MeshLab".to_string(),
            },
            force: false,
            uninstall: false,
            source_glob: false,
            sudo: FlightSudo::Never,
            guards: vec![FlightGuard::UnlessExists(FlightPath {
                base: FlightPathBase::Literal,
                path: target.to_string_lossy().to_string(),
            })],
        };

        execute_flight_steps(
            &test_cask("meshlab", "2025.07"),
            &[step],
            tmp.path(),
            &appdir,
            "postflight_steps",
        )?;

        assert_eq!(std::fs::read_link(target)?, PathBuf::from("meshlab"));
        Ok(())
    }

    #[test]
    fn based_flight_paths_reject_root_escapes() {
        let cask = test_cask("example", "1.0.0");
        let staged = Path::new("/tmp/staged");
        let appdir = Path::new("/Applications");
        for base in [
            FlightPathBase::StagedPath,
            FlightPathBase::AppDir,
            FlightPathBase::HomebrewPrefix,
        ] {
            for path in ["../outside", "/absolute/outside"] {
                let err = resolve_flight_path_with_context(
                    &cask,
                    &FlightPath {
                        base,
                        path: path.to_string(),
                    },
                    staged,
                    appdir,
                )
                .unwrap_err()
                .to_string();
                assert!(err.contains("invalid structured flight path"));
            }
        }
    }

    #[test]
    #[cfg(unix)]
    fn structured_symlink_expands_versioned_glob() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let source_dir = tmp.path().join("tool-2/bin");
        file::create_dir_all(&source_dir)?;
        file::write(source_dir.join("tool"), "binary")?;
        let target_dir = tmp.path().join("links");
        file::create_dir_all(&target_dir)?;
        let step = FlightStep::Symlink {
            source: FlightPath {
                base: FlightPathBase::StagedPath,
                path: "tool-{{version.major}}/bin/*".to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::Literal,
                path: target_dir.to_string_lossy().to_string(),
            },
            force: false,
            uninstall: false,
            source_glob: true,
            sudo: FlightSudo::Never,
            guards: Vec::new(),
        };

        execute_flight_steps(
            &test_cask("tool", "2.3.4"),
            &[step],
            tmp.path(),
            Path::new("/Applications"),
            "postflight_steps",
        )?;

        let link = target_dir.join("tool");
        assert_eq!(
            lexically_normalized_path(&resolve_symlink_target(&link, std::fs::read_link(&link)?)),
            source_dir.join("tool")
        );
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn structured_symlink_glob_rollback_removes_created_directory() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let staged = tmp.path().join("stage");
        file::create_dir_all(&staged)?;
        file::write(staged.join("one"), "one")?;
        file::write(staged.join("two"), "two")?;
        let target = tmp.path().join("links");
        let step = FlightStep::Symlink {
            source: FlightPath {
                base: FlightPathBase::StagedPath,
                path: "*".to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::Literal,
                path: target.to_string_lossy().to_string(),
            },
            force: false,
            uninstall: false,
            source_glob: true,
            sudo: FlightSudo::Never,
            guards: Vec::new(),
        };
        let mut targets = FlightTargetTransaction::default();

        execute_flight_step(
            &test_cask("example", "1.0.0"),
            &step,
            &staged,
            Path::new("/Applications"),
            &mut targets,
        )?;
        assert!(target.is_dir());
        assert_eq!(
            targets.installed_directories(),
            std::slice::from_ref(&target)
        );
        assert!(!targets.installed_targets().contains(&target));
        assert_eq!(
            targets.uninstall_targets(),
            &BTreeMap::from([(target.join("one"), false), (target.join("two"), false),])
        );

        targets.rollback()?;

        assert!(target.symlink_metadata().is_err());

        file::create_dir_all(&target)?;
        let mut upgrade_targets = FlightTargetTransaction::default();
        upgrade_targets.previous_directories.insert(target.clone());
        execute_flight_step(
            &test_cask("example", "2.0.0"),
            &step,
            &staged,
            Path::new("/Applications"),
            &mut upgrade_targets,
        )?;
        assert_eq!(upgrade_targets.installed_directories(), [target]);
        upgrade_targets.commit()?;
        Ok(())
    }

    #[test]
    fn obsolete_flight_directories_remove_only_empty_unclaimed_directories() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let empty = tmp.path().join("empty");
        let occupied = tmp.path().join("occupied");
        let current = tmp.path().join("current");
        for directory in [&empty, &occupied, &current] {
            file::create_dir_all(directory)?;
        }
        file::write(occupied.join("user-file"), "keep")?;
        let previous = BTreeSet::from([empty.clone(), occupied.clone(), current.clone()]);

        remove_obsolete_flight_directories(&previous, std::slice::from_ref(&current))?;

        assert!(empty.symlink_metadata().is_err());
        assert!(occupied.join("user-file").is_file());
        assert!(current.is_dir());
        Ok(())
    }

    #[test]
    fn structured_symlink_rejects_empty_glob() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let target = tmp.path().join("links");
        let step = FlightStep::Symlink {
            source: FlightPath {
                base: FlightPathBase::StagedPath,
                path: "missing/*".to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::Literal,
                path: target.to_string_lossy().to_string(),
            },
            force: false,
            uninstall: false,
            source_glob: true,
            sudo: FlightSudo::Never,
            guards: Vec::new(),
        };

        let err = execute_flight_steps(
            &test_cask("tool", "1.0.0"),
            &[step],
            tmp.path(),
            Path::new("/Applications"),
            "postflight_steps",
        )
        .unwrap_err();
        let err = format!("{err:#}");

        assert!(err.contains("did not match any paths"));
        assert!(target.symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn structured_symlink_glob_replaces_dangling_target() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let source_dir = tmp.path().join("bin");
        file::create_dir_all(&source_dir)?;
        file::write(source_dir.join("one"), "one")?;
        file::write(source_dir.join("two"), "two")?;
        let target = tmp.path().join("links");
        file::make_symlink(Path::new("missing"), &target)?;
        let step = FlightStep::Symlink {
            source: FlightPath {
                base: FlightPathBase::StagedPath,
                path: "bin/*".to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::Literal,
                path: target.to_string_lossy().to_string(),
            },
            force: false,
            uninstall: false,
            source_glob: true,
            sudo: FlightSudo::Never,
            guards: Vec::new(),
        };

        execute_flight_steps(
            &test_cask("tool", "1.0.0"),
            &[step],
            tmp.path(),
            Path::new("/Applications"),
            "postflight_steps",
        )?;

        assert!(target.is_dir());
        for name in ["one", "two"] {
            let link = target.join(name);
            assert_eq!(
                lexically_normalized_path(&resolve_symlink_target(
                    &link,
                    std::fs::read_link(&link)?
                )),
                source_dir.join(name)
            );
        }
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn structured_symlink_replaces_directory_symlink_without_following_it() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let source = tmp.path().join("source");
        let unrelated = tmp.path().join("unrelated");
        let target = tmp.path().join("target");
        file::write(&source, "source")?;
        file::create_dir_all(&unrelated)?;
        file::make_symlink(&unrelated, &target)?;
        let step = FlightStep::Symlink {
            source: FlightPath {
                base: FlightPathBase::Literal,
                path: source.to_string_lossy().to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::Literal,
                path: target.to_string_lossy().to_string(),
            },
            force: true,
            uninstall: false,
            source_glob: false,
            sudo: FlightSudo::Never,
            guards: Vec::new(),
        };

        execute_flight_steps(
            &test_cask("tool", "1.0.0"),
            &[step],
            tmp.path(),
            Path::new("/Applications"),
            "postflight_steps",
        )?;

        assert_eq!(
            lexically_normalized_path(&resolve_symlink_target(
                &target,
                std::fs::read_link(&target)?
            )),
            source
        );
        assert!(std::fs::read_dir(unrelated)?.next().is_none());
        Ok(())
    }

    #[test]
    fn forced_symlink_command_uses_replacement_flags() {
        let no_dereference = if cfg!(target_os = "macos") {
            "-h"
        } else {
            "-n"
        };
        assert_eq!(
            symlink_command_args(Path::new("source"), Path::new("target")),
            ["-s", "-f", no_dereference, "--", "source", "target"]
        );
    }

    #[test]
    #[cfg(unix)]
    fn flight_guards_match_homebrew_for_dangling_symlinks() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let dangling = tmp.path().join("dangling");
        std::os::unix::fs::symlink("missing", &dangling)?;
        let cask = test_cask("example", "1.0.0");
        let path = FlightPath {
            base: FlightPathBase::Literal,
            path: dangling.to_string_lossy().to_string(),
        };

        assert!(!flight_guard_matches(
            &cask,
            &FlightGuard::IfExists(path.clone()),
            tmp.path(),
            Path::new("/Applications"),
        )?);
        assert!(flight_guard_matches(
            &cask,
            &FlightGuard::UnlessExists(path),
            tmp.path(),
            Path::new("/Applications"),
        )?);
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn structured_symlink_replaces_dangling_target_without_force() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let source = tmp.path().join("source");
        let target = tmp.path().join("target");
        file::write(&source, "source")?;
        file::make_symlink(Path::new("missing"), &target)?;
        let step = FlightStep::Symlink {
            source: FlightPath {
                base: FlightPathBase::Literal,
                path: source.to_string_lossy().to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::Literal,
                path: target.to_string_lossy().to_string(),
            },
            force: false,
            uninstall: false,
            source_glob: false,
            sudo: FlightSudo::Never,
            guards: vec![FlightGuard::UnlessExists(FlightPath {
                base: FlightPathBase::Literal,
                path: target.to_string_lossy().to_string(),
            })],
        };

        execute_flight_steps(
            &test_cask("example", "1.0.0"),
            &[step],
            tmp.path(),
            Path::new("/Applications"),
            "postflight_steps",
        )?;

        assert_eq!(
            lexically_normalized_path(&resolve_symlink_target(
                &target,
                std::fs::read_link(&target)?
            )),
            source
        );
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn structured_symlink_replaces_previous_owned_target_without_force() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let stage = tmp.path().join("stage");
        let old_source = tmp.path().join("old");
        let new_source = stage.join("new");
        let target = tmp.path().join("target");
        file::create_dir_all(&stage)?;
        file::write(&old_source, "old")?;
        file::write(&new_source, "new")?;
        file::make_symlink(&old_source, &target)?;
        let step = FlightStep::Symlink {
            source: FlightPath {
                base: FlightPathBase::StagedPath,
                path: "new".to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::Literal,
                path: target.to_string_lossy().to_string(),
            },
            force: false,
            uninstall: false,
            source_glob: false,
            sudo: FlightSudo::Never,
            guards: Vec::new(),
        };
        let mut targets = FlightTargetTransaction::default();
        targets.previous_symlinks.insert(target.clone());

        execute_flight_step(
            &test_cask("example", "2.0.0"),
            &step,
            &stage,
            Path::new("/Applications"),
            &mut targets,
        )?;
        assert_eq!(std::fs::read_link(&target)?, new_source);

        targets.rollback()?;
        assert_eq!(std::fs::read_link(target)?, old_source);
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn structured_symlink_rollback_removes_link_with_created_parent() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let stage = tmp.path().join("stage");
        let source = stage.join("source");
        let target = tmp.path().join("external/nested/target");
        file::create_dir_all(&stage)?;
        file::create_dir_all(tmp.path().join("external"))?;
        file::write(&source, "source")?;
        let step = FlightStep::Symlink {
            source: FlightPath {
                base: FlightPathBase::StagedPath,
                path: "source".to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::Literal,
                path: target.to_string_lossy().to_string(),
            },
            force: false,
            uninstall: false,
            source_glob: false,
            sudo: FlightSudo::Never,
            guards: Vec::new(),
        };
        let mut targets = FlightTargetTransaction::default();

        execute_flight_step(
            &test_cask("example", "1.0.0"),
            &step,
            &stage,
            Path::new("/Applications"),
            &mut targets,
        )?;
        assert!(target.symlink_metadata()?.file_type().is_symlink());

        targets.rollback()?;
        assert!(target.symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn structured_symlink_force_refuses_to_replace_directory() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let source = tmp.path().join("source");
        let target = tmp.path().join("target");
        let link = target.join("source");
        file::write(&source, "source")?;
        file::create_dir_all(&link)?;
        file::write(link.join("keep"), "keep")?;
        let step = FlightStep::Symlink {
            source: FlightPath {
                base: FlightPathBase::Literal,
                path: source.to_string_lossy().to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::Literal,
                path: target.to_string_lossy().to_string(),
            },
            force: true,
            uninstall: false,
            source_glob: false,
            sudo: FlightSudo::Never,
            guards: Vec::new(),
        };

        let err = execute_flight_steps(
            &test_cask("example", "1.0.0"),
            &[step],
            tmp.path(),
            Path::new("/Applications"),
            "postflight_steps",
        )
        .unwrap_err();

        assert!(format!("{err:#}").contains("refusing to replace structured symlink directory"));
        assert_eq!(file::read_to_string(link.join("keep"))?, "keep");
        Ok(())
    }

    #[test]
    fn flight_target_transaction_restores_replaced_target() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let target = tmp.path().join("target");
        file::write(&target, "original")?;
        {
            let mut transaction = FlightTargetTransaction::default();
            transaction.protect(&target)?;
            let backup = transaction.backups[0].backup.as_ref().unwrap();
            let recovery = flight_backup_recovery_path(backup);
            assert!(recovery.is_file());
            assert_ne!(recovery.parent(), backup.parent());
            file::write(&target, "replacement")?;
        }
        assert_eq!(file::read_to_string(target)?, "original");
        Ok(())
    }

    #[test]
    fn flight_target_transaction_retries_failed_restore() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let target = tmp.path().join("missing/target");
        let backup = tmp.path().join("backup");
        file::write(&backup, "original")?;
        let recovery = flight_backup_recovery_path(&backup);
        let target_parent = resolved_parent(&target)?;
        let backup_parent = Some(resolved_parent(&backup)?);
        let record = FlightRecoveryRecord {
            target: target.clone(),
            backup: Some(backup.clone()),
            target_parent: target_parent.clone(),
            backup_parent: backup_parent.clone(),
            receipt_caskroom: None,
            elevate: true,
        };
        write_durable_file(&recovery, &serde_json::to_vec_pretty(&record)?)?;
        let mut transaction = FlightTargetTransaction {
            backups: vec![ArtifactLinkBackup {
                target: target.clone(),
                backup: Some(backup.clone()),
                target_parent,
                backup_parent,
                elevate: true,
            }],
            receipt_caskroom: None,
            installed: Vec::new(),
            uninstall: BTreeMap::new(),
            previous_symlinks: BTreeSet::new(),
            copied_files: BTreeSet::new(),
            previous_directories: BTreeSet::new(),
            installed_directories: Vec::new(),
            committed: false,
        };

        assert!(transaction.rollback().is_err());
        assert_eq!(transaction.backups.len(), 1);
        assert!(backup.is_file());
        assert!(recovery.is_file());

        file::create_dir_all(target.parent().unwrap())?;
        transaction.rollback()?;
        assert!(transaction.backups.is_empty());
        assert_eq!(file::read_to_string(target)?, "original");
        assert!(recovery.symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    fn recovers_interrupted_flight_target_transaction() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let target = tmp.path().join("target");
        file::write(&target, "original")?;
        let mut transaction = FlightTargetTransaction::default();
        transaction.protect(&target)?;
        let backup = transaction.backups[0].backup.as_ref().unwrap();
        let recovery = flight_backup_recovery_path(backup);
        std::mem::forget(transaction);

        recover_flight_backup(&recovery)?;

        assert_eq!(file::read_to_string(&target)?, "original");
        assert!(recovery.symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    fn recovers_interrupted_new_flight_target() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let target = tmp.path().join("target");
        let mut transaction = FlightTargetTransaction::default();
        transaction.protect(&target)?;
        let recovery = flight_absent_recovery_path(&target);
        assert!(recovery.is_file());
        file::make_symlink(Path::new("source"), &target)?;
        std::mem::forget(transaction);

        recover_flight_backup(&recovery)?;

        assert!(target.symlink_metadata().is_err());
        assert!(recovery.symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    fn interrupted_new_flight_target_preserves_completed_receipt() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let target = tmp.path().join("target");
        let caskroom = tmp.path().join("Caskroom/example/1.0.0");
        file::create_dir_all(&caskroom)?;
        let mut transaction = FlightTargetTransaction::default();
        transaction.receipt_caskroom = Some(caskroom.clone());
        transaction.protect(&target)?;
        let recovery = flight_absent_recovery_path(&target);
        file::make_symlink(Path::new("source"), &target)?;
        let receipt = CaskReceipt {
            schema_version: 3,
            version: "1.0.0".to_string(),
            apps: Vec::new(),
            binaries: Vec::new(),
            fonts: Vec::new(),
            completions: Vec::new(),
            flight_directories: Vec::new(),
            generic: Vec::new(),
            pkg_ids: Vec::new(),
            targets: vec![CaskTargetRecord {
                path: target.clone(),
                fingerprint: cask_target_fingerprint(&target)?,
                uninstall: Some(true),
            }],
            prune_safe: false,
            prune_blocker: None,
        };
        file::write(
            caskroom.join(".mise-cask.toml"),
            toml::to_string_pretty(&receipt)?,
        )?;
        std::mem::forget(transaction);

        recover_flight_backup(&recovery)?;

        assert!(target.is_symlink());
        assert!(recovery.symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    fn interrupted_flight_recovery_preserves_recreated_target_and_backup() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let target = tmp.path().join("target");
        file::write(&target, "original")?;
        let mut transaction = FlightTargetTransaction::default();
        transaction.protect(&target)?;
        let backup = transaction.backups[0].backup.as_ref().unwrap().clone();
        let recovery = flight_backup_recovery_path(&backup);
        file::write(&target, "recreated")?;
        std::mem::forget(transaction);

        recover_flight_backup(&recovery)?;

        assert_eq!(file::read_to_string(&target)?, "recreated");
        assert_eq!(file::read_to_string(&backup)?, "original");
        assert!(recovery.is_file());

        let mut retry = FlightTargetTransaction::default();
        let err = retry.protect(&target).unwrap_err().to_string();
        assert!(err.contains("unresolved recovery"));
        assert_eq!(file::read_to_string(&backup)?, "original");

        file::remove_all(&target)?;
        recover_flight_backup(&recovery)?;
        assert_eq!(file::read_to_string(&target)?, "original");
        assert!(backup.symlink_metadata().is_err());
        assert!(recovery.symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    fn invalid_flight_recovery_does_not_block_later_installs() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let target = tmp.path().join("missing/target");
        let backup = tmp.path().join("backup");
        file::write(&backup, "original")?;
        let recovery = flight_backup_recovery_path(&backup);
        let record = FlightRecoveryRecord {
            target,
            backup: Some(backup.clone()),
            target_parent: tmp.path().join("unexpected-target-parent"),
            backup_parent: Some(resolved_parent(&backup)?),
            receipt_caskroom: None,
            elevate: true,
        };
        write_durable_file(&recovery, &serde_json::to_vec_pretty(&record)?)?;

        recover_flight_backup_or_warn(&recovery);

        assert!(recovery.is_file());
        assert_eq!(file::read_to_string(backup)?, "original");
        file::remove_all(recovery)?;
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn stale_flight_recovery_temp_file_does_not_block_recovery() -> Result<()> {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir()?;
        let recovery_root = tmp.path().join("recovery");
        let stale = recovery_root.join("stale.tmp");
        file::create_dir_all(&stale)?;
        file::write(stale.join("locked"), "content")?;
        std::fs::set_permissions(&stale, std::fs::Permissions::from_mode(0o000))?;

        let target = tmp.path().join("target");
        let backup = tmp.path().join("backup");
        file::write(&backup, "original")?;
        let recovery = recovery_root.join("valid.recovery");
        let record = FlightRecoveryRecord {
            target: target.clone(),
            backup: Some(backup.clone()),
            target_parent: resolved_parent(&target)?,
            backup_parent: Some(resolved_parent(&backup)?),
            receipt_caskroom: None,
            elevate: true,
        };
        write_durable_file(&recovery, &serde_json::to_vec_pretty(&record)?)?;

        recover_flight_backups_in(&recovery_root)?;

        assert_eq!(file::read_to_string(target)?, "original");
        assert!(recovery.symlink_metadata().is_err());
        assert!(stale.symlink_metadata().is_ok());
        std::fs::set_permissions(&stale, std::fs::Permissions::from_mode(0o700))?;
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn flight_target_rollback_rejects_swapped_parent() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("prefix");
        let target = prefix.join("bin/example");
        file::create_dir_all(target.parent().unwrap())?;
        file::write(&target, "original")?;
        let mut transaction = FlightTargetTransaction::default();
        transaction.protect(&target)?;

        let saved_prefix = tmp.path().join("saved-prefix");
        file::rename(&prefix, &saved_prefix)?;
        let external = tmp.path().join("external");
        file::create_dir_all(external.join("bin"))?;
        let external_target = external.join("bin/example");
        file::write(&external_target, "external")?;
        file::make_symlink(&external, &prefix)?;

        assert!(transaction.rollback().is_err());
        assert_eq!(file::read_to_string(&external_target)?, "external");
        assert_eq!(transaction.backups.len(), 1);

        file::remove_file(&prefix)?;
        file::rename(&saved_prefix, &prefix)?;
        transaction.rollback()?;
        assert_eq!(file::read_to_string(&target)?, "original");
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn flight_target_backup_survives_app_replacement() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let app = tmp.path().join("Example.app");
        let target = app.join("Contents/MacOS/example-link");
        file::create_dir_all(target.parent().unwrap())?;
        file::make_symlink(Path::new("original"), &target)?;
        let mut transaction = FlightTargetTransaction::default();

        transaction.protect(&target)?;
        let backup = transaction.backups[0].backup.as_ref().unwrap().clone();
        assert_eq!(backup.parent(), app.parent());
        file::remove_all(&app)?;
        file::create_dir_all(target.parent().unwrap())?;
        file::make_symlink(Path::new("replacement"), &target)?;

        transaction.rollback()?;
        assert_eq!(std::fs::read_link(target)?, PathBuf::from("original"));
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn receipt_flight_symlinks_exclude_standard_and_drifted_targets() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let binary = tmp.path().join("bin/example");
        let flight = tmp.path().join("share/example");
        let retained = tmp.path().join("share/retained");
        let drifted = tmp.path().join("share/drifted");
        file::create_dir_all(binary.parent().unwrap())?;
        file::create_dir_all(flight.parent().unwrap())?;
        file::make_symlink(Path::new("binary-source"), &binary)?;
        file::make_symlink(Path::new("flight-source"), &flight)?;
        file::make_symlink(Path::new("retained-source"), &retained)?;
        file::make_symlink(Path::new("original-source"), &drifted)?;
        let records = [&binary, &flight, &retained, &drifted]
            .into_iter()
            .map(|path| {
                Ok(CaskTargetRecord {
                    path: path.clone(),
                    fingerprint: cask_target_fingerprint(path)?,
                    uninstall: if path == &retained {
                        Some(false)
                    } else if path == &binary {
                        None
                    } else {
                        Some(true)
                    },
                })
            })
            .collect::<Result<Vec<_>>>()?;
        file::remove_file(&drifted)?;
        file::make_symlink(Path::new("changed-source"), &drifted)?;
        let receipt = CaskReceipt {
            schema_version: 3,
            version: "1.0.0".to_string(),
            apps: Vec::new(),
            binaries: vec![binary],
            fonts: Vec::new(),
            completions: Vec::new(),
            flight_directories: Vec::new(),
            generic: Vec::new(),
            pkg_ids: Vec::new(),
            targets: records,
            prune_safe: false,
            prune_blocker: None,
        };

        assert_eq!(receipt_flight_symlink_targets(&receipt)?, vec![flight]);
        Ok(())
    }

    #[test]
    fn staged_symlink_sources_become_caskroom_owned() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let stage = tmp.path().join("stage");
        let source = stage.join("AndroidNDK.app/Contents/NDK");
        file::create_dir_all(&source)?;
        file::write(source.join("ndk-build"), "binary")?;
        let temporary_caskroom = tmp.path().join("Caskroom/android-ndk/.mise-tmp");
        let target = tmp.path().join("share/android-ndk");
        let mut targets = FlightTargetTransaction::default();
        targets.protect(&target)?;
        file::create_dir_all(
            target
                .parent()
                .ok_or_else(|| eyre!("missing target parent"))?,
        )?;
        file::make_symlink(&source, &target)?;
        targets.record_installed(target.clone());

        durabilize_staged_symlink_targets(&stage, &temporary_caskroom, &mut targets)?;

        assert_eq!(
            std::fs::read_link(&target)?,
            temporary_caskroom.join(".homebrew-staged/AndroidNDK.app/Contents/NDK")
        );
        assert!(
            temporary_caskroom
                .join(".homebrew-staged/AndroidNDK.app/Contents/NDK/ndk-build")
                .is_file()
        );
        targets.commit()?;
        Ok(())
    }

    #[test]
    fn staged_symlink_source_copies_reachable_internal_links() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let stage = tmp.path().join("stage");
        let source = stage.join("pkg/bin");
        let shared = stage.join("shared/data");
        file::create_dir_all(&source)?;
        file::create_dir_all(&shared)?;
        file::write(shared.join("value"), "content")?;
        file::make_symlink(&shared, &source.join("absolute"))?;
        file::make_symlink(Path::new("../../shared/data"), &source.join("relative"))?;
        let temporary_caskroom = tmp.path().join("Caskroom/example/.mise-tmp");
        let target = tmp.path().join("share/example");
        let mut targets = FlightTargetTransaction::default();
        targets.protect(&target)?;
        file::create_dir_all(target.parent().unwrap())?;
        file::make_symlink(&source, &target)?;
        targets.record_installed(target.clone());

        durabilize_staged_symlink_targets(&stage, &temporary_caskroom, &mut targets)?;

        let owned_stage = temporary_caskroom.join(".homebrew-staged");
        assert_eq!(
            std::fs::read_link(owned_stage.join("pkg/bin/absolute"))?,
            owned_stage.join("shared/data")
        );
        assert_eq!(
            std::fs::read_link(owned_stage.join("pkg/bin/relative"))?,
            PathBuf::from("../../shared/data")
        );
        assert_eq!(
            file::read_to_string(owned_stage.join("pkg/bin/absolute/value"))?,
            "content"
        );
        assert_eq!(
            file::read_to_string(owned_stage.join("pkg/bin/relative/value"))?,
            "content"
        );
        targets.commit()?;
        Ok(())
    }

    #[test]
    fn staged_symlink_source_preserves_link_to_external_referent() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let stage = tmp.path().join("stage");
        let external = tmp.path().join("external");
        file::create_dir_all(&stage)?;
        file::create_dir_all(&external)?;
        file::write(external.join("value"), "content")?;
        let staged_link = stage.join("external-link");
        file::make_symlink(&external, &staged_link)?;
        let temporary_caskroom = tmp.path().join("Caskroom/example/.mise-tmp");
        let target = tmp.path().join("share/example");
        file::create_dir_all(target.parent().unwrap())?;
        file::make_symlink(&staged_link, &target)?;
        let mut targets = FlightTargetTransaction::default();
        targets.protect(&target)?;
        file::make_symlink(&staged_link, &target)?;
        targets.record_installed(target.clone());

        durabilize_staged_symlink_targets(&stage, &temporary_caskroom, &mut targets)?;

        let durable = temporary_caskroom.join(".homebrew-staged/external-link");
        assert_eq!(std::fs::read_link(&target)?, durable);
        assert_eq!(std::fs::read_link(&durable)?, external);
        file::remove_all(&stage)?;
        assert_eq!(file::read_to_string(target.join("value"))?, "content");
        targets.commit()?;
        Ok(())
    }

    #[test]
    fn staged_symlink_source_accepts_canonical_stage_spelling() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let real_parent = tmp.path().join("real");
        let real_stage = real_parent.join("stage");
        file::create_dir_all(&real_stage)?;
        file::write(real_stage.join("value"), "content")?;
        let alias_parent = tmp.path().join("alias");
        file::make_symlink(&real_parent, &alias_parent)?;
        let stage = alias_parent.join("stage");
        let temporary_caskroom = tmp.path().join("Caskroom/example/.mise-tmp");
        let target = tmp.path().join("share/example");
        file::create_dir_all(target.parent().unwrap())?;
        let mut targets = FlightTargetTransaction::default();
        targets.protect(&target)?;
        file::make_symlink(&real_stage, &target)?;
        targets.record_installed(target.clone());

        durabilize_staged_symlink_targets(&stage, &temporary_caskroom, &mut targets)?;

        assert_eq!(file::read_to_string(target.join("value"))?, "content");
        targets.commit()?;
        Ok(())
    }

    #[test]
    fn staged_artifact_closure_merges_a_parent_after_its_child() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let stage = tmp.path().join("stage");
        let parent = stage.join("parent");
        file::create_dir_all(&parent)?;
        file::write(parent.join("first"), "first")?;
        file::write(parent.join("second"), "second")?;
        let owned = tmp.path().join("owned");

        copy_staged_artifact_closure(&stage, &owned, &parent.join("first"))?;
        copy_staged_artifact_closure(&stage, &owned, &parent)?;

        assert_eq!(file::read_to_string(owned.join("parent/first"))?, "first");
        assert_eq!(file::read_to_string(owned.join("parent/second"))?, "second");
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn staged_artifact_closure_rejects_intermediate_symlink_escape() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let stage = tmp.path().join("stage");
        let outside = tmp.path().join("outside");
        file::create_dir_all(&stage)?;
        file::create_dir_all(&outside)?;
        file::write(outside.join("secret"), "secret")?;
        file::make_symlink(&outside, &stage.join("link"))?;
        let owned = tmp.path().join("owned");

        let err = copy_staged_artifact_closure(&stage, &owned, &stage.join("link/secret"))
            .unwrap_err()
            .to_string();

        assert!(err.contains("escaped extraction root"));
        assert!(owned.symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn structured_symlink_inside_stage_uses_relative_source() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let stage = tmp.path().join("stage");
        file::create_dir_all(stage.join("source"))?;
        let link = stage.join("nested/link");
        let step = FlightStep::Symlink {
            source: FlightPath {
                base: FlightPathBase::StagedPath,
                path: "source".to_string(),
            },
            target: FlightPath {
                base: FlightPathBase::StagedPath,
                path: "nested/link".to_string(),
            },
            force: false,
            uninstall: false,
            source_glob: false,
            sudo: FlightSudo::Never,
            guards: Vec::new(),
        };

        execute_flight_steps(
            &test_cask("example", "1.0.0"),
            &[step],
            &stage,
            Path::new("/Applications"),
            "preflight_steps",
        )?;

        assert_eq!(std::fs::read_link(link)?, PathBuf::from("../source"));
        Ok(())
    }

    #[test]
    fn internal_staged_symlinks_remain_relative_and_untracked() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let stage = tmp.path().join("stage");
        let source = stage.join("Example.app/Contents/Resources/data");
        let link = stage.join("Example.app/Contents/data");
        file::create_dir_all(&source)?;
        file::create_dir_all(link.parent().unwrap())?;
        file::make_symlink(Path::new("Resources/data"), &link)?;
        let temporary_caskroom = tmp.path().join("Caskroom/example/.mise-tmp");
        let mut targets = FlightTargetTransaction::default();
        targets.record_installed(link.clone());

        durabilize_staged_symlink_targets(&stage, &temporary_caskroom, &mut targets)?;

        assert_eq!(std::fs::read_link(&link)?, PathBuf::from("Resources/data"));
        assert!(temporary_caskroom.symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    fn temporary_caskroom_symlink_sources_follow_activation() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let temporary_caskroom = tmp.path().join("Caskroom/example/.mise-tmp");
        let final_caskroom = tmp.path().join("Caskroom/example/1.0.0");
        let source = temporary_caskroom.join("bin/example");
        file::create_dir_all(source.parent().unwrap())?;
        file::write(&source, "binary")?;
        let target = tmp.path().join("bin/example");
        file::create_dir_all(target.parent().unwrap())?;
        let mut targets = FlightTargetTransaction::default();
        targets.protect(&target)?;
        file::make_symlink(&source, &target)?;
        targets.record_installed(target.clone());
        file::rename(&temporary_caskroom, &final_caskroom)?;

        retarget_transient_symlinks(
            &temporary_caskroom,
            &final_caskroom,
            &final_caskroom,
            &targets,
        )?;

        assert_eq!(
            std::fs::read_link(target)?,
            final_caskroom.join("bin/example")
        );
        targets.commit()?;
        Ok(())
    }

    #[test]
    fn internal_temporary_caskroom_symlinks_follow_activation() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let temporary_caskroom = tmp.path().join("Caskroom/example/.mise-tmp");
        let final_caskroom = tmp.path().join("Caskroom/example/1.0.0");
        let source = temporary_caskroom.join("share/example/source");
        let target = temporary_caskroom.join("share/example/target");
        file::create_dir_all(source.parent().unwrap())?;
        file::write(&source, "content")?;
        file::make_symlink(&source, &target)?;
        file::rename(&temporary_caskroom, &final_caskroom)?;
        let installed_target = final_caskroom.join("share/example/target");

        retarget_transient_symlinks(
            &temporary_caskroom,
            &final_caskroom,
            &final_caskroom,
            &FlightTargetTransaction::default(),
        )?;

        assert_eq!(
            std::fs::read_link(installed_target)?,
            final_caskroom.join("share/example/source")
        );
        Ok(())
    }

    #[test]
    fn parses_zoom_terminate_process_step() -> Result<()> {
        let mut cask = test_cask("zoom", "7.1.5.84650");
        cask.artifacts = vec![
            serde_json::json!({"uninstall": [{"pkgutil": "us.zoom.pkg.videomeeting"}]}),
            serde_json::json!({"pkg": ["zoomusInstallerFull.pkg"]}),
            serde_json::json!({
                "postflight_steps": [{
                    "steps": [{
                        "type": "terminate_process",
                        "name": "/Applications/zoom.us.app",
                        "match": "full",
                        "attempts": 3,
                        "notices": [
                            "The Zoom package postinstall script launches the Zoom app",
                            "Attempting to close zoom.us.app to avoid unwanted user intervention"
                        ],
                        "failure_message": "Unable to forcibly close zoom.us.app"
                    }]
                }]
            }),
        ];

        assert_eq!(
            cask_artifacts(&cask)?.postflight_steps,
            vec![FlightStep::TerminateProcess {
                name: "/Applications/zoom.us.app".to_string(),
                match_mode: ProcessMatch::Full,
                sudo: false,
                attempts: 3,
                must_succeed: false,
                notices: vec![
                    "The Zoom package postinstall script launches the Zoom app".to_string(),
                    "Attempting to close zoom.us.app to avoid unwanted user intervention"
                        .to_string(),
                ],
                failure_message: Some("Unable to forcibly close zoom.us.app".to_string()),
            }]
        );
        Ok(())
    }

    #[test]
    fn completed_flight_action_names_are_receipt_stable() -> Result<()> {
        let cask = test_cask("example", "1.0.0");
        let stage = tempfile::tempdir()?;
        let source = stage.path().join("obsolete");
        std::fs::write(&source, "remove me")?;
        let steps = vec![FlightStep::Remove {
            paths: vec![FlightPath {
                path: "obsolete".to_string(),
                base: FlightPathBase::StagedPath,
            }],
            recursive: false,
        }];
        let mut completed = Vec::new();
        let mut targets = FlightTargetTransaction::default();

        execute_flight_steps_with_completion(
            &cask,
            &steps,
            stage.path(),
            Path::new("/Applications"),
            "postflight_steps",
            &mut targets,
            |index, step| {
                completed.push(format!("postflight_steps[{index}]:{}", step.kind()));
                Ok(())
            },
        )?;

        assert_eq!(completed, ["postflight_steps[0]:remove"]);
        assert!(!source.exists());
        Ok(())
    }

    #[test]
    fn terminate_process_has_explicit_completed_action_kind() {
        let step = FlightStep::TerminateProcess {
            name: "zoom.us.app".to_string(),
            match_mode: ProcessMatch::Name,
            sudo: false,
            attempts: 1,
            must_succeed: false,
            notices: Vec::new(),
            failure_message: None,
        };

        assert_eq!(step.kind(), "terminate_process");
    }

    #[test]
    fn terminate_process_defaults_match_homebrew() -> Result<()> {
        let cask = test_cask("example", "1.0.0");
        let step = parse_flight_step(
            &cask,
            "postflight_steps",
            &serde_json::json!({"type": "terminate_process", "name": "Example"}),
        )?;
        assert_eq!(
            step,
            FlightStep::TerminateProcess {
                name: "Example".to_string(),
                match_mode: ProcessMatch::Name,
                sudo: false,
                attempts: 1,
                must_succeed: false,
                notices: Vec::new(),
                failure_message: None,
            }
        );
        Ok(())
    }

    #[test]
    fn terminate_process_rejects_malformed_metadata() {
        let cask = test_cask("example", "1.0.0");
        let invalid = [
            serde_json::json!({"type": "terminate_process"}),
            serde_json::json!({"type": "terminate_process", "name": ""}),
            serde_json::json!({"type": "terminate_process", "name": "x", "match": "prefix"}),
            serde_json::json!({"type": "terminate_process", "name": "x", "attempts": 0}),
            serde_json::json!({"type": "terminate_process", "name": "x", "attempts": 1.5}),
            serde_json::json!({"type": "terminate_process", "name": "x", "sudo": "yes"}),
            serde_json::json!({"type": "terminate_process", "name": "x", "must_succeed": 1}),
            serde_json::json!({"type": "terminate_process", "name": "x", "notices": [1]}),
            serde_json::json!({"type": "terminate_process", "name": "x", "failure_message": 1}),
            serde_json::json!({"type": "terminate_process", "name": "x", "unknown": true}),
        ];
        for value in invalid {
            assert!(parse_flight_step(&cask, "postflight_steps", &value).is_err());
        }
    }

    #[test]
    fn terminate_process_retries_with_direct_argv_and_nonfatal_exhaustion() -> Result<()> {
        let step = FlightStep::TerminateProcess {
            name: "{{appdir}}/Example.app".to_string(),
            match_mode: ProcessMatch::Full,
            sudo: true,
            attempts: 3,
            must_succeed: false,
            notices: vec!["Closing {{version}}".to_string()],
            failure_message: Some("Unable to close {{version}}".to_string()),
        };
        let mut calls = Vec::new();
        let mut sleeps = Vec::new();
        execute_terminate_process(
            &step,
            Path::new("/tmp/stage"),
            Path::new("/Applications"),
            "1.2.3",
            |command, args, sudo| {
                calls.push((command.to_path_buf(), args.to_vec(), sudo));
                Err(eyre!("still running"))
            },
            |duration| sleeps.push(duration),
        )?;
        assert_eq!(calls.len(), 3);
        assert!(calls.iter().all(|(command, args, sudo)| {
            command == Path::new("/usr/bin/pkill")
                && args == &["-f", "/Applications/Example.app"]
                && *sudo
        }));
        assert_eq!(sleeps, vec![std::time::Duration::from_secs(1); 2]);
        Ok(())
    }

    #[test]
    fn terminate_process_name_mode_stops_after_success() -> Result<()> {
        let step = FlightStep::TerminateProcess {
            name: "Example".to_string(),
            match_mode: ProcessMatch::Name,
            sudo: false,
            attempts: 3,
            must_succeed: true,
            notices: Vec::new(),
            failure_message: None,
        };
        let mut attempts = 0;
        execute_terminate_process(
            &step,
            Path::new("/tmp/stage"),
            Path::new("/Applications"),
            "1.0.0",
            |command, args, sudo| {
                attempts += 1;
                assert_eq!(command, Path::new("/usr/bin/killall"));
                assert_eq!(args, &["Example"]);
                assert!(!sudo);
                if attempts == 1 {
                    Err(eyre!("retry"))
                } else {
                    Ok(())
                }
            },
            |_| {},
        )?;
        assert_eq!(attempts, 2);
        Ok(())
    }

    #[test]
    fn terminate_process_must_succeed_returns_final_error() {
        let step = FlightStep::TerminateProcess {
            name: "Example".to_string(),
            match_mode: ProcessMatch::Name,
            sudo: false,
            attempts: 1,
            must_succeed: true,
            notices: Vec::new(),
            failure_message: None,
        };
        let err = execute_terminate_process(
            &step,
            Path::new("/tmp/stage"),
            Path::new("/Applications"),
            "1.0.0",
            |_, _, _| Err(eyre!("still running")),
            |_| {},
        )
        .unwrap_err();
        assert!(err.to_string().contains("still running"));
    }

    #[test]
    fn structured_run_expands_paths_args_and_env() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let _guard = BrewPrefixGuard::set(&prefix);
        let staged = tmp.path().join("stage");
        let appdir = tmp.path().join("Applications");
        file::create_dir_all(&staged)?;
        file::create_dir_all(&appdir)?;
        let result = staged.join("result");

        execute_flight_steps(
            &test_cask("example", "1.2.3"),
            &[FlightStep::Run {
                command: FlightPath {
                    base: FlightPathBase::Literal,
                    path: "/bin/sh".to_string(),
                },
                args: vec![
                    "-c".to_string(),
                    "printf '%s' \"$MISE_TEST:$1:$2:$3\" > \"$4\"".to_string(),
                    "_".to_string(),
                    "{{appdir}}".to_string(),
                    "{{staged_path}}".to_string(),
                    "{{HOMEBREW_PREFIX}}".to_string(),
                    "{{staged_path}}/result".to_string(),
                ],
                env: BTreeMap::from([("MISE_TEST".to_string(), "version-{{version}}".to_string())]),
                sudo: false,
                guards: Vec::new(),
            }],
            &staged,
            &appdir,
            "postflight_steps",
        )?;

        assert_eq!(
            file::read_to_string(result)?,
            format!(
                "version-1.2.3:{}:{}:{}",
                appdir.display(),
                staged.display(),
                prefix.display()
            )
        );
        Ok(())
    }

    #[test]
    fn structured_flight_steps_move_and_remove_staged_paths() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let staged = tmp.path();
        let bundle_dir = staged.join("WezTerm-nightly");
        let app = bundle_dir.join("WezTerm.app");
        file::create_dir_all(&app)?;

        execute_flight_steps(
            &test_cask("wezterm@nightly", "latest"),
            &[
                FlightStep::Move {
                    source: FlightPath {
                        base: FlightPathBase::StagedPath,
                        path: "{WezTerm-*,wezterm-*}/WezTerm.app".to_string(),
                    },
                    target: FlightPath {
                        base: FlightPathBase::StagedPath,
                        path: ".".to_string(),
                    },
                    source_glob: true,
                },
                FlightStep::Remove {
                    paths: vec![
                        FlightPath {
                            base: FlightPathBase::StagedPath,
                            path: "WezTerm-*".to_string(),
                        },
                        FlightPath {
                            base: FlightPathBase::StagedPath,
                            path: "wezterm-*".to_string(),
                        },
                    ],
                    recursive: true,
                },
            ],
            staged,
            staged,
            "preflight_steps",
        )?;

        assert!(staged.join("WezTerm.app").is_dir());
        assert!(!bundle_dir.exists());
        Ok(())
    }

    #[test]
    fn rejects_unsupported_structured_flight_steps() {
        let mut cask = test_cask("battle-net", "1.0.0");
        cask.artifacts = vec![
            serde_json::json!({
                "preflight_steps": [{
                    "steps": [{
                        "type": "set_permissions",
                        "paths": [{"base": "staged_path", "path": "Battle.net-Setup.app"}],
                        "permissions": "a+x"
                    }]
                }]
            }),
            serde_json::json!({"app": "Battle.net.app"}),
        ];

        let err = cask_artifacts(&cask).unwrap_err().to_string();
        assert!(err.contains("unsupported preflight_steps step type set_permissions"));
    }

    #[test]
    fn rejects_structured_flight_step_group_controls() {
        let mut cask = test_cask("example", "1.0.0");
        cask.artifacts = vec![
            serde_json::json!({
                "preflight_steps": [{
                    "if": {"arch": "arm64"},
                    "steps": [{
                        "type": "remove",
                        "paths": [{"base": "staged_path", "path": "old"}]
                    }]
                }]
            }),
            serde_json::json!({"app": "Example.app"}),
        ];

        let err = cask_artifacts(&cask).unwrap_err().to_string();
        assert!(err.contains("unsupported preflight_steps step group field if"));
    }

    #[test]
    fn rejects_structured_flight_step_controls() {
        let mut cask = test_cask("miniconda", "25.5.1-1");
        cask.artifacts = vec![
            serde_json::json!({
                "postflight_steps": [{
                    "steps": [{
                        "type": "remove",
                        "paths": [{"base": "staged_path", "path": "base/envs"}],
                        "recursive": true,
                        "guards": [{"condition": "if_exists", "path": "{{temp}}/miniconda-envs"}]
                    }]
                }]
            }),
            serde_json::json!({"pkg": ["Miniconda.pkg"]}),
            serde_json::json!({"uninstall": [{"pkgutil": "com.anaconda.pkg"}]}),
        ];

        let err = cask_artifacts(&cask).unwrap_err().to_string();
        assert!(err.contains("unsupported postflight_steps remove step field guards"));
    }

    #[test]
    fn rejects_baseless_relative_run_command_paths() {
        let cask = test_cask("example", "1.0.0");
        for path in ["bin/tool", "../tool", "./tool"] {
            let value = serde_json::json!({"path": path});
            let err = parse_run_command(&cask, "preflight_steps", Some(&value))
                .unwrap_err()
                .to_string();
            assert!(
                err.contains("invalid preflight_steps run command path"),
                "{err}"
            );
        }
    }

    #[test]
    fn accepts_baseless_bare_and_absolute_run_commands() -> Result<()> {
        let cask = test_cask("example", "1.0.0");
        for path in ["xattr", "/usr/bin/xattr"] {
            let value = serde_json::json!({"path": path});
            assert_eq!(
                parse_run_command(&cask, "preflight_steps", Some(&value))?,
                FlightPath {
                    base: FlightPathBase::Literal,
                    path: path.to_string(),
                }
            );
        }
        Ok(())
    }

    #[test]
    fn ensure_cask_shim_creates_parent_dir() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let shim_path = tmp.path().join("missing").join("cask_shim.rb");

        ensure_cask_shim(&shim_path)?;

        assert_eq!(file::read_to_string(&shim_path)?, CASK_SHIM_RB);
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn cask_shim_supports_language_and_system_conditionals() -> Result<()> {
        let Some(ruby) = file::which("ruby") else {
            return Ok(());
        };
        let tmp = tempfile::tempdir()?;
        let shim = tmp.path().join("cask_shim.rb");
        let cask = tmp.path().join("example.rb");
        let result = tmp.path().join("result");
        file::write(&shim, CASK_SHIM_RB)?;
        file::write(
            &cask,
            r##"cask "example" do
  version "1.0.0"
  language "fr" do
    "fr"
  end
  language "en", default: true do
    "en-US"
  end
  suffix = on_system_conditional linux: "-linux", macos: "-macos"
  preflight do
    File.write staged_path/"result", "#{language}#{suffix}"
  end
end
"##,
        )?;

        let output = run_cask_shim(&ruby, &shim, &cask, tmp.path(), "1.0.0")?;
        assert!(
            output.status.success(),
            "{}",
            String::from_utf8_lossy(&output.stderr)
        );
        let suffix = if cfg!(target_os = "macos") {
            "-macos"
        } else {
            "-linux"
        };
        assert_eq!(file::read_to_string(result)?, format!("en-US{suffix}"));
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn cask_shim_supports_csv_version_array_helpers() -> Result<()> {
        let Some(ruby) = file::which("ruby") else {
            return Ok(());
        };
        let tmp = tempfile::tempdir()?;
        let shim = tmp.path().join("cask_shim.rb");
        let cask = tmp.path().join("example.rb");
        let result = tmp.path().join("result");
        file::write(&shim, CASK_SHIM_RB)?;
        file::write(
            &cask,
            r#"cask "example" do
  version "2.2.1,20628"
  url "https://example.com/OrbStack_v#{version.csv.first}_#{version.csv.second}.dmg"
  auto_updates true
  preflight do
    File.write staged_path/"result", version.csv.second
  end
end
"#,
        )?;

        let output = run_cask_shim(&ruby, &shim, &cask, tmp.path(), "2.2.1,20628")?;
        assert!(
            output.status.success(),
            "{}",
            String::from_utf8_lossy(&output.stderr)
        );
        assert_eq!(file::read_to_string(result)?, "20628");
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn cask_shim_supports_completion_stanzas_and_system_command() -> Result<()> {
        let Some(ruby) = file::which("ruby") else {
            return Ok(());
        };
        let tmp = tempfile::tempdir()?;
        let shim = tmp.path().join("cask_shim.rb");
        let cask = tmp.path().join("example.rb");
        file::write(&shim, CASK_SHIM_RB)?;
        crate::file::write(tmp.path().join("kubectl"), "kubectl")?;
        // Modeled on the docker-desktop cask: completion stanzas plus a
        // postflight that symlinks kubectl via system_command.
        file::write(
            &cask,
            r##"cask "example" do
  version "1.0.0"
  app "Example.app"
  binary "#{appdir}/Example.app/Contents/Resources/bin/example"
  bash_completion "#{appdir}/Example.app/Contents/Resources/etc/example.bash-completion"
  zsh_completion "#{appdir}/Example.app/Contents/Resources/etc/example.zsh-completion"
  fish_completion "#{appdir}/Example.app/Contents/Resources/etc/example.fish-completion"
  manpage "#{appdir}/Example.app/Contents/Resources/man/example.1"
  postflight do
    kubectl_target = staged_path/"kubectl-link"
    next if kubectl_target.exist?
    system_command "/bin/ln", args: ["-sfn", staged_path/"kubectl", kubectl_target],
                              sudo: false
    echoed = system_command "/bin/echo", args: ["-n", "hello"], print_stderr: false
    File.write staged_path/"result", echoed.stdout if echoed.success?
    # A no-args executable whose path contains spaces and shell
    # metacharacters must run via argv, not a shell command line.
    spaced = system_command staged_path/"my tool $HOME"
    File.write staged_path/"spaced-result", spaced.stdout
  end
end
"##,
        )?;
        let spaced_tool = tmp.path().join("my tool $HOME");
        crate::file::write(&spaced_tool, "#!/bin/sh\nprintf spaced-ok\n")?;
        file::make_executable(&spaced_tool)?;

        let output = run_cask_shim_hook(&ruby, &shim, &cask, tmp.path(), "1.0.0", "postflight")?;
        assert!(
            output.status.success(),
            "{}",
            String::from_utf8_lossy(&output.stderr)
        );
        assert_eq!(
            std::fs::read_link(tmp.path().join("kubectl-link"))?,
            tmp.path().join("kubectl")
        );
        assert_eq!(file::read_to_string(tmp.path().join("result"))?, "hello");
        assert_eq!(
            file::read_to_string(tmp.path().join("spaced-result"))?,
            "spaced-ok"
        );
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn cask_shim_system_command_reports_denied_sudo() -> Result<()> {
        let Some(ruby) = file::which("ruby") else {
            return Ok(());
        };
        let tmp = tempfile::tempdir()?;
        let shim = tmp.path().join("cask_shim.rb");
        let cask = tmp.path().join("example.rb");
        file::write(&shim, CASK_SHIM_RB)?;
        file::write(
            &cask,
            r#"cask "example" do
  version "1.0.0"
  preflight do
    system_command "/usr/bin/true", args: ["--flag"], sudo: true
  end
end
"#,
        )?;

        // MISE_BREW_CASK_SUDO is unset, which must behave as "deny".
        let output = run_cask_shim(&ruby, &shim, &cask, tmp.path(), "1.0.0")?;
        if nix::unistd::geteuid().is_root() {
            // root never needs to elevate, so the hook succeeds
            assert!(output.status.success());
            return Ok(());
        }
        assert!(!output.status.success());
        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(stderr.contains("needs sudo"), "{stderr}");
        assert!(stderr.contains("sudo /usr/bin/true --flag"), "{stderr}");
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn cask_shim_system_command_reports_failed_commands() -> Result<()> {
        let Some(ruby) = file::which("ruby") else {
            return Ok(());
        };
        let tmp = tempfile::tempdir()?;
        let shim = tmp.path().join("cask_shim.rb");
        let cask = tmp.path().join("example.rb");
        file::write(&shim, CASK_SHIM_RB)?;
        file::write(
            &cask,
            r#"cask "example" do
  version "1.0.0"
  preflight do
    system_command "/usr/bin/false"
  end
end
"#,
        )?;

        let output = run_cask_shim(&ruby, &shim, &cask, tmp.path(), "1.0.0")?;
        assert!(!output.status.success());
        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(
            stderr.contains("command failed (exit 1): /usr/bin/false"),
            "{stderr}"
        );
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn cask_shim_reports_missing_system_conditional() -> Result<()> {
        let Some(ruby) = file::which("ruby") else {
            return Ok(());
        };
        let tmp = tempfile::tempdir()?;
        let shim = tmp.path().join("cask_shim.rb");
        let cask = tmp.path().join("example.rb");
        let (conditional, platform) = if cfg!(target_os = "macos") {
            ("linux: \"-linux\"", "macos")
        } else {
            ("macos: \"-macos\"", "linux")
        };
        file::write(&shim, CASK_SHIM_RB)?;
        file::write(
            &cask,
            format!("cask \"example\" do\n  on_system_conditional {conditional}\nend\n"),
        )?;

        let output = run_cask_shim(&ruby, &shim, &cask, tmp.path(), "1.0.0")?;
        assert!(!output.status.success());
        assert!(String::from_utf8_lossy(&output.stderr).contains(&format!(
            "Error: cask uses `on_system_conditional without {platform}`"
        )));
        Ok(())
    }

    #[test]
    fn detects_suffixless_zip_archives() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let archive = tmp.path().join("stable");
        std::fs::write(&archive, b"PK\x03\x04suffixless zip")?;

        assert_eq!(
            cask_extraction_format(&archive, "visual-studio-code-1.127.0-stable")?,
            ExtractionFormat::Zip
        );
        Ok(())
    }

    #[test]
    fn detects_suffixless_dmg_archives() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let archive = tmp.path().join("download");
        let mut contents = vec![0; 1024];
        contents[512..524].copy_from_slice(b"koly\0\0\0\x04\0\0\x02\0");
        std::fs::write(&archive, contents)?;

        assert!(is_dmg_archive(&archive, "raycast-1.104.24-download")?);
        Ok(())
    }

    #[test]
    fn rejects_malformed_suffixless_dmg_trailers() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let archive = tmp.path().join("download");
        let mut contents = vec![0; 1024];
        contents[512..520].copy_from_slice(b"koly\0\0\0\x04");
        std::fs::write(&archive, contents)?;

        assert!(!is_dmg_archive(&archive, "raycast-1.104.24-download")?);
        Ok(())
    }

    #[test]
    fn does_not_sniff_named_archives_as_dmg() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let archive = tmp.path().join("archive.zip");
        let mut contents = vec![0; 1024];
        contents[..4].copy_from_slice(b"PK\x03\x04");
        contents[512..524].copy_from_slice(b"koly\0\0\0\x04\0\0\x02\0");
        std::fs::write(&archive, contents)?;

        assert!(!is_dmg_archive(&archive, "archive.zip")?);
        assert_eq!(
            cask_extraction_format(&archive, "archive.zip")?,
            ExtractionFormat::Zip
        );
        Ok(())
    }

    #[test]
    fn leaves_suffixless_raw_binaries_raw() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let archive = tmp.path().join("claude");
        let mut contents = vec![0; 1024];
        contents[..10].copy_from_slice(b"#!/bin/sh\n");
        std::fs::write(&archive, contents)?;

        assert!(!is_dmg_archive(&archive, "claude-1.0.0-claude")?);
        assert_eq!(
            cask_extraction_format(&archive, "claude-1.0.0-claude")?,
            ExtractionFormat::Raw
        );
        Ok(())
    }

    #[test]
    fn artifact_lookup_ignores_macos_metadata_directories() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let metadata_app = tmp.path().join("__MACOSX/Pearcleaner.app");
        file::create_dir_all(&metadata_app)?;

        assert_eq!(find_app(tmp.path(), "Pearcleaner.app"), None);

        let app = tmp.path().join("Pearcleaner.app");
        file::create_dir_all(&app)?;

        assert_eq!(find_app(tmp.path(), "Pearcleaner.app"), Some(app));
        Ok(())
    }

    #[test]
    fn artifact_lookup_matches_app_bundle_case_insensitively() -> Result<()> {
        // Homebrew cask `yaak` declares `app "yaak.app"` but the DMG ships
        // `Yaak.app`. Default macOS APFS is case-insensitive; exact match must
        // not be required.
        let tmp = tempfile::tempdir()?;
        let app = tmp.path().join("Yaak.app");
        file::create_dir_all(&app)?;

        assert_eq!(find_app(tmp.path(), "yaak.app"), Some(app.clone()));
        assert_eq!(find_app(tmp.path(), "Yaak.app"), Some(app));
        assert_eq!(find_app(tmp.path(), "Other.app"), None);
        Ok(())
    }

    #[test]
    fn artifact_lookup_prefers_exact_case_over_earlier_fallback() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let fallback = tmp.path().join("Yaak.app");
        let exact = fallback.join("Contents/yaak.app");
        file::create_dir_all(&exact)?;

        assert_eq!(find_app(tmp.path(), "yaak.app"), Some(exact));
        Ok(())
    }

    #[test]
    fn artifact_lookup_skips_macos_metadata_for_case_insensitive_match() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        file::create_dir_all(tmp.path().join("__MACOSX/Yaak.app"))?;
        let app = tmp.path().join("Yaak.app");
        file::create_dir_all(&app)?;

        assert_eq!(find_app(tmp.path(), "yaak.app"), Some(app));
        Ok(())
    }

    #[test]
    fn find_app_ignores_file_that_matches_app_name() -> Result<()> {
        // A same-named regular file must not shadow a later .app directory.
        let tmp = tempfile::tempdir()?;
        std::fs::write(tmp.path().join("yaak.app"), b"not a bundle")?;
        let app = tmp.path().join("nested/Yaak.app");
        file::create_dir_all(&app)?;

        assert_eq!(find_app(tmp.path(), "yaak.app"), Some(app));
        Ok(())
    }

    #[test]
    fn path_ends_with_ignore_ascii_case_matches_components() {
        assert!(path_ends_with_ignore_ascii_case(
            Path::new("payload/Yaak.app"),
            Path::new("yaak.app")
        ));
        assert!(path_ends_with_ignore_ascii_case(
            Path::new("Yaak.app"),
            Path::new("yaak.app")
        ));
        assert!(!path_ends_with_ignore_ascii_case(
            Path::new("Yaak.app"),
            Path::new("Other.app")
        ));
        assert!(!path_ends_with_ignore_ascii_case(
            Path::new("Yaak.app"),
            Path::new("")
        ));
        assert!(!path_ends_with_ignore_ascii_case(
            Path::new("Yaak.app"),
            Path::new("/Yaak.app")
        ));
    }

    #[test]
    fn maps_preflight_generated_wrapper_from_extract_stage() -> Result<()> {
        // VLC: preflight writes `#{staged_path}/vlc.wrapper.sh` while preflight
        // staged_path is the extract stage, not the temp Caskroom. API binary
        // source is `$HOMEBREW_PREFIX/Caskroom/vlc/<ver>/vlc.wrapper.sh`.
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let _guard = BrewPrefixGuard::set(&prefix);
        let cask = test_cask("vlc", "3.0.23");
        let stage = tmp.path().join("extract");
        let tmp_caskroom = tmp.path().join("tmp-caskroom");
        file::create_dir_all(&stage)?;
        file::create_dir_all(&tmp_caskroom)?;
        let wrapper = stage.join("vlc.wrapper.sh");
        std::fs::write(&wrapper, "#!/bin/sh\n")?;

        let binary = BinaryArtifact {
            source: "$HOMEBREW_PREFIX/Caskroom/vlc/3.0.23/vlc.wrapper.sh".to_string(),
            target: Some("vlc".to_string()),
        };

        assert_eq!(
            find_binary_source(&stage, &tmp_caskroom, &cask, &binary)?,
            wrapper
        );
        Ok(())
    }

    #[test]
    fn prefers_temp_caskroom_wrapper_over_extract_stage() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let _guard = BrewPrefixGuard::set(&prefix);
        let cask = test_cask("vlc", "3.0.23");
        let stage = tmp.path().join("extract");
        let tmp_caskroom = tmp.path().join("tmp-caskroom");
        file::create_dir_all(&stage)?;
        file::create_dir_all(&tmp_caskroom)?;
        std::fs::write(stage.join("vlc.wrapper.sh"), "#!/bin/sh\necho stage\n")?;
        let preferred = tmp_caskroom.join("vlc.wrapper.sh");
        std::fs::write(&preferred, "#!/bin/sh\necho caskroom\n")?;

        let binary = BinaryArtifact {
            source: "$HOMEBREW_PREFIX/Caskroom/vlc/3.0.23/vlc.wrapper.sh".to_string(),
            target: Some("vlc".to_string()),
        };

        assert_eq!(
            find_binary_source(&stage, &tmp_caskroom, &cask, &binary)?,
            preferred
        );
        Ok(())
    }

    #[test]
    fn parses_app_artifact_targets() {
        let value: Value =
            serde_json::json!({"app": ["Firefox.app", {"target": "Firefox Nightly.app"}]});
        assert_eq!(
            parse_app_artifact(&value),
            Some(AppArtifact {
                source: "Firefox.app".to_string(),
                target: Some("Firefox Nightly.app".to_string())
            })
        );
    }

    #[test]
    fn parses_binary_artifact_targets() {
        let value: Value =
            serde_json::json!({"binary": ["op"], "target": "$HOMEBREW_PREFIX/bin/op"});
        assert_eq!(
            parse_binary_artifact(&value),
            Some(BinaryArtifact {
                source: "op".to_string(),
                target: Some("$HOMEBREW_PREFIX/bin/op".to_string())
            })
        );
    }

    #[test]
    fn parses_binary_artifacts_and_generated_completions() -> Result<()> {
        let mut cask = test_cask("1password-cli", "2.34.1");
        cask.artifacts = vec![
            serde_json::json!({"binary": ["op"], "target": "$HOMEBREW_PREFIX/bin/op"}),
            serde_json::json!({
                "generate_completions_from_executable": [
                    "op",
                    "completion",
                    {"shells": ["bash", "zsh", "fish"]}
                ]
            }),
            serde_json::json!({"zap": [{"trash": "~/.config/op"}]}),
        ];

        assert_eq!(
            cask_artifacts(&cask)?,
            CaskArtifacts {
                binaries: vec![BinaryArtifact {
                    source: "op".to_string(),
                    target: Some("$HOMEBREW_PREFIX/bin/op".to_string())
                }],
                generated_completions: vec![GeneratedCompletionArtifact {
                    executable: "op".to_string(),
                    args: vec!["completion".to_string()],
                    base_name: None,
                    shell_parameter_format: None,
                    shells: vec![
                        CompletionShell::Bash,
                        CompletionShell::Zsh,
                        CompletionShell::Fish,
                    ],
                }],
                ..Default::default()
            }
        );
        Ok(())
    }

    #[test]
    fn rejects_generated_completions_with_no_shells() {
        let value = serde_json::json!({
            "generate_completions_from_executable": ["op", {"shells": []}]
        });

        let err = parse_generated_completion_artifact(&value)
            .unwrap_err()
            .to_string();

        assert!(err.contains("requires at least one shell"));
    }

    #[test]
    fn parses_declared_completion_artifacts() -> Result<()> {
        let mut cask = test_cask("ghostty", "1.2.0");
        cask.artifacts = vec![
            serde_json::json!({"app": "Ghostty.app"}),
            serde_json::json!({
                "bash_completion": [
                    "$APPDIR/Ghostty.app/Contents/Resources/bash-completion/completions/ghostty.bash"
                ],
                "target": "$HOMEBREW_PREFIX/etc/bash_completion.d/ghostty"
            }),
            serde_json::json!({
                "fish_completion": [
                    "$APPDIR/Ghostty.app/Contents/Resources/fish/vendor_completions.d/ghostty.fish"
                ],
                "target": "$HOMEBREW_PREFIX/share/fish/vendor_completions.d/ghostty.fish"
            }),
            serde_json::json!({
                "zsh_completion": [
                    "$APPDIR/Ghostty.app/Contents/Resources/zsh/site-functions/_ghostty"
                ],
                "target": "$HOMEBREW_PREFIX/share/zsh/site-functions/_ghostty"
            }),
        ];

        assert_eq!(
            cask_artifacts(&cask)?.completions,
            vec![
                CompletionArtifact {
                    shell: CompletionShell::Bash,
                    source: "$APPDIR/Ghostty.app/Contents/Resources/bash-completion/completions/ghostty.bash"
                        .to_string(),
                    target: Some("$HOMEBREW_PREFIX/etc/bash_completion.d/ghostty".to_string()),
                },
                CompletionArtifact {
                    shell: CompletionShell::Fish,
                    source: "$APPDIR/Ghostty.app/Contents/Resources/fish/vendor_completions.d/ghostty.fish"
                        .to_string(),
                    target: Some(
                        "$HOMEBREW_PREFIX/share/fish/vendor_completions.d/ghostty.fish"
                            .to_string()
                    ),
                },
                CompletionArtifact {
                    shell: CompletionShell::Zsh,
                    source: "$APPDIR/Ghostty.app/Contents/Resources/zsh/site-functions/_ghostty"
                        .to_string(),
                    target: Some("$HOMEBREW_PREFIX/share/zsh/site-functions/_ghostty".to_string()),
                },
            ]
        );
        Ok(())
    }

    #[test]
    fn completion_target_paths_match_homebrew_names() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());

        assert_eq!(
            completion_target_path(CompletionShell::Bash, "ghostty.bash")?,
            tmp.path().join("etc/bash_completion.d/ghostty")
        );
        assert_eq!(
            completion_target_path(CompletionShell::Fish, "ghostty")?,
            tmp.path()
                .join("share/fish/vendor_completions.d/ghostty.fish")
        );
        assert_eq!(
            completion_target_path(CompletionShell::Zsh, "ghostty")?,
            tmp.path().join("share/zsh/site-functions/_ghostty")
        );
        assert_eq!(
            generated_completion_target_path(CompletionShell::Pwsh, "ghostty")?,
            tmp.path().join("share/pwsh/completions/_ghostty.ps1")
        );
        Ok(())
    }

    #[test]
    fn stages_and_links_declared_completion() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        let caskroom = tmp.path().join("caskroom");
        file::create_dir_all(stage.join("completions"))?;
        file::create_dir_all(&caskroom)?;
        crate::file::write(stage.join("completions/ghostty.bash"), "complete")?;
        let cask = test_cask("ghostty", "1.0.0");
        let completion = CompletionArtifact {
            shell: CompletionShell::Bash,
            source: "completions/ghostty.bash".to_string(),
            target: None,
        };
        let artifacts = CaskArtifacts {
            completions: vec![completion.clone()],
            ..Default::default()
        };
        let target = completion.target_path()?;

        stage_completion(&stage, &caskroom, &cask, &[], &completion)?;
        link_completion(&cask, &artifacts, &caskroom, &target)?;

        assert_eq!(
            crate::file::read_to_string(caskroom.join("etc/bash_completion.d/ghostty"))?,
            "complete"
        );
        assert_eq!(
            std::fs::read_link(&target)?,
            caskroom.join("etc/bash_completion.d/ghostty")
        );
        assert_eq!(crate::file::read_to_string(target)?, "complete");
        Ok(())
    }

    #[test]
    fn declared_completion_source_maps_caskroom_path_to_temp_caskroom() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        let caskroom = tmp.path().join("tmp-caskroom");
        let cask = test_cask("foo", "1.0.0");
        file::create_dir_all(&stage)?;
        file::create_dir_all(caskroom.join("etc/bash_completion.d"))?;
        crate::file::write(caskroom.join("etc/bash_completion.d/foo"), "complete")?;
        let completion = CompletionArtifact {
            shell: CompletionShell::Bash,
            source: "$HOMEBREW_PREFIX/Caskroom/foo/1.0.0/etc/bash_completion.d/foo".to_string(),
            target: Some("$HOMEBREW_PREFIX/etc/bash_completion.d/foo".to_string()),
        };

        stage_completion(&stage, &caskroom, &cask, &[], &completion)?;

        assert_eq!(
            crate::file::read_to_string(caskroom.join("etc/bash_completion.d/foo"))?,
            "complete"
        );
        Ok(())
    }

    #[test]
    fn declared_completion_source_maps_caskroom_path_to_extract_stage() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        let caskroom = tmp.path().join("tmp-caskroom");
        let cask = test_cask("foo", "1.0.0");
        file::create_dir_all(stage.join("share/completions"))?;
        file::create_dir_all(&caskroom)?;
        crate::file::write(stage.join("share/completions/foo.bash"), "complete")?;
        let completion = CompletionArtifact {
            shell: CompletionShell::Bash,
            source: "$HOMEBREW_PREFIX/Caskroom/foo/1.0.0/share/completions/foo.bash".to_string(),
            target: Some("$HOMEBREW_PREFIX/etc/bash_completion.d/foo".to_string()),
        };

        stage_completion(&stage, &caskroom, &cask, &[], &completion)?;

        assert_eq!(
            crate::file::read_to_string(caskroom.join("etc/bash_completion.d/foo"))?,
            "complete"
        );
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn link_completion_adopts_homebrew_app_symlink() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("docker-desktop", "2.0.0");
        let caskroom = caskroom_version_dir(&cask.token, &cask.version);
        let app = AppArtifact {
            source: "Docker.app".to_string(),
            target: Some("$HOMEBREW_PREFIX/Applications/Docker.app".to_string()),
        };
        let completion = CompletionArtifact {
            shell: CompletionShell::Bash,
            source: "$APPDIR/Docker.app/Contents/Resources/etc/docker.bash-completion".to_string(),
            target: Some("$HOMEBREW_PREFIX/etc/bash_completion.d/docker".to_string()),
        };
        let artifacts = CaskArtifacts {
            apps: vec![app.clone()],
            completions: vec![completion.clone()],
            ..Default::default()
        };
        let relative = Path::new("etc/bash_completion.d/docker");
        let target = tmp.path().join(relative);
        let caskroom_completion = caskroom.join(relative);
        let app_completion = app_target_path(app.target_name())?
            .join("Contents/Resources/etc/docker.bash-completion");
        file::create_dir_all(caskroom_completion.parent().unwrap())?;
        file::create_dir_all(app_completion.parent().unwrap())?;
        file::create_dir_all(target.parent().unwrap())?;
        crate::file::write(&caskroom_completion, "new")?;
        crate::file::write(&app_completion, "homebrew")?;
        file::make_symlink(&app_completion, &target)?;

        link_completion(&cask, &artifacts, &caskroom, &target)?;

        assert_eq!(std::fs::read_link(&target)?, caskroom_completion);
        assert_eq!(crate::file::read_to_string(target)?, "new");
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn link_completion_rejects_other_file_in_declared_app() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("foo", "2.0.0");
        let app = AppArtifact {
            source: "Example.app".to_string(),
            target: Some("$HOMEBREW_PREFIX/Applications/Example.app".to_string()),
        };
        let completion = CompletionArtifact {
            shell: CompletionShell::Bash,
            source: "$APPDIR/Example.app/Contents/Resources/etc/expected.bash".to_string(),
            target: Some("$HOMEBREW_PREFIX/etc/bash_completion.d/foo".to_string()),
        };
        let artifacts = CaskArtifacts {
            apps: vec![app.clone()],
            completions: vec![completion.clone()],
            ..Default::default()
        };
        let target = completion.target_path()?;
        let app_resources = app_target_path(app.target_name())?.join("Contents/Resources/etc");
        let expected = app_resources.join("expected.bash");
        let other = app_resources.join("other.bash");
        file::create_dir_all(&app_resources)?;
        file::create_dir_all(target.parent().unwrap())?;
        crate::file::write(expected, "expected")?;
        crate::file::write(&other, "other")?;
        file::make_symlink(&other, &target)?;

        let err = ensure_completion_target_replaceable(&cask, &artifacts, &target)
            .unwrap_err()
            .to_string();

        assert!(err.contains("is not owned by cask 'foo'"));
        assert_eq!(std::fs::read_link(&target)?, other);
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn link_completion_rejects_target_owned_by_another_cask() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("foo", "2.0.0");
        let caskroom = caskroom_version_dir(&cask.token, &cask.version);
        let other_caskroom = caskroom_version_dir("other", "1.0.0");
        let relative = Path::new("etc/bash_completion.d/foo");
        let target = tmp.path().join(relative);
        file::create_dir_all(caskroom.join("etc/bash_completion.d"))?;
        file::create_dir_all(other_caskroom.join("etc/bash_completion.d"))?;
        file::create_dir_all(target.parent().unwrap())?;
        crate::file::write(caskroom.join(relative), "new")?;
        crate::file::write(other_caskroom.join(relative), "other")?;
        file::make_symlink(&other_caskroom.join(relative), &target)?;

        let err = link_completion(&cask, &CaskArtifacts::default(), &caskroom, &target)
            .unwrap_err()
            .to_string();

        assert!(err.contains("is not owned by cask 'foo'"));
        assert_eq!(std::fs::read_link(&target)?, other_caskroom.join(relative));
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn stages_generated_completion_output() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        let caskroom = tmp.path().join("caskroom");
        file::create_dir_all(&stage)?;
        file::create_dir_all(&caskroom)?;
        let executable = stage.join("op");
        crate::file::write(
            &executable,
            "#!/bin/sh\nprintf '%s|%s|%s' \"$1\" \"$2\" \"$SHELL\"\n",
        )?;
        let cask = test_cask("1password-cli", "2.34.1");
        let completion = GeneratedCompletionArtifact {
            executable: "op".to_string(),
            args: vec!["completion".to_string()],
            base_name: None,
            shell_parameter_format: None,
            shells: vec![CompletionShell::Bash],
        };

        stage_generated_completions(&stage, &caskroom, &cask, &[], &completion)?;

        assert_eq!(
            crate::file::read_to_string(caskroom.join("etc/bash_completion.d/op"))?,
            "completion|bash|bash"
        );
        Ok(())
    }

    #[test]
    fn generated_completion_executable_expands_appdir() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        let caskroom = tmp.path().join("caskroom");
        file::create_dir_all(&stage)?;
        file::create_dir_all(&caskroom)?;
        let app_executable = tmp.path().join("Applications/Foo.app/Contents/MacOS/foo");
        file::create_dir_all(app_executable.parent().unwrap())?;
        crate::file::write(&app_executable, "app cli")?;
        let cask = test_cask("foo", "1.0.0");
        let completion = GeneratedCompletionArtifact {
            executable: "$APPDIR/Foo.app/Contents/MacOS/foo".to_string(),
            args: vec![],
            base_name: None,
            shell_parameter_format: None,
            shells: vec![CompletionShell::Bash],
        };
        let apps = [AppArtifact {
            source: "Foo.app".to_string(),
            target: Some("$HOMEBREW_PREFIX/Applications/Foo.app".to_string()),
        }];

        assert_eq!(
            find_generated_completion_executable(&stage, &caskroom, &cask, &apps, &completion,)?,
            app_executable
        );
        Ok(())
    }

    #[test]
    fn appdir_artifact_source_matches_app_case_insensitively() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let prefix_appdir = tmp.path().join("Applications");
        let relative = "foo.app/Contents/MacOS/foo";
        file::create_dir_all(prefix_appdir.join(relative).parent().unwrap())?;
        crate::file::write(prefix_appdir.join(relative), "prefix")?;
        let apps = [
            AppArtifact {
                source: "Other.app".to_string(),
                target: None,
            },
            AppArtifact {
                source: "foo.app".to_string(),
                target: Some("$HOMEBREW_PREFIX/Applications/foo.app".to_string()),
            },
        ];

        assert_eq!(
            appdir_artifact_source("$APPDIR/Foo.app/Contents/MacOS/foo", &apps)?,
            Some(prefix_appdir.join(relative)),
        );
        Ok(())
    }

    #[test]
    fn generated_completion_executable_prefers_staged_prefix_binary() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        let caskroom = tmp.path().join("caskroom");
        file::create_dir_all(tmp.path().join("bin"))?;
        file::create_dir_all(caskroom.join("bin"))?;
        crate::file::write(tmp.path().join("bin/op"), "old")?;
        crate::file::write(caskroom.join("bin/op"), "new")?;
        let cask = test_cask("1password-cli", "2.34.1");
        let completion = GeneratedCompletionArtifact {
            executable: "$HOMEBREW_PREFIX/bin/op".to_string(),
            args: vec![],
            base_name: None,
            shell_parameter_format: None,
            shells: vec![CompletionShell::Bash],
        };

        assert_eq!(
            find_generated_completion_executable(&stage, &caskroom, &cask, &[], &completion,)?,
            caskroom.join("bin/op")
        );
        Ok(())
    }

    #[test]
    fn rejects_ambiguous_generated_completion_bare_executable() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        let caskroom = tmp.path().join("caskroom");
        file::create_dir_all(stage.join("a"))?;
        file::create_dir_all(stage.join("b"))?;
        file::create_dir_all(&caskroom)?;
        crate::file::write(stage.join("a/tool"), "a")?;
        crate::file::write(stage.join("b/tool"), "b")?;
        let cask = test_cask("tool", "1.0.0");
        let completion = GeneratedCompletionArtifact {
            executable: "tool".to_string(),
            args: vec![],
            base_name: None,
            shell_parameter_format: None,
            shells: vec![CompletionShell::Bash],
        };

        let err = find_generated_completion_executable(&stage, &caskroom, &cask, &[], &completion)
            .unwrap_err();
        assert!(
            err.to_string()
                .contains("completion executable 'tool' is ambiguous")
        );
        Ok(())
    }

    #[test]
    fn rejects_ambiguous_generated_completion_nested_executable() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let stage = tmp.path().join("stage");
        let caskroom = tmp.path().join("caskroom");
        file::create_dir_all(stage.join("a/bin"))?;
        file::create_dir_all(stage.join("b/bin"))?;
        file::create_dir_all(&caskroom)?;
        crate::file::write(stage.join("a/bin/tool"), "a")?;
        crate::file::write(stage.join("b/bin/tool"), "b")?;
        let cask = test_cask("tool", "1.0.0");
        let completion = GeneratedCompletionArtifact {
            executable: "bin/tool".to_string(),
            args: vec![],
            base_name: None,
            shell_parameter_format: None,
            shells: vec![CompletionShell::Bash],
        };

        let err = find_generated_completion_executable(&stage, &caskroom, &cask, &[], &completion)
            .unwrap_err();

        assert!(
            err.to_string()
                .contains("completion executable 'bin/tool' is ambiguous")
        );
        Ok(())
    }

    #[test]
    fn remove_obsolete_completions_removes_only_caskroom_symlinks() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("foo", "2.0.0");
        let old_caskroom = caskroom_version_dir(&cask.token, "1.0.0");
        let other_caskroom = caskroom_version_dir("other", "1.0.0");
        let relative = Path::new("etc/bash_completion.d/foo");
        let target = tmp.path().join(relative);
        let dangling_target = tmp.path().join("etc/bash_completion.d/dangling-foo");
        let other_target = tmp.path().join("etc/bash_completion.d/other-foo");
        let regular_target = tmp.path().join("etc/bash_completion.d/regular-foo");
        file::create_dir_all(old_caskroom.join("etc/bash_completion.d"))?;
        file::create_dir_all(other_caskroom.join("etc/bash_completion.d"))?;
        file::create_dir_all(target.parent().unwrap())?;
        crate::file::write(old_caskroom.join(relative), "old")?;
        crate::file::write(other_caskroom.join(relative), "old")?;
        crate::file::write(&regular_target, "old")?;
        file::make_symlink(&old_caskroom.join(relative), &target)?;
        file::make_symlink(
            &old_caskroom.join("etc/bash_completion.d/dangling"),
            &dangling_target,
        )?;
        file::make_symlink(&other_caskroom.join(relative), &other_target)?;

        remove_obsolete_completions(
            &cask,
            &[
                target.clone(),
                dangling_target.clone(),
                other_target.clone(),
                regular_target.clone(),
            ],
            &[],
        )?;

        assert!(target.symlink_metadata().is_err());
        assert!(dangling_target.symlink_metadata().is_err());
        assert!(other_target.symlink_metadata().is_ok());
        assert!(regular_target.symlink_metadata().is_ok());
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn remove_obsolete_completions_removes_dangling_symlinks_with_symlinked_prefix() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let real_prefix = tmp.path().join("homebrew-real");
        let prefix = tmp.path().join("homebrew");
        file::create_dir_all(&real_prefix)?;
        file::make_symlink(&real_prefix, &prefix)?;
        let _guard = BrewPrefixGuard::set(&prefix);
        let cask = test_cask("foo", "2.0.0");
        let old_caskroom = caskroom_version_dir(&cask.token, "1.0.0");
        let relative = Path::new("etc/bash_completion.d/dangling");
        let target = prefix.join("etc/bash_completion.d/foo");
        file::create_dir_all(old_caskroom.join("etc/bash_completion.d"))?;
        file::create_dir_all(target.parent().unwrap())?;
        file::make_symlink(&old_caskroom.join(relative), &target)?;

        remove_obsolete_completions(&cask, std::slice::from_ref(&target), &[])?;

        assert!(target.symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    fn completion_shell_parameter_formats_match_homebrew() {
        let (args, env) =
            completion_shell_parameter(Some("cobra"), CompletionShell::Zsh, Path::new("tool"));
        assert_eq!(args, vec!["completion".to_string(), "zsh".to_string()]);
        assert_eq!(env, Vec::<(String, String)>::new());

        let (args, env) =
            completion_shell_parameter(Some("click"), CompletionShell::Fish, Path::new("my-tool"));
        assert!(args.is_empty());
        assert_eq!(
            env,
            vec![("_MY_TOOL_COMPLETE".to_string(), "fish_source".to_string())]
        );

        let (args, env) =
            completion_shell_parameter(Some("clap"), CompletionShell::Bash, Path::new("tool"));
        assert!(args.is_empty());
        assert_eq!(env, vec![("COMPLETE".to_string(), "bash".to_string())]);

        let (args, env) = completion_shell_parameter(
            Some("--autocomplete=init:"),
            CompletionShell::Pwsh,
            Path::new("tool"),
        );
        assert_eq!(args, vec!["--autocomplete=init:powershell".to_string()]);
        assert_eq!(env, Vec::<(String, String)>::new());
    }

    #[test]
    fn detects_lifecycle_hooks() {
        let mut cask = test_cask("gimp", "3.2.4");
        cask.artifacts = vec![
            serde_json::json!({"preflight": null}),
            serde_json::json!({"app": ["GIMP.app"]}),
        ];

        assert!(has_lifecycle_hook(&cask, "preflight"));
        assert!(!has_lifecycle_hook(&cask, "postflight"));
    }

    #[test]
    fn maps_generated_caskroom_binary_to_temp_caskroom() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let _guard = BrewPrefixGuard::set(&prefix);
        let cask = test_cask("gimp", "3.2.4");
        let tmp_caskroom = tmp.path().join("tmp-caskroom");
        let generated = tmp_caskroom.join("gimp.wrapper.sh");
        file::create_dir_all(&tmp_caskroom)?;
        std::fs::write(&generated, "#!/bin/sh\n")?;

        let source = "$HOMEBREW_PREFIX/Caskroom/gimp/3.2.4/gimp.wrapper.sh";

        assert_eq!(
            generated_caskroom_artifact(&tmp_caskroom, &cask, source),
            Some(generated)
        );
        Ok(())
    }

    #[test]
    fn rejects_generated_caskroom_binary_parent_dirs() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let _guard = BrewPrefixGuard::set(&prefix);
        let cask = test_cask("gimp", "3.2.4");
        let tmp_caskroom = tmp.path().join("tmp-caskroom");
        let source = "$HOMEBREW_PREFIX/Caskroom/gimp/3.2.4/../escape";

        assert_eq!(
            generated_caskroom_artifact(&tmp_caskroom, &cask, source),
            None
        );
        Ok(())
    }

    #[test]
    fn parses_pkg_artifacts() {
        let value: Value = serde_json::json!({"pkg": ["OpenJDK.pkg"]});
        assert_eq!(
            parse_pkg_artifact(&value).unwrap(),
            Some(PkgArtifact {
                source: "OpenJDK.pkg".to_string()
            })
        );
    }

    #[test]
    fn parses_and_installs_generic_artifact() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        let source = stage.join("libcblite-4.1.0/include/cbl");
        file::create_dir_all(&source)?;
        file::write(source.join("CouchbaseLite.h"), "header")?;
        let value = serde_json::json!({
            "artifact": [
                "libcblite-4.1.0/include/cbl",
                {"target": "$HOMEBREW_PREFIX/include/cbl"}
            ],
            "target": "$HOMEBREW_PREFIX/include/cbl"
        });
        let artifact = parse_generic_artifact(&value)?.ok_or_else(|| eyre!("missing artifact"))?;
        assert_eq!(
            artifact,
            GenericArtifact {
                source: "libcblite-4.1.0/include/cbl".to_string(),
                target: "$HOMEBREW_PREFIX/include/cbl".to_string(),
            }
        );

        let mut targets = FlightTargetTransaction::default();
        let temporary_caskroom = tmp.path().join("Caskroom/example/.mise-tmp");
        install_generic_artifact(&stage, &temporary_caskroom, &artifact, &mut targets)?;
        assert_eq!(
            file::read_to_string(tmp.path().join("include/cbl/CouchbaseLite.h"))?,
            "header"
        );
        assert_eq!(
            file::read_to_string(
                temporary_caskroom.join("libcblite-4.1.0/include/cbl/CouchbaseLite.h")
            )?,
            "header"
        );
        assert_eq!(
            std::fs::read_link(temporary_caskroom.join("libcblite-4.1.0/include/cbl"))?,
            tmp.path().join("include/cbl")
        );
        assert_eq!(
            targets.installed_targets(),
            [tmp.path().join("include/cbl")]
        );
        assert_eq!(targets.backups.len(), 1);
        assert!(!targets.backups[0].elevate);
        targets.commit()?;
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn generic_artifact_rejects_extraction_source_symlink_escape() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        let outside = tmp.path().join("outside");
        file::create_dir_all(&stage)?;
        file::create_dir_all(&outside)?;
        file::write(outside.join("secret"), "external")?;
        file::make_symlink(&outside, &stage.join("payload"))?;
        let artifact = GenericArtifact {
            source: "payload".to_string(),
            target: "$HOMEBREW_PREFIX/share/example".to_string(),
        };
        let mut targets = FlightTargetTransaction::default();

        let err = install_generic_artifact(
            &stage,
            &tmp.path().join("Caskroom/example/.mise-tmp"),
            &artifact,
            &mut targets,
        )
        .unwrap_err()
        .to_string();

        assert!(err.contains("outside the extraction root"));
        assert!(tmp.path().join("share/example").symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn generic_artifact_rejects_caskroom_source_symlink_escape() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        let source = stage.join("payload/include/example");
        file::create_dir_all(&source)?;
        file::write(source.join("example.h"), "header")?;
        let temporary_caskroom = tmp.path().join("Caskroom/example/.mise-tmp");
        let outside = tmp.path().join("outside");
        file::create_dir_all(&temporary_caskroom)?;
        file::create_dir_all(&outside)?;
        file::make_symlink(&outside, &temporary_caskroom.join("payload"))?;
        let artifact = GenericArtifact {
            source: "payload/include/example".to_string(),
            target: "$HOMEBREW_PREFIX/include/example".to_string(),
        };
        let mut targets = FlightTargetTransaction::default();

        let err = install_generic_artifact(&stage, &temporary_caskroom, &artifact, &mut targets)
            .unwrap_err()
            .to_string();

        assert!(err.contains("outside the caskroom"));
        assert!(
            tmp.path()
                .join("include/example")
                .symlink_metadata()
                .is_err()
        );
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn rejects_generic_artifact_target_through_external_symlink() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let external = tmp.path().join("external");
        file::create_dir_all(&prefix)?;
        file::create_dir_all(&external)?;
        std::os::unix::fs::symlink(&external, prefix.join("lib"))?;
        let _guard = BrewPrefixGuard::set(&prefix);

        let err = generic_artifact_target_path("$HOMEBREW_PREFIX/lib/example")
            .unwrap_err()
            .to_string();
        assert!(err.contains("must stay below"));
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn generic_copy_revalidates_target_after_symlink_swap() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let external = tmp.path().join("external");
        let library = prefix.join("lib");
        file::create_dir_all(&library)?;
        file::create_dir_all(&external)?;
        let _guard = BrewPrefixGuard::set(&prefix);
        let target = generic_artifact_target_path("$HOMEBREW_PREFIX/lib/example")?;

        std::fs::remove_dir(&library)?;
        std::os::unix::fs::symlink(&external, &library)?;

        let err = validate_generic_copy_target(&target)
            .unwrap_err()
            .to_string();
        assert!(err.contains("refusing generic artifact copy outside Homebrew prefix"));
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn trusted_operation_parent_stays_bound_after_ancestor_swap() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let library = prefix.join("lib");
        file::create_dir_all(&library)?;
        let external = tmp.path().join("external");
        file::create_dir_all(external.join("lib"))?;
        let _guard = BrewPrefixGuard::set(&prefix);
        let target = library.join("example");
        let parent = open_trusted_operation_parent(&target, true, false)?;
        let source = tmp.path().join("source");
        file::create_dir_all(&source)?;
        file::write(source.join("payload"), "installed")?;

        let saved_prefix = tmp.path().join("saved-homebrew");
        file::rename(&prefix, &saved_prefix)?;
        file::make_symlink(&external, &prefix)?;
        copy_cask_artifact_at(&source, &parent.fd, std::ffi::OsStr::new("example"))?;

        assert_eq!(
            file::read_to_string(saved_prefix.join("lib/example/payload"))?,
            "installed"
        );
        assert!(external.join("lib/example").symlink_metadata().is_err());
        remove_all_at(&parent.fd, std::ffi::OsStr::new("example"))?;
        assert!(saved_prefix.join("lib/example").symlink_metadata().is_err());

        file::remove_file(&prefix)?;
        file::rename(&saved_prefix, &prefix)?;
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn trusted_operation_parent_accepts_symlinked_prefix() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let real_prefix = tmp.path().join("real-homebrew");
        file::create_dir_all(real_prefix.join("lib"))?;
        let configured_prefix = tmp.path().join("homebrew");
        file::make_symlink(&real_prefix, &configured_prefix)?;
        let _guard = BrewPrefixGuard::set(&configured_prefix);
        let target = configured_prefix.join("lib/example");

        let parent = open_trusted_operation_parent(&target, true, false)?;
        assert_eq!(
            parent.stable_path()?,
            std::fs::canonicalize(real_prefix.join("lib"))?
        );
        file::write(parent.path()?.join("example"), "installed")?;

        assert_eq!(
            file::read_to_string(real_prefix.join("lib/example"))?,
            "installed"
        );
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn trusted_operation_parent_creates_missing_directories() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        file::create_dir_all(&prefix)?;
        let _guard = BrewPrefixGuard::set(&prefix);
        let target = prefix.join("share/example/include/example.h");

        let parent = open_trusted_operation_parent(&target, true, true)?;

        file::write(parent.path()?.join("example.h"), "header")?;
        assert_eq!(file::read_to_string(&target)?, "header");
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn sudo_invoking_ids_are_trusted_only_for_effective_root() {
        assert_eq!(sudo_invoking_id_from(0, Some("501")), Some(501));
        assert_eq!(sudo_invoking_id_from(1000, Some("501")), None);
        assert_eq!(sudo_invoking_id_from(0, Some("0")), None);
        assert_eq!(sudo_invoking_id_from(0, Some("invalid")), None);
    }

    #[test]
    fn permission_detection_follows_wrapped_error_sources() {
        let err = eyre::Report::from(nix::errno::Errno::EACCES)
            .wrap_err("cannot create operation directory");

        assert!(is_permission_denied(&err));
    }

    #[test]
    #[cfg(unix)]
    fn elevated_generic_target_allows_only_group_writable_prefix() {
        let prefix = Path::new("/usr/local");

        assert!(strict_elevated_directory_is_trusted(
            prefix, prefix, 0, 0o775
        ));
        assert!(!strict_elevated_directory_is_trusted(
            Path::new("/usr/local/include"),
            prefix,
            0,
            0o775,
        ));
        assert!(!strict_elevated_directory_is_trusted(
            prefix, prefix, 0, 0o777
        ));
        assert!(!strict_elevated_directory_is_trusted(
            prefix, prefix, 501, 0o755
        ));
    }

    #[test]
    #[cfg(unix)]
    fn unprivileged_generic_rollback_restores_backup_when_target_is_absent() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let target = prefix.join("include/example.h");
        file::create_dir_all(target.parent().unwrap())?;
        file::write(&target, "original")?;
        let _guard = BrewPrefixGuard::set(&prefix);
        let mut transaction = FlightTargetTransaction::default();

        transaction.protect_generic(&target)?;
        assert!(target.symlink_metadata().is_err());
        transaction.rollback()?;

        assert_eq!(file::read_to_string(&target)?, "original");
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn trusted_generic_rename_rejects_swapped_prefix() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let library = prefix.join("lib");
        file::create_dir_all(&library)?;
        let _guard = BrewPrefixGuard::set(&prefix);
        let target = library.join("example");
        let backup = library.join("example.backup");
        let expected_parent = resolved_parent(&target)?;
        file::write(&backup, "original")?;

        let saved_prefix = tmp.path().join("saved-homebrew");
        file::rename(&prefix, &saved_prefix)?;
        let external = tmp.path().join("external");
        file::create_dir_all(external.join("lib"))?;
        file::write(external.join("lib/example"), "external")?;
        file::write(external.join("lib/example.backup"), "attacker")?;
        file::make_symlink(&external, &prefix)?;

        let err = rename_trusted_generic_target(&backup, &target, &expected_parent)
            .unwrap_err()
            .to_string();

        assert!(err.contains("changed generic artifact parent"));
        assert_eq!(
            file::read_to_string(external.join("lib/example"))?,
            "external"
        );
        assert_eq!(
            file::read_to_string(external.join("lib/example.backup"))?,
            "attacker"
        );
        file::remove_file(&prefix)?;
        file::rename(&saved_prefix, &prefix)?;
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn private_staging_cleanup_rejects_replaced_directory() -> Result<()> {
        use std::os::unix::fs::PermissionsExt;

        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let library = prefix.join("lib");
        file::create_dir_all(&library)?;
        let _guard = BrewPrefixGuard::set(&prefix);
        let parent = open_trusted_operation_parent(&library.join("target"), true, false)?;
        let staging_name = std::ffi::OsStr::new(".mise-copy-test");
        let staging_path = library.join(staging_name);
        file::create_dir_all(&staging_path)?;
        std::fs::set_permissions(&staging_path, std::fs::Permissions::from_mode(0o700))?;
        let staging = TrustedOperationParent {
            fd: nix::fcntl::openat(
                &parent.fd,
                staging_name,
                nix::fcntl::OFlag::O_RDONLY
                    | nix::fcntl::OFlag::O_DIRECTORY
                    | nix::fcntl::OFlag::O_NOFOLLOW,
                nix::sys::stat::Mode::empty(),
            )?,
        };
        let saved = library.join("saved-staging");
        file::rename(&staging_path, &saved)?;
        file::create_dir_all(&staging_path)?;

        let err = remove_private_staging_dir(&parent, &staging, staging_name)
            .unwrap_err()
            .to_string();

        assert!(err.contains("was replaced"));
        assert!(staging_path.is_dir());
        assert!(saved.is_dir());
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn obsolete_generic_cleanup_skips_mutable_parent_directories() -> Result<()> {
        use std::os::unix::fs::PermissionsExt;

        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let obsolete = tmp.path().join("lib/obsolete");
        let modified = tmp.path().join("lib/modified");
        file::create_dir_all(obsolete.parent().unwrap())?;
        std::fs::set_permissions(
            obsolete.parent().unwrap(),
            std::fs::Permissions::from_mode(0o777),
        )?;
        file::write(&obsolete, "owned")?;
        file::write(&modified, "owned")?;
        let records = vec![
            CaskTargetRecord {
                path: obsolete.clone(),
                fingerprint: cask_target_fingerprint(&obsolete)?,
                uninstall: None,
            },
            CaskTargetRecord {
                path: modified.clone(),
                fingerprint: cask_target_fingerprint(&modified)?,
                uninstall: None,
            },
        ];
        file::write(&modified, "user change")?;

        remove_obsolete_generic_artifacts(&records, &[])?;

        assert_eq!(file::read_to_string(obsolete)?, "owned");
        assert_eq!(file::read_to_string(modified)?, "user change");
        Ok(())
    }

    #[test]
    #[cfg(unix)]
    fn obsolete_generic_cleanup_allows_owner_group_writable_prefix() -> Result<()> {
        use std::os::unix::fs::PermissionsExt;

        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let prefix = tmp.path().join("homebrew");
        let library = prefix.join("lib");
        file::create_dir_all(&library)?;
        std::fs::set_permissions(&prefix, std::fs::Permissions::from_mode(0o775))?;
        std::fs::set_permissions(&library, std::fs::Permissions::from_mode(0o775))?;
        let _guard = BrewPrefixGuard::set(&prefix);
        let obsolete = library.join("obsolete");
        file::write(&obsolete, "owned")?;
        let records = vec![CaskTargetRecord {
            path: obsolete.clone(),
            fingerprint: cask_target_fingerprint(&obsolete)?,
            uninstall: None,
        }];

        remove_obsolete_generic_artifacts(&records, &[])?;

        assert!(obsolete.symlink_metadata().is_err());
        Ok(())
    }

    #[test]
    fn rejects_pkg_installer_choices() {
        let value: Value = serde_json::json!({
            "pkg": [
                "VirtualBox.pkg",
                {"choices": [{"choiceIdentifier": "choiceVBox", "attributeSetting": 1}]}
            ]
        });
        assert!(parse_pkg_artifact(&value).is_err());
    }

    #[test]
    fn parses_uninstall_pkgutil_ids() -> Result<()> {
        let mut cask = test_cask("temurin", "26.0.1,8");
        cask.artifacts = vec![
            serde_json::json!({"uninstall": [{"pkgutil": "net.temurin.26.jdk"}]}),
            serde_json::json!({"pkg": ["OpenJDK26U-jdk.pkg"]}),
        ];

        assert_eq!(
            cask_artifacts(&cask)?,
            CaskArtifacts {
                pkgs: vec![PkgArtifact {
                    source: "OpenJDK26U-jdk.pkg".to_string()
                }],
                pkg_ids: vec!["net.temurin.26.jdk".to_string()],
                ..Default::default()
            }
        );
        Ok(())
    }

    #[test]
    fn ignores_zap_pkgutil_ids_for_pkg_receipts() -> Result<()> {
        let mut cask = test_cask("google-japanese-ime", "3.33.6130");
        cask.artifacts = vec![
            serde_json::json!({"uninstall": [{"pkgutil": "com.google.pkg.GoogleJapaneseInput"}]}),
            serde_json::json!({"pkg": ["GoogleJapaneseInput.pkg"]}),
            serde_json::json!({"zap": [{"pkgutil": "com.google.pkg.Keystone"}]}),
        ];

        assert_eq!(
            cask_artifacts(&cask)?,
            CaskArtifacts {
                pkgs: vec![PkgArtifact {
                    source: "GoogleJapaneseInput.pkg".to_string()
                }],
                pkg_ids: vec!["com.google.pkg.GoogleJapaneseInput".to_string()],
                ..Default::default()
            }
        );
        Ok(())
    }

    #[test]
    fn rejects_pkg_artifacts_without_pkgutil_ids() {
        let mut cask = test_cask("example", "1.0.0");
        cask.artifacts = vec![serde_json::json!({"pkg": ["Example.pkg"]})];

        let err = cask_artifacts(&cask).unwrap_err().to_string();
        assert!(err.contains("pkg artifacts require pkgutil ids"));
    }

    #[test]
    fn rejects_pkg_artifacts_with_only_zap_pkgutil_ids() {
        let mut cask = test_cask("example", "1.0.0");
        cask.artifacts = vec![
            serde_json::json!({"pkg": ["Example.pkg"]}),
            serde_json::json!({"zap": [{"pkgutil": "com.example.cleanup"}]}),
        ];

        let err = cask_artifacts(&cask).unwrap_err().to_string();
        assert!(err.contains("pkg artifacts require pkgutil ids in uninstall metadata"));
    }

    #[test]
    fn parses_font_artifact() {
        let value: Value = serde_json::json!({"font": "SauceCodeProNerdFont-Regular.ttf"});
        assert_eq!(
            parse_font_artifact(&value),
            Some(FontArtifact {
                source: "SauceCodeProNerdFont-Regular.ttf".to_string(),
                target: None,
            })
        );
    }

    #[test]
    fn parses_font_artifact_with_target() {
        let value: Value = serde_json::json!({"font": ["SauceCodeProNerdFont-Regular.ttf", {"target": "CustomName.ttf"}]});
        assert_eq!(
            parse_font_artifact(&value),
            Some(FontArtifact {
                source: "SauceCodeProNerdFont-Regular.ttf".to_string(),
                target: Some("CustomName.ttf".to_string()),
            })
        );
    }

    #[test]
    fn parses_font_cask_artifacts() -> Result<()> {
        let mut cask = test_cask("font-sauce-code-pro-nerd-font", "3.4.0");
        cask.artifacts = vec![
            serde_json::json!({"font": "SauceCodeProNerdFont-Regular.ttf"}),
            serde_json::json!({"font": "SauceCodeProNerdFont-Bold.ttf"}),
        ];

        assert_eq!(
            cask_artifacts(&cask)?,
            CaskArtifacts {
                fonts: vec![
                    FontArtifact {
                        source: "SauceCodeProNerdFont-Regular.ttf".to_string(),
                        target: None,
                    },
                    FontArtifact {
                        source: "SauceCodeProNerdFont-Bold.ttf".to_string(),
                        target: None,
                    },
                ],
                ..Default::default()
            }
        );
        Ok(())
    }

    #[test]
    fn parses_completion_artifacts_and_skips_manpage_artifacts() -> Result<()> {
        let mut cask = test_cask("ghostty", "1.2.0");
        cask.artifacts = vec![
            serde_json::json!({"app": "Ghostty.app"}),
            serde_json::json!({"manpage": ["ghostty.1"]}),
            serde_json::json!({"bash_completion": ["ghostty"]}),
            serde_json::json!({"fish_completion": ["ghostty"]}),
            serde_json::json!({"zsh_completion": ["ghostty"]}),
        ];

        let artifacts = cask_artifacts(&cask)?;
        assert_eq!(artifacts.apps.len(), 1);
        assert_eq!(artifacts.completions.len(), 3);
        assert_eq!(artifacts.fonts.len(), 0);
        Ok(())
    }

    #[test]
    fn font_only_cask_is_valid() -> Result<()> {
        let mut cask = test_cask("font-test", "1.0.0");
        cask.artifacts = vec![serde_json::json!({"font": "TestFont.ttf"})];

        let artifacts = cask_artifacts(&cask)?;
        assert_eq!(artifacts.fonts.len(), 1);
        Ok(())
    }

    #[test]
    fn font_filename_from_source() -> Result<()> {
        let font = FontArtifact {
            source: "MyFont-Regular.ttf".to_string(),
            target: None,
        };
        assert_eq!(font_filename(&font)?, "MyFont-Regular.ttf");
        Ok(())
    }

    #[test]
    fn font_filename_simple_target() -> Result<()> {
        let font = FontArtifact {
            source: "MyFont.ttf".to_string(),
            target: Some("RenamedFont.ttf".to_string()),
        };
        assert_eq!(font_filename(&font)?, "RenamedFont.ttf");
        Ok(())
    }

    #[test]
    fn font_filename_target_with_home_and_absolute_fonts_path() -> Result<()> {
        // Simulates the JetBrainsMono pattern:
        // target: "/$HOME/Library/Fonts/JetBrainsMonoNerdFontPropo-ThinItalic.ttf"
        let target = "/$HOME/Library/Fonts/JetBrainsMonoNerdFontPropo-ThinItalic.ttf".to_string();
        let font = FontArtifact {
            source: "JetBrainsMonoNerdFontPropo-ThinItalic.ttf".to_string(),
            target: Some(target),
        };
        assert_eq!(
            font_filename(&font)?,
            "JetBrainsMonoNerdFontPropo-ThinItalic.ttf"
        );
        Ok(())
    }

    #[test]
    fn font_filename_target_with_home_expansion() -> Result<()> {
        // $HOME without leading slash: "$HOME/Library/Fonts/Font.ttf"
        let target = "$HOME/Library/Fonts/SomeFont.ttf";
        let font = FontArtifact {
            source: "SomeFont.ttf".to_string(),
            target: Some(target.to_string()),
        };
        assert_eq!(font_filename(&font)?, "SomeFont.ttf");
        Ok(())
    }

    #[test]
    fn font_filename_target_with_tilde_expansion() -> Result<()> {
        // ~/Library/Fonts/Font.ttf should expand to <home>/Library/Fonts/Font.ttf
        let target = "~/Library/Fonts/TildeFont.ttf";
        let font = FontArtifact {
            source: "TildeFont.ttf".to_string(),
            target: Some(target.to_string()),
        };
        assert_eq!(font_filename(&font)?, "TildeFont.ttf");
        Ok(())
    }

    #[test]
    fn font_target_path_from_simple_target() -> Result<()> {
        let font = FontArtifact {
            source: "MyFont.ttf".to_string(),
            target: Some("MyFont.ttf".to_string()),
        };
        let expected = font_dir().join("MyFont.ttf");
        assert_eq!(font_target_path(&font)?, expected);
        Ok(())
    }

    #[test]
    fn font_target_path_from_source_only() -> Result<()> {
        let font = FontArtifact {
            source: "FontAwesome.otf".to_string(),
            target: None,
        };
        let expected = font_dir().join("FontAwesome.otf");
        assert_eq!(font_target_path(&font)?, expected);
        Ok(())
    }

    #[test]
    fn font_target_path_with_home_absolute_target() -> Result<()> {
        // Regression: absolute target with $HOME under ~/Library/Fonts
        // should resolve to the correct path
        let target = "/$HOME/Library/Fonts/JetBrainsMono.ttf".to_string();
        let font = FontArtifact {
            source: "JetBrainsMono.ttf".to_string(),
            target: Some(target),
        };
        let expected = font_dir().join("JetBrainsMono.ttf");
        assert_eq!(font_target_path(&font)?, expected);
        Ok(())
    }

    #[test]
    fn font_target_path_with_tilde_target() -> Result<()> {
        // ~/Library/Fonts/Font.ttf should resolve to correct path
        let target = "~/Library/Fonts/TildeFont.ttf".to_string();
        let font = FontArtifact {
            source: "TildeFont.ttf".to_string(),
            target: Some(target),
        };
        let expected = font_dir().join("TildeFont.ttf");
        assert_eq!(font_target_path(&font)?, expected);
        Ok(())
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn linux_font_dir_uses_xdg_data_home() {
        assert_eq!(font_dir(), crate::env::XDG_DATA_HOME.join("fonts"));
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn linux_font_target_preserves_xdg_subdirectories() -> Result<()> {
        let target = font_dir().join("nerd-fonts").join("NestedFont.ttf");
        let font = FontArtifact {
            source: "NestedFont.ttf".to_string(),
            target: Some(target.to_string_lossy().to_string()),
        };

        assert_eq!(font_filename(&font)?, "nerd-fonts/NestedFont.ttf");
        assert_eq!(font_target_path(&font)?, target);
        Ok(())
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn linux_supports_font_only_casks() -> Result<()> {
        let mut cask = test_cask("font-test", "1.0.0");
        cask.artifacts = vec![serde_json::json!({"font": "TestFont.ttf"})];
        let artifacts = cask_artifacts(&cask)?;

        validate_platform_support(&cask, &artifacts)
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn linux_rejects_non_font_casks() -> Result<()> {
        let mut cask = test_cask("example", "1.0.0");
        cask.artifacts = vec![serde_json::json!({"app": "Example.app"})];
        let artifacts = cask_artifacts(&cask)?;

        let err = validate_platform_support(&cask, &artifacts)
            .unwrap_err()
            .to_string();
        assert!(err.contains("only font-only casks"));
        assert!(matches!(
            platform_unavailable_state(&cask, &artifacts),
            Some(PackageState::Unavailable { .. })
        ));
        Ok(())
    }

    #[test]
    fn app_only_casks_ignore_pkgutil_ids() -> Result<()> {
        let mut cask = test_cask("example", "1.0.0");
        cask.artifacts = vec![
            serde_json::json!({"uninstall": [{"pkgutil": "com.example.helper"}]}),
            serde_json::json!({"app": "Example.app"}),
        ];

        assert_eq!(
            cask_artifacts(&cask)?,
            CaskArtifacts {
                apps: vec![AppArtifact {
                    source: "Example.app".to_string(),
                    target: None,
                }],
                ..Default::default()
            }
        );
        Ok(())
    }

    #[test]
    fn binary_targets_default_to_prefix_bin() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());

        assert_eq!(
            binary_target_path("op", Path::new("/Applications"))?,
            tmp.path().join("bin/op")
        );
        assert_eq!(
            binary_target_path("sbin/op", Path::new("/Applications"))?,
            tmp.path().join("sbin/op")
        );
        assert_eq!(
            binary_target_path("$HOMEBREW_PREFIX/bin/op", Path::new("/Applications"))?,
            tmp.path().join("bin/op")
        );
        Ok(())
    }

    #[test]
    fn binary_targets_must_stay_under_an_allowed_root() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());

        // Targets outside both the prefix and /usr/local are rejected.
        let err = binary_target_path("/opt/elsewhere/bin/op", Path::new("/Applications"))
            .unwrap_err()
            .to_string();
        assert!(err.contains("must be under"));
        let err = binary_target_path("../op", Path::new("/Applications"))
            .unwrap_err()
            .to_string();
        assert!(err.contains("must not contain '..'"));
        Ok(())
    }

    #[test]
    fn binary_targets_allow_absolute_usr_local() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());

        // Casks like docker-desktop hardcode absolute /usr/local targets; these
        // are honored even when the prefix is elsewhere (arm64 /opt/homebrew).
        assert_eq!(
            binary_target_path("/usr/local/bin/docker", Path::new("/Applications"))?,
            PathBuf::from("/usr/local/bin/docker")
        );
        assert_eq!(
            binary_target_path(
                "/usr/local/cli-plugins/docker-compose",
                Path::new("/Applications")
            )?,
            PathBuf::from("/usr/local/cli-plugins/docker-compose")
        );
        Ok(())
    }

    #[test]
    fn appdir_binary_targets_are_contained() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        assert_eq!(
            binary_target_path("$APPDIR/Surge Dashboard.app", Path::new("/Applications"))?,
            PathBuf::from("/Applications/Surge Dashboard.app")
        );
        let prefix_appdir = tmp.path().join("Applications");
        assert_eq!(
            binary_target_path("$APPDIR/Surge Dashboard.app", &prefix_appdir)?,
            prefix_appdir.join("Surge Dashboard.app")
        );
        for target in [
            "$APPDIR/../secret",
            "$APPDIR//absolute",
            "$APPDIR/Surge.app/../../secret",
            "prefix/$APPDIR/secret",
        ] {
            assert!(
                binary_target_path(target, Path::new("/Applications")).is_err(),
                "accepted {target}"
            );
        }
        Ok(())
    }

    #[test]
    fn caskroom_binary_paths_support_contained_appdir_targets() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let caskroom = tmp.path().join("Caskroom/surge/1.0.0");
        let appdir = tmp.path().join("Applications");
        let binary = BinaryArtifact {
            source: "$APPDIR/Surge.app/Contents/Applications/Surge Dashboard.app".to_string(),
            target: Some("$APPDIR/Surge Dashboard.app".to_string()),
        };
        assert_eq!(
            caskroom_binary_path(&caskroom, &appdir, &binary)?,
            caskroom.join("Surge Dashboard.app")
        );
        Ok(())
    }

    #[test]
    fn caskroom_binary_paths_preserve_prefix_relative_target() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let caskroom = tmp.path().join("Caskroom/example/1.0.0");
        let binary = BinaryArtifact {
            source: "op".to_string(),
            target: Some("$HOMEBREW_PREFIX/sbin/op".to_string()),
        };

        assert_eq!(
            caskroom_binary_path(&caskroom, Path::new("/Applications"), &binary)?,
            caskroom.join("sbin/op")
        );
        Ok(())
    }

    #[test]
    fn caskroom_binary_paths_strip_usr_local_root() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let caskroom = tmp.path().join("Caskroom/docker-desktop/1.0.0");
        let binary = BinaryArtifact {
            source: "$APPDIR/Docker.app/Contents/Resources/bin/docker".to_string(),
            target: Some("/usr/local/bin/docker".to_string()),
        };

        assert_eq!(
            caskroom_binary_path(&caskroom, Path::new("/Applications"), &binary)?,
            caskroom.join("bin/docker")
        );
        Ok(())
    }

    #[test]
    fn installed_cask_version_uses_only_recorded_legacy_targets() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("app-only", "1.0.0");
        let app = AppArtifact {
            source: "Example.app".to_string(),
            target: Some("$HOMEBREW_PREFIX/Applications/Example.app".to_string()),
        };
        let caskroom = caskroom_version_dir(&cask.token, &cask.version);
        file::create_dir_all(&caskroom)?;
        file::create_dir_all(app_target_path(app.target_name())?)?;
        let receipt = CaskReceipt {
            schema_version: 0,
            version: cask.version.clone(),
            apps: vec![app_target_path(app.target_name())?],
            binaries: vec![],
            fonts: vec![],
            completions: vec![],
            flight_directories: vec![],
            generic: vec![],
            pkg_ids: vec![],
            targets: Vec::new(),
            prune_safe: false,
            prune_blocker: None,
        };
        crate::file::write(
            caskroom.join(".mise-cask.toml"),
            toml::to_string_pretty(&receipt)?,
        )?;

        assert_eq!(
            installed_cask_version(
                &cask,
                &CaskArtifacts {
                    apps: vec![app],
                    ..Default::default()
                }
            )?,
            Some("1.0.0".to_string())
        );
        Ok(())
    }

    #[test]
    fn installed_cask_version_rejects_unknown_receipt_schema() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("future", "1.0.0");
        let caskroom = caskroom_version_dir(&cask.token, &cask.version);
        file::create_dir_all(&caskroom)?;
        let receipt = CaskReceipt {
            schema_version: 4,
            version: cask.version.clone(),
            apps: Vec::new(),
            binaries: Vec::new(),
            fonts: Vec::new(),
            completions: Vec::new(),
            flight_directories: Vec::new(),
            generic: Vec::new(),
            pkg_ids: Vec::new(),
            targets: Vec::new(),
            prune_safe: false,
            prune_blocker: None,
        };
        file::write(
            caskroom.join(".mise-cask.toml"),
            toml::to_string_pretty(&receipt)?,
        )?;

        assert_eq!(
            installed_cask_version(&cask, &CaskArtifacts::default())?,
            None
        );
        Ok(())
    }

    #[test]
    fn cask_prune_removes_only_receipt_owned_direct_artifacts() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let state_dir = tmp.path().join("state");
        let cask = test_cask("example", "1.0.0");
        let app = AppArtifact {
            source: "Example.app".to_string(),
            target: Some("$HOMEBREW_PREFIX/Applications/Example.app".to_string()),
        };
        let target = app_target_path(app.target_name())?;
        file::create_dir_all(&target)?;
        file::write(target.join("version"), "1.0.0")?;
        let version_dir = caskroom_version_dir(&cask.token, &cask.version);
        file::create_dir_all(version_dir.join("Example.app"))?;
        file::write(version_dir.join("Example.app/version"), "1.0.0")?;
        write_receipt_with_flight_targets(
            &version_dir,
            &cask,
            &CaskArtifacts {
                apps: vec![app],
                ..Default::default()
            },
            &[],
            &BTreeMap::new(),
            &[],
        )?;

        let plan = cask_prune_plan_from_tokens(&BTreeSet::new(), &state_dir)?;
        assert_eq!(plan.remove.len(), 1);
        assert!(plan.skipped.is_empty());
        assert_eq!(apply_cask_prune_plan_in(&plan, true, &state_dir)?, 0);
        assert!(target.exists());

        assert_eq!(apply_cask_prune_plan_in(&plan, false, &state_dir)?, 1);
        assert!(!target.exists());
        assert!(!caskroom_token_dir(&cask.token).exists());
        assert!(!cask_journal_pending_in(&state_dir, &cask.token));
        Ok(())
    }

    #[test]
    fn cask_prune_skips_configured_drifted_and_legacy_casks() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let state_dir = tmp.path().join("state");

        let configured = test_cask("configured", "1.0.0");
        let configured_dir = caskroom_version_dir(&configured.token, &configured.version);
        let configured_target = tmp.path().join("Applications/Configured.app");
        file::create_dir_all(&configured_target)?;
        file::create_dir_all(configured_dir.join("Configured.app"))?;
        write_receipt_with_flight_targets(
            &configured_dir,
            &configured,
            &CaskArtifacts {
                apps: vec![AppArtifact {
                    source: "Configured.app".to_string(),
                    target: Some("$HOMEBREW_PREFIX/Applications/Configured.app".to_string()),
                }],
                ..Default::default()
            },
            &[],
            &BTreeMap::new(),
            &[],
        )?;

        let drifted = test_cask("drifted", "1.0.0");
        let drifted_dir = caskroom_version_dir(&drifted.token, &drifted.version);
        let drifted_target = tmp.path().join("Applications/Drifted.app");
        file::create_dir_all(&drifted_target)?;
        file::create_dir_all(drifted_dir.join("Drifted.app"))?;
        write_receipt_with_flight_targets(
            &drifted_dir,
            &drifted,
            &CaskArtifacts {
                apps: vec![AppArtifact {
                    source: "Drifted.app".to_string(),
                    target: Some("$HOMEBREW_PREFIX/Applications/Drifted.app".to_string()),
                }],
                ..Default::default()
            },
            &[],
            &BTreeMap::new(),
            &[],
        )?;
        file::write(drifted_target.join("changed"), "changed")?;

        let legacy = test_cask("legacy", "1.0.0");
        let legacy_dir = caskroom_version_dir(&legacy.token, &legacy.version);
        file::create_dir_all(&legacy_dir)?;
        file::write(
            legacy_dir.join(".mise-cask.toml"),
            toml::to_string_pretty(&CaskReceipt {
                schema_version: 2,
                version: legacy.version.clone(),
                apps: Vec::new(),
                binaries: Vec::new(),
                fonts: Vec::new(),
                completions: Vec::new(),
                flight_directories: Vec::new(),
                generic: Vec::new(),
                pkg_ids: Vec::new(),
                targets: Vec::new(),
                prune_safe: false,
                prune_blocker: None,
            })?,
        )?;

        let plan =
            cask_prune_plan_from_tokens(&BTreeSet::from([configured.token.clone()]), &state_dir)?;
        assert!(plan.remove.is_empty());
        assert_eq!(
            plan.skipped
                .iter()
                .map(|skip| skip.token.as_str())
                .collect::<Vec<_>>(),
            vec!["drifted", "legacy"]
        );
        Ok(())
    }

    #[test]
    fn cask_prune_skips_shared_targets_and_pending_transactions() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let state_dir = tmp.path().join("state");

        write_test_app_receipt(&test_cask("shared-a", "1.0.0"), "Shared.app")?;
        write_test_app_receipt(&test_cask("shared-b", "1.0.0"), "Shared.app")?;
        write_test_app_receipt(&test_cask("single", "1.0.0"), "Multi.app")?;
        write_test_app_receipt(&test_cask("multi", "1.0.0"), "Multi.app")?;
        write_test_app_receipt(&test_cask("multi", "2.0.0"), "Multi.app")?;
        write_test_app_receipt(&test_cask("pending", "1.0.0"), "Pending.app")?;
        let journal_dir = state_dir.join("brew-cask/pending");
        file::create_dir_all(&journal_dir)?;
        file::write(journal_dir.join("1.0.0.json"), "{}")?;

        let plan = cask_prune_plan_from_tokens(&BTreeSet::new(), &state_dir)?;
        assert!(plan.remove.is_empty());
        assert_eq!(plan.skipped.len(), 5);
        for token in ["shared-a", "shared-b"] {
            assert!(plan.skipped.iter().any(|skip| {
                skip.token == token && skip.reason.contains("also claimed by another cask")
            }));
        }
        assert!(plan.skipped.iter().any(|skip| {
            skip.token == "pending"
                && skip
                    .reason
                    .contains("incomplete cask transaction is pending")
        }));
        assert!(plan.skipped.iter().any(|skip| {
            skip.token == "single" && skip.reason.contains("also claimed by another cask")
        }));
        assert!(
            plan.skipped.iter().any(|skip| {
                skip.token == "multi" && skip.reason.contains("expected exactly one")
            })
        );
        Ok(())
    }

    #[test]
    fn cask_prune_rechecks_shared_targets_before_removal() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let state_dir = tmp.path().join("state");
        let target = write_test_app_receipt(&test_cask("planned", "1.0.0"), "Shared.app")?;
        let plan = cask_prune_plan_from_tokens(&BTreeSet::new(), &state_dir)?;
        assert_eq!(plan.remove.len(), 1);

        write_test_app_receipt(&test_cask("late-claim", "1.0.0"), "Shared.app")?;

        assert_eq!(apply_cask_prune_plan_in(&plan, false, &state_dir)?, 0);
        assert!(target.exists());
        assert!(caskroom_token_dir("planned").exists());
        Ok(())
    }

    #[test]
    fn cask_prune_rechecks_homebrew_ownership_before_removal() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let state_dir = tmp.path().join("state");
        let target = write_test_app_receipt(&test_cask("claimed", "1.0.0"), "Claimed.app")?;
        let plan = cask_prune_plan_from_tokens(&BTreeSet::new(), &state_dir)?;
        assert_eq!(plan.remove.len(), 1);

        file::create_dir_all(caskroom_token_dir("claimed").join(".metadata"))?;

        assert_eq!(apply_cask_prune_plan_in(&plan, false, &state_dir)?, 0);
        assert!(target.exists());
        assert!(caskroom_token_dir("claimed").exists());
        Ok(())
    }

    #[test]
    fn cask_prune_fails_closed_when_a_receipt_is_corrupt() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let state_dir = tmp.path().join("state");
        write_test_app_receipt(&test_cask("clean", "1.0.0"), "Clean.app")?;
        let corrupt_dir = caskroom_version_dir("corrupt", "1.0.0");
        file::create_dir_all(&corrupt_dir)?;
        file::write(corrupt_dir.join(".mise-cask.toml"), "not = [valid")?;

        let plan = cask_prune_plan_from_tokens(&BTreeSet::new(), &state_dir)?;

        assert!(plan.remove.is_empty());
        assert!(plan.skipped.iter().any(|skip| {
            skip.token == "corrupt" && skip.reason.contains("receipt could not be read")
        }));
        assert!(plan.skipped.iter().any(|skip| {
            skip.token == "clean" && skip.reason.contains("could not be indexed completely")
        }));
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn cask_prune_fails_closed_when_a_token_directory_is_unreadable() -> Result<()> {
        use std::os::unix::fs::PermissionsExt;

        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let state_dir = tmp.path().join("state");
        write_test_app_receipt(&test_cask("clean", "1.0.0"), "Clean.app")?;
        let unreadable = caskroom_token_dir("unreadable");
        file::create_dir_all(&unreadable)?;
        std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000))?;

        let plan = cask_prune_plan_from_tokens(&BTreeSet::new(), &state_dir);
        std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755))?;
        let plan = plan?;

        assert!(plan.remove.is_empty());
        assert!(plan.skipped.iter().any(|skip| {
            skip.token == "unreadable" && skip.reason.contains("directory could not be read")
        }));
        assert!(plan.skipped.iter().any(|skip| {
            skip.token == "clean" && skip.reason.contains("could not be indexed completely")
        }));
        Ok(())
    }

    #[test]
    fn cask_prune_receipt_rejects_pkg_and_lifecycle_casks() -> Result<()> {
        let mut cask = test_cask("example", "1.0.0");
        let direct = CaskArtifacts {
            apps: vec![AppArtifact {
                source: "Example.app".to_string(),
                target: None,
            }],
            ..Default::default()
        };
        assert_eq!(cask_prune_blocker(&cask, &direct), None);

        cask.artifacts = vec![serde_json::json!({"uninstall": [{"quit": "com.example"}]})];
        assert!(cask_prune_blocker(&cask, &direct).is_some());

        cask.artifacts.clear();
        let pkg = CaskArtifacts {
            pkgs: vec![PkgArtifact {
                source: "Example.pkg".to_string(),
            }],
            pkg_ids: vec!["com.example.pkg".to_string()],
            ..Default::default()
        };
        assert!(cask_prune_blocker(&cask, &pkg).is_some());

        let wrapper = CaskArtifacts {
            command_wrappers: vec![CommandWrapperArtifact {
                name: "example".to_string(),
                target: None,
                content: None,
                executable: Some("$APPDIR/Example.app/Contents/MacOS/example".to_string()),
                args: Vec::new(),
                env: BTreeMap::new(),
            }],
            ..Default::default()
        };
        assert_eq!(
            cask_prune_blocker(&cask, &wrapper).as_deref(),
            Some("command wrapper artifacts are not supported for pruning")
        );
        Ok(())
    }

    #[test]
    fn any_version_journal_marks_token_pending() -> Result<()> {
        let tmp = tempfile::tempdir()?;
        let journal_dir = tmp.path().join("brew-cask/example");
        file::create_dir_all(&journal_dir)?;
        file::write(journal_dir.join("0.9.0.json"), "{}")?;
        file::write(journal_dir.join("1.0.0.json"), "{}")?;

        assert!(cask_journal_pending_in(tmp.path(), "example"));
        assert!(!cask_journal_pending_in(tmp.path(), "other"));
        remove_cask_journals_in(tmp.path(), "example")?;
        assert!(!cask_journal_pending_in(tmp.path(), "example"));
        Ok(())
    }

    #[test]
    fn installed_cask_version_rejects_binary_state_without_receipt() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("binary-only", "1.0.0");
        let binary = BinaryArtifact {
            source: "op".to_string(),
            target: Some("$HOMEBREW_PREFIX/bin/op".to_string()),
        };
        file::create_dir_all(caskroom_version_dir(&cask.token, &cask.version))?;

        assert_eq!(
            installed_cask_version(
                &cask,
                &CaskArtifacts {
                    binaries: vec![binary.clone()],
                    ..Default::default()
                }
            )?,
            None
        );

        let target = binary.target_path(Path::new("/Applications"))?;
        file::create_dir_all(target.parent().unwrap())?;
        crate::file::write(&target, "binary")?;

        assert_eq!(
            installed_cask_version(
                &cask,
                &CaskArtifacts {
                    binaries: vec![binary],
                    ..Default::default()
                }
            )?,
            None
        );
        Ok(())
    }

    #[test]
    fn installed_cask_version_does_not_invent_wrapper_from_current_api() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("firefox", "153.0.1");
        let app = AppArtifact {
            source: "Firefox.app".to_string(),
            target: Some("$HOMEBREW_PREFIX/Applications/Firefox.app".to_string()),
        };
        let wrapper = CommandWrapperArtifact {
            name: "firefox".to_string(),
            target: Some("$HOMEBREW_PREFIX/bin/firefox".to_string()),
            content: None,
            executable: Some("$APPDIR/Firefox.app/Contents/MacOS/firefox".to_string()),
            args: Vec::new(),
            env: BTreeMap::new(),
        };
        let caskroom = caskroom_version_dir(&cask.token, &cask.version);
        file::create_dir_all(&caskroom)?;
        let app_target = app_target_path(app.target_name())?;
        file::create_dir_all(&app_target)?;
        let receipt = CaskReceipt {
            schema_version: 0,
            version: cask.version.clone(),
            apps: vec![app_target],
            binaries: Vec::new(),
            fonts: Vec::new(),
            completions: Vec::new(),
            flight_directories: Vec::new(),
            generic: Vec::new(),
            pkg_ids: Vec::new(),
            targets: Vec::new(),
            prune_safe: false,
            prune_blocker: None,
        };
        file::write(
            caskroom.join(".mise-cask.toml"),
            toml::to_string_pretty(&receipt)?,
        )?;
        let artifacts = CaskArtifacts {
            apps: vec![app],
            command_wrappers: vec![wrapper.clone()],
            ..Default::default()
        };

        assert_eq!(
            installed_cask_version(&cask, &artifacts)?,
            Some(cask.version.clone())
        );

        let target = wrapper.target_path()?;
        file::create_dir_all(target.parent().unwrap())?;
        file::write(target, "wrapper")?;
        assert_eq!(
            installed_cask_version(&cask, &artifacts)?,
            Some(cask.version)
        );
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn stages_and_links_binary_artifact() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        file::create_dir_all(&stage)?;
        crate::file::write(stage.join("op"), "binary")?;
        let caskroom = caskroom_version_dir("binary-only", "1.0.0");
        file::create_dir_all(&caskroom)?;
        let cask = test_cask("binary-only", "1.0.0");
        let binary = BinaryArtifact {
            source: "op".to_string(),
            target: Some("$HOMEBREW_PREFIX/bin/op".to_string()),
        };

        stage_binary(&stage, &caskroom, &cask, &[], &binary)?;
        link_binary(&caskroom, Path::new("/Applications"), &binary)?;

        let target = binary.target_path(Path::new("/Applications"))?;
        assert_eq!(std::fs::read_link(&target)?, caskroom.join("bin/op"));
        assert_eq!(crate::file::read_to_string(&target)?, "binary");
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn stages_same_basename_binaries_without_collision() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        file::create_dir_all(stage.join("bin"))?;
        file::create_dir_all(stage.join("sbin"))?;
        crate::file::write(stage.join("bin/op"), "bin")?;
        crate::file::write(stage.join("sbin/op"), "sbin")?;
        let caskroom = caskroom_version_dir("binary-only", "1.0.0");
        file::create_dir_all(&caskroom)?;
        let cask = test_cask("binary-only", "1.0.0");
        let bin = BinaryArtifact {
            source: "bin/op".to_string(),
            target: Some("$HOMEBREW_PREFIX/bin/op".to_string()),
        };
        let sbin = BinaryArtifact {
            source: "sbin/op".to_string(),
            target: Some("$HOMEBREW_PREFIX/sbin/op".to_string()),
        };

        stage_binary(&stage, &caskroom, &cask, &[], &bin)?;
        stage_binary(&stage, &caskroom, &cask, &[], &sbin)?;
        link_binary(&caskroom, Path::new("/Applications"), &bin)?;
        link_binary(&caskroom, Path::new("/Applications"), &sbin)?;

        assert_eq!(
            crate::file::read_to_string(bin.target_path(Path::new("/Applications"))?)?,
            "bin"
        );
        assert_eq!(
            crate::file::read_to_string(sbin.target_path(Path::new("/Applications"))?)?,
            "sbin"
        );
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn binary_source_prefers_hook_generated_caskroom_file() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        file::create_dir_all(&stage)?;
        crate::file::write(stage.join("op"), "stage")?;
        let caskroom = caskroom_version_dir("binary-only", "1.0.0");
        file::create_dir_all(&caskroom)?;
        crate::file::write(caskroom.join("op"), "hook")?;
        let cask = test_cask("binary-only", "1.0.0");
        let binary = BinaryArtifact {
            source: "op".to_string(),
            target: Some("$HOMEBREW_PREFIX/bin/op".to_string()),
        };

        stage_binary(&stage, &caskroom, &cask, &[], &binary)?;

        assert_eq!(
            crate::file::read_to_string(caskroom.join("bin/op"))?,
            "hook"
        );
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn stages_absolute_binary_source_from_pkg_install() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        file::create_dir_all(&stage)?;
        let pkg_binary = tmp
            .path()
            .join("Library/Application Support/org.pqrs/Karabiner-Elements/bin/karabiner_cli");
        if let Some(parent) = pkg_binary.parent() {
            file::create_dir_all(parent)?;
        }
        crate::file::write(&pkg_binary, "pkg binary")?;
        let caskroom = caskroom_version_dir("karabiner-elements", "16.1.0");
        file::create_dir_all(&caskroom)?;
        let cask = test_cask("karabiner-elements", "16.1.0");
        let binary = BinaryArtifact {
            source: pkg_binary.to_string_lossy().to_string(),
            target: Some("$HOMEBREW_PREFIX/bin/karabiner_cli".to_string()),
        };

        stage_binary(&stage, &caskroom, &cask, &[], &binary)?;
        link_binary(&caskroom, Path::new("/Applications"), &binary)?;

        let staged = caskroom.join("bin/karabiner_cli");
        assert_eq!(std::fs::read_link(&staged)?, pkg_binary);
        let target = binary.target_path(Path::new("/Applications"))?;
        assert_eq!(std::fs::read_link(&target)?, staged);
        assert_eq!(crate::file::read_to_string(&target)?, "pkg binary");
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn reports_missing_target_for_dangling_staged_binary_symlink() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let stage = tmp.path().join("stage");
        file::create_dir_all(&stage)?;
        let pkg_binary = tmp
            .path()
            .join("Library/Application Support/org.pqrs/Karabiner-Elements/bin/karabiner_cli");
        if let Some(parent) = pkg_binary.parent() {
            file::create_dir_all(parent)?;
        }
        crate::file::write(&pkg_binary, "pkg binary")?;
        let caskroom = caskroom_version_dir("karabiner-elements", "16.1.0");
        file::create_dir_all(&caskroom)?;
        let cask = test_cask("karabiner-elements", "16.1.0");
        let binary = BinaryArtifact {
            source: pkg_binary.to_string_lossy().to_string(),
            target: Some("$HOMEBREW_PREFIX/bin/karabiner_cli".to_string()),
        };

        stage_binary(&stage, &caskroom, &cask, &[], &binary)?;
        file::remove_file(&pkg_binary)?;
        let err = link_binary(&caskroom, Path::new("/Applications"), &binary)
            .unwrap_err()
            .to_string();

        assert!(err.contains("was staged but symlink target"));
        assert!(err.contains(&pkg_binary.to_string_lossy().to_string()));
        Ok(())
    }

    #[test]
    fn cask_appdir_uses_prefix_for_prefix_targeted_apps() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let app = AppArtifact {
            source: "Example.app".to_string(),
            target: Some("$HOMEBREW_PREFIX/Applications/Example.app".to_string()),
        };

        assert_eq!(cask_appdir(&[app])?, tmp.path().join("Applications"));
        Ok(())
    }

    #[test]
    fn app_target_path_defaults_to_applications() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let mut _guard = EnvVarGuard::new();
        _guard.remove(APP_DIR_ENV);
        assert_eq!(
            app_target_path("Firefox.app")?,
            PathBuf::from("/Applications/Firefox.app")
        );
        Ok(())
    }

    #[test]
    fn parse_app_artifact_target_without_slash_is_preserved() {
        // The Homebrew API commonly renders `app` targets as a bare bundle
        // name. Parsing must keep it verbatim and must not consult the override.
        let _lock = ENV_LOCK.lock().unwrap();
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, "/tmp/should-not-be-used");
        let value: Value =
            serde_json::json!({"app": ["Firefox.app", {"target": "Firefox Nightly.app"}]});
        assert_eq!(
            parse_app_artifact(&value),
            Some(AppArtifact {
                source: "Firefox.app".to_string(),
                target: Some("Firefox Nightly.app".to_string()),
            })
        );
    }

    #[test]
    fn parse_app_artifact_preserves_prefix_target() {
        // A `$HOMEBREW_PREFIX`-anchored target must survive parsing so that
        // `cask_appdir`/`app_target_path` can route it into the prefix.
        let _lock = ENV_LOCK.lock().unwrap();
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, "/tmp/should-not-be-used");
        let value: Value = serde_json::json!({
            "app": ["Example.app", {"target": "$HOMEBREW_PREFIX/Applications/Example.app"}]
        });
        assert_eq!(
            parse_app_artifact(&value),
            Some(AppArtifact {
                source: "Example.app".to_string(),
                target: Some("$HOMEBREW_PREFIX/Applications/Example.app".to_string()),
            })
        );
    }

    #[test]
    fn app_target_path_honours_appdir_override() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir()?;
        // target_app_dir canonicalizes the override, so compare against the
        // resolved base (macOS tempdirs live under the `/var` symlink).
        let base = tmp.path().canonicalize()?;
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, &base);
        assert_eq!(app_target_path("Firefox.app")?, base.join("Firefox.app"));
        Ok(())
    }

    #[test]
    fn app_target_path_accepts_absolute_target_under_override() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir()?;
        let base = tmp.path().canonicalize()?;
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, &base);
        let target = base.join("Firefox.app");
        assert_eq!(app_target_path(&target.to_string_lossy())?, target);
        Ok(())
    }

    #[test]
    fn app_target_path_relocates_default_applications_target() -> Result<()> {
        // The Homebrew API frequently hardcodes an absolute
        // `/Applications/Foo.app` target (e.g. the firefox cask). With an
        // override configured this must be relocated into it.
        let _lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir()?;
        let base = tmp.path().canonicalize()?;
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, &base);
        assert_eq!(
            app_target_path("/Applications/Firefox.app")?,
            base.join("Firefox.app")
        );
        Ok(())
    }

    #[test]
    fn app_target_path_relocation_preserves_subdirectories() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir()?;
        let base = tmp.path().canonicalize()?;
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, &base);
        assert_eq!(
            app_target_path("/Applications/JetBrains/IDEA.app")?,
            base.join("JetBrains/IDEA.app")
        );
        Ok(())
    }

    #[test]
    fn app_target_path_defaults_keep_absolute_applications_target() -> Result<()> {
        // Without an override, an absolute `/Applications` target is accepted
        // as-is (no relocation).
        let _lock = ENV_LOCK.lock().unwrap();
        let mut _guard = EnvVarGuard::new();
        _guard.remove(APP_DIR_ENV);
        assert_eq!(
            app_target_path("/Applications/Firefox.app")?,
            PathBuf::from("/Applications/Firefox.app")
        );
        Ok(())
    }

    #[test]
    fn app_target_path_rejects_target_outside_override() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir()?;
        let base = tmp.path().canonicalize()?;
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, &base);
        let err = app_target_path("/Users/someone/Evil.app")
            .unwrap_err()
            .to_string();
        assert!(err.contains(&base.to_string_lossy().to_string()), "{err}");
        assert!(!err.contains("/Applications"), "{err}");
        Ok(())
    }

    #[test]
    fn cask_appdir_uses_override() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir()?;
        let base = tmp.path().canonicalize()?;
        let _prefix = BrewPrefixGuard::set(&base.join("prefix"));
        let appdir = base.join("appdir");
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, &appdir);
        let app = AppArtifact {
            source: "Example.app".to_string(),
            target: None,
        };
        assert_eq!(cask_appdir(&[app])?, appdir);
        Ok(())
    }

    #[test]
    fn command_wrapper_target_path_uses_override() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir()?;
        let base = tmp.path().canonicalize()?;
        let appdir = base.join("appdir");
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, &appdir);
        // Only `$APPDIR`-anchored targets resolve into the appdir; a bare name
        // lands under the prefix's bin, so anchor the target to exercise the
        // override path.
        let wrapper = CommandWrapperArtifact {
            name: "gimp".to_string(),
            target: Some("$APPDIR/GIMP.app/Contents/MacOS/gimp".to_string()),
            content: None,
            executable: None,
            args: Vec::new(),
            env: BTreeMap::new(),
        };
        assert_eq!(
            wrapper.target_path()?,
            appdir.join("GIMP.app/Contents/MacOS/gimp"),
        );
        Ok(())
    }

    #[test]
    fn allowed_appdir_roots_has_no_duplicates() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir()?;
        let base = tmp.path().canonicalize()?;
        let _prefix = BrewPrefixGuard::set(&base.join("prefix"));

        let mut _guard = EnvVarGuard::new();
        _guard.remove(APP_DIR_ENV);
        let roots = allowed_appdir_roots()?;
        let unique: BTreeSet<_> = roots.iter().collect();
        assert_eq!(unique.len(), roots.len(), "{roots:?}");
        assert!(roots.contains(&PathBuf::from("/Applications")));

        let appdir = base.join("appdir");
        _guard.set(APP_DIR_ENV, &appdir);
        let roots = allowed_appdir_roots()?;
        let unique: BTreeSet<_> = roots.iter().collect();
        assert_eq!(unique.len(), roots.len(), "{roots:?}");
        assert!(roots.contains(&appdir));
        Ok(())
    }

    #[test]
    fn binary_target_path_accepts_override_appdir() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir()?;
        let base = tmp.path().canonicalize()?;
        let appdir = base.join("appdir");
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, &appdir);
        assert_eq!(
            binary_target_path("$APPDIR/Foo.app/Contents/MacOS/foo", &appdir)?,
            appdir.join("Foo.app/Contents/MacOS/foo"),
        );
        Ok(())
    }

    #[test]
    fn ensure_trusted_appdir_refuses_world_writable_ancestor() -> Result<()> {
        // Regression guard for the CI failure: a world-writable ancestor (as
        // `/tmp` is, mode 1777) must be refused, because any local user could
        // substitute components beneath it. Real application directories are
        // never world-writable.
        let tmp = trusted_tempdir()?;
        let base = tmp.path().canonicalize()?;
        let shared = base.join("shared");
        file::create_dir_all(&shared)?;
        let mode = std::fs::Permissions::from_mode(0o1777);
        std::fs::set_permissions(&shared, mode)?;
        let err = match ensure_trusted_appdir(&shared.join("Applications")) {
            Ok(_) => panic!("expected world-writable ancestor to be refused"),
            Err(err) => err.to_string(),
        };
        assert!(err.contains("untrusted directory"), "{err}");
        Ok(())
    }

    #[test]
    fn ensure_trusted_appdir_creates_missing_tail() -> Result<()> {
        let tmp = trusted_tempdir()?;
        let base = tmp.path().canonicalize()?;
        let appdir = base.join("Applications");
        ensure_trusted_appdir(&appdir)?;
        assert!(appdir.symlink_metadata()?.file_type().is_dir());
        // Idempotent when the directory already exists.
        ensure_trusted_appdir(&appdir)?;
        Ok(())
    }

    #[test]
    fn ensure_trusted_appdir_rejects_symlinked_tail() -> Result<()> {
        // Simulate a symlink planted on the not-yet-existing appdir tail
        // between validation and mutation: it must be rejected, not followed.
        let tmp = trusted_tempdir()?;
        let base = tmp.path().canonicalize()?;
        let elsewhere = base.join("elsewhere");
        file::create_dir_all(&elsewhere)?;
        let appdir = base.join("Applications");
        std::os::unix::fs::symlink(&elsewhere, &appdir)?;
        let err = match ensure_trusted_appdir(&appdir) {
            Ok(_) => panic!("expected symlinked appdir tail to be rejected"),
            Err(err) => err.to_string(),
        };
        // Must fail because the tail is a symlink, not because an ancestor was
        // untrusted (which is a different guard).
        assert!(err.contains("cannot open operation directory"), "{err}");
        assert!(!err.contains("untrusted directory"), "{err}");
        Ok(())
    }

    #[test]
    fn ensure_trusted_appdir_stays_bound_after_same_uid_replacement() -> Result<()> {
        // The reviewer's scenario: after validation, a same-uid process swaps
        // the accepted appdir for a different directory (or symlink). Because
        // the descriptor is retained and mutations are addressed through it,
        // writes still land in the originally validated directory.
        let tmp = trusted_tempdir()?;
        let base = tmp.path().canonicalize()?;
        let appdir = base.join("Applications");
        let parent = ensure_trusted_appdir(&appdir)?;

        // Swap the validated directory aside and put an attacker-controlled
        // path in its place.
        let stashed = base.join("stashed");
        std::fs::rename(&appdir, &stashed)?;
        let attacker = base.join("attacker");
        file::create_dir_all(&attacker)?;
        std::os::unix::fs::symlink(&attacker, &appdir)?;

        // Writing through the bound descriptor path must reach the original
        // directory (now at `stashed`), never the attacker's directory.
        let bound = parent.path()?;
        crate::file::write(bound.join("canary"), "bound")?;
        assert!(stashed.join("canary").is_file());
        assert!(!attacker.join("canary").exists());
        Ok(())
    }

    // `ditto` only exists on macOS, and app artifacts are macOS-only in
    // practice (Linux cask support is font-only).
    #[cfg(target_os = "macos")]
    #[test]
    fn ditto_into_stays_bound_after_directory_replacement() -> Result<()> {
        // Bind the appdir, then have a same-uid replacement swap the directory
        // pathname for an attacker-controlled one. The fd-bound copy must still
        // land in the originally validated directory.
        let tmp = trusted_tempdir()?;
        let base = tmp.path().canonicalize()?;
        let appdir = base.join("Applications");
        let parent = ensure_trusted_appdir(&appdir)?;

        let source = base.join("payload");
        file::create_dir_all(&source)?;
        crate::file::write(source.join("marker"), "payload")?;

        let stashed = base.join("stashed");
        std::fs::rename(&appdir, &stashed)?;
        let attacker = base.join("attacker");
        file::create_dir_all(&attacker)?;
        std::os::unix::fs::symlink(&attacker, &appdir)?;

        ditto_into(&source, &parent.fd, std::ffi::OsStr::new("Copied.app"))?;
        assert!(stashed.join("Copied.app/marker").is_file());
        assert!(!attacker.join("Copied.app").exists());
        Ok(())
    }

    #[test]
    fn ensure_trusted_appdir_walks_from_unreplaceable_root() -> Result<()> {
        // The appdir is never re-opened via a scanned ancestor pathname: the
        // walk starts at `/` and descends only through verified descriptors.
        // Swapping an intermediate component for another same-uid-owned real
        // directory before the call therefore cannot be reached through a
        // previously-resolved root, and a symlink swap is rejected outright.
        let tmp = trusted_tempdir()?;
        let base = tmp.path().canonicalize()?;
        let middle = base.join("middle");
        file::create_dir_all(&middle)?;
        let appdir = middle.join("Applications");
        let parent = ensure_trusted_appdir(&appdir)?;
        assert!(appdir.symlink_metadata()?.file_type().is_dir());

        // Replace the intermediate component with a same-uid symlink: the next
        // walk must refuse it rather than following it.
        let attacker = base.join("attacker");
        file::create_dir_all(&attacker)?;
        std::fs::remove_dir_all(&middle)?;
        std::os::unix::fs::symlink(&attacker, &middle)?;
        assert!(ensure_trusted_appdir(&appdir).is_err());
        // Nothing was created inside the attacker's directory.
        assert!(!attacker.join("Applications").exists());
        drop(parent);
        Ok(())
    }

    #[test]
    fn ditto_into_rejects_preplanted_symlink_destination() -> Result<()> {
        // A same-uid process creates the predictable temporary name as a
        // symlink before the copy. The copy must fail closed rather than follow
        // it, so nothing is written outside the verified directory.
        let tmp = trusted_tempdir()?;
        let base = tmp.path().canonicalize()?;
        let appdir = base.join("Applications");
        let parent = ensure_trusted_appdir(&appdir)?;

        let source = base.join("payload");
        file::create_dir_all(&source)?;
        crate::file::write(source.join("marker"), "payload")?;

        let attacker = base.join("attacker");
        file::create_dir_all(&attacker)?;
        let tmp_name = std::ffi::OsStr::new("Foo.mise-tmp-abc");
        std::os::unix::fs::symlink(&attacker, appdir.join(tmp_name))?;

        // Fails at `mkdirat` (EEXIST) before `ditto` is ever spawned, so this
        // holds on platforms without `ditto` too.
        let err = match ditto_into(&source, &parent.fd, tmp_name) {
            Ok(()) => panic!("expected pre-planted symlink destination to be refused"),
            Err(err) => err.to_string(),
        };
        assert!(err.contains("cannot create staging directory"), "{err}");
        assert!(!attacker.join("marker").exists());
        Ok(())
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn repair_app_permissions_does_not_traverse_bundle_symlinks() -> Result<()> {
        // A cask bundle may contain a symlink pointing outside the application
        // directory. The recursive flag/permission repair must not follow it and
        // change the referent.
        let tmp = trusted_tempdir()?;
        let base = tmp.path().canonicalize()?;
        let appdir = base.join("Applications");
        let parent = ensure_trusted_appdir(&appdir)?;

        let outside = base.join("outside.txt");
        crate::file::write(&outside, "keep")?;
        let status = std::process::Command::new("/bin/chmod")
            .args(["644"])
            .arg(&outside)
            .status()?;
        assert!(status.success());

        let bundle = appdir.join("Victim.app");
        file::create_dir_all(&bundle)?;
        std::os::unix::fs::symlink(&outside, bundle.join("link"))?;

        repair_app_permissions_at(&parent, std::ffi::OsStr::new("Victim.app"));

        // The referent keeps its mode and gains no flags.
        let mode = std::process::Command::new("/usr/bin/stat")
            .args(["-f", "%Sp"])
            .arg(&outside)
            .output()?;
        let mode = String::from_utf8_lossy(&mode.stdout).trim().to_string();
        assert_eq!(mode, "-rw-r--r--", "referent mode changed: {mode}");
        let flags = std::process::Command::new("/usr/bin/stat")
            .args(["-f", "%Sf"])
            .arg(&outside)
            .output()?;
        let flags = String::from_utf8_lossy(&flags.stdout).trim().to_string();
        assert!(
            flags.is_empty() || flags == "-",
            "referent flags set: {flags}"
        );
        assert_eq!(crate::file::read_to_string(&outside)?, "keep");
        Ok(())
    }

    #[test]
    fn empty_appdir_override_falls_back_to_applications() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, "");
        assert_eq!(
            app_target_path("Firefox.app")?,
            PathBuf::from("/Applications/Firefox.app")
        );
        assert!(app_target_path("/etc/passwd").is_err());
        Ok(())
    }

    #[test]
    fn relative_appdir_override_is_rejected() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, "relative/apps");
        assert!(app_target_path("Firefox.app").is_err());
        Ok(())
    }

    #[test]
    fn appdir_override_with_parent_dir_is_rejected() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, "/Applications/../etc");
        assert!(app_target_path("Firefox.app").is_err());
        Ok(())
    }

    #[test]
    fn appdir_override_root_alias_is_rejected() -> Result<()> {
        // Alternate spellings of the filesystem root must not become the
        // containment boundary.
        let _lock = ENV_LOCK.lock().unwrap();
        for alias in ["/.", "//", "/./."] {
            let mut _guard = EnvVarGuard::new();
            _guard.set(APP_DIR_ENV, alias);
            assert!(
                app_target_path("Firefox.app").is_err(),
                "expected {alias} to be rejected"
            );
        }
        Ok(())
    }

    #[test]
    fn appdir_override_with_symlink_to_root_is_rejected() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir()?;
        // An override that resolves to the filesystem root would make `/` the
        // containment boundary for privileged mutations, so it must be
        // rejected — including when reached through a symlink.
        let link = tmp.path().join("link-to-root");
        std::os::unix::fs::symlink("/", &link)?;
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, &link);
        let err = app_target_path("Firefox.app").unwrap_err().to_string();
        assert!(
            err.contains("must not resolve to the filesystem root"),
            "{err}"
        );
        Ok(())
    }

    #[test]
    fn appdir_override_with_benign_symlink_is_resolved() -> Result<()> {
        let _lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir()?;
        // A symlink whose target is an ordinary directory (not root) is
        // accepted, but the boundary is the resolved real path so privileged
        // mutations cannot be redirected through the link.
        let real = tmp.path().join("real");
        file::create_dir_all(&real)?;
        let real = real.canonicalize()?;
        let link = tmp.path().join("link");
        std::os::unix::fs::symlink(&real, &link)?;
        let mut _guard = EnvVarGuard::new();
        _guard.set(APP_DIR_ENV, &link);
        assert_eq!(app_target_path("Firefox.app")?, real.join("Firefox.app"));
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn failed_app_activation_preserves_caskroom_copy() -> Result<()> {
        let tmp = trusted_tempdir()?;
        let base = tmp.path().canonicalize()?;
        let appdir = base.join("Applications");
        let parent = ensure_trusted_appdir(&appdir)?;
        let target = appdir.join("Example.app");
        file::create_dir_all(&target)?;
        file::write(target.join("version"), "old")?;

        let caskroom_app = base.join("Caskroom/example/2.0.0/Example.app");
        file::create_dir_all(&caskroom_app)?;
        file::write(caskroom_app.join("version"), "staged")?;

        let result = activate_app_at(
            &parent,
            std::ffi::OsStr::new("Example.app"),
            std::ffi::OsStr::new("missing.mise-tmp"),
            std::ffi::OsStr::new("Example.mise-old-test"),
            &caskroom_app,
            &target,
        );

        assert!(result.is_err());
        assert!(!caskroom_app.symlink_metadata()?.file_type().is_symlink());
        assert_eq!(
            file::read_to_string(caskroom_app.join("version"))?,
            "staged"
        );
        assert_eq!(file::read_to_string(target.join("version"))?, "old");
        Ok(())
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn upgrades_app_with_protected_existing_contents() -> Result<()> {
        let tmp = trusted_tempdir()?;
        // ensure_trusted_appdir walks the appdir with O_NOFOLLOW, so use a
        // canonical base (macOS tempdirs sit under the `/var` symlink).
        let base = tmp.path().canonicalize()?;
        let target = base.join("Docker.app");
        let protected_dir = target.join("Contents/Resources");
        file::create_dir_all(&protected_dir)?;
        crate::file::write(protected_dir.join("docker"), "old")?;
        let status = std::process::Command::new("/bin/chmod")
            .args(["+a", "everyone deny delete_child"])
            .arg(&protected_dir)
            .status()?;
        assert!(status.success());

        let tmp_target = base.join("Docker.mise-tmp-test");
        file::create_dir_all(&tmp_target)?;
        crate::file::write(tmp_target.join("version"), "new")?;

        // swap_app_at addresses entries by name relative to the verified appdir
        // descriptor, so open the containing directory and use bare names.
        let parent = ensure_trusted_appdir(&base)?;
        let old_name = std::ffi::OsString::from("Docker.mise-old-test");
        let old_target = base.join(&old_name);
        let result = swap_app_at(
            &parent,
            std::ffi::OsStr::new("Docker.app"),
            std::ffi::OsStr::new("Docker.mise-tmp-test"),
            &old_name,
        );

        // Remove the ACL so tempfile can clean up even when the repro fails.
        if old_target.exists() {
            let status = std::process::Command::new("/bin/chmod")
                .arg("-RN")
                .arg(&old_target)
                .status()?;
            assert!(status.success());
        }

        result?;
        assert_eq!(crate::file::read_to_string(target.join("version"))?, "new");
        assert!(!old_target.exists());
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn remove_obsolete_binary_links_removes_only_caskroom_symlinks() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("binary-only", "2.0.0");
        let old_caskroom = caskroom_version_dir(&cask.token, "1.0.0");
        file::create_dir_all(old_caskroom.join("bin"))?;
        crate::file::write(old_caskroom.join("bin/old"), "old")?;
        let old_target = tmp.path().join("bin/old");
        file::create_dir_all(old_target.parent().unwrap())?;
        file::make_symlink(&old_caskroom.join("bin/old"), &old_target)?;

        let external = tmp.path().join("external/outside");
        file::create_dir_all(external.parent().unwrap())?;
        crate::file::write(&external, "outside")?;
        let external_target = tmp.path().join("bin/outside");
        file::make_symlink(&external, &external_target)?;

        remove_obsolete_binary_links(
            &cask,
            &[old_target.clone(), external_target.clone()],
            &[tmp.path().join("bin/new")],
        )?;

        assert!(old_target.symlink_metadata().is_err());
        assert!(external_target.symlink_metadata().is_ok());
        Ok(())
    }

    #[test]
    fn installed_cask_version_does_not_invent_pkg_ids_from_current_api() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("pkg-only", "1.0.0");
        let caskroom = caskroom_version_dir(&cask.token, &cask.version);
        file::create_dir_all(&caskroom)?;
        let receipt = CaskReceipt {
            schema_version: 0,
            version: cask.version.clone(),
            apps: vec![],
            binaries: vec![],
            fonts: vec![],
            completions: vec![],
            flight_directories: vec![],
            generic: vec![],
            pkg_ids: vec![],
            targets: Vec::new(),
            prune_safe: false,
            prune_blocker: None,
        };
        crate::file::write(
            caskroom.join(".mise-cask.toml"),
            toml::to_string_pretty(&receipt)?,
        )?;

        assert_eq!(
            installed_cask_version(
                &cask,
                &CaskArtifacts {
                    pkgs: vec![PkgArtifact {
                        source: "Example.pkg".to_string(),
                    }],
                    pkg_ids: vec!["com.example.missing".to_string()],
                    ..Default::default()
                }
            )?,
            Some("1.0.0".to_string())
        );
        Ok(())
    }

    #[test]
    fn installed_cask_version_rejects_app_state_without_receipt() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("actual-token", "1.0.0");
        let app = AppArtifact {
            source: "Example.app".to_string(),
            target: Some("$HOMEBREW_PREFIX/Applications/Example.app".to_string()),
        };
        file::create_dir_all(caskroom_version_dir(&cask.token, &cask.version))?;

        assert_eq!(
            installed_cask_version(
                &cask,
                &CaskArtifacts {
                    apps: vec![app.clone()],
                    ..Default::default()
                }
            )?,
            None
        );

        file::create_dir_all(app_target_path(app.target_name())?)?;
        assert_eq!(
            installed_cask_version(
                &cask,
                &CaskArtifacts {
                    apps: vec![app],
                    ..Default::default()
                }
            )?,
            None
        );
        Ok(())
    }

    #[test]
    fn installed_cask_version_rejects_completion_state_without_receipt() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("completion-only", "1.0.0");
        let completion = CompletionArtifact {
            shell: CompletionShell::Zsh,
            source: "ghostty".to_string(),
            target: None,
        };
        file::create_dir_all(caskroom_version_dir(&cask.token, &cask.version))?;
        let artifacts = CaskArtifacts {
            completions: vec![completion.clone()],
            ..Default::default()
        };

        assert_eq!(installed_cask_version(&cask, &artifacts)?, None);

        let target = completion.target_path()?;
        file::create_dir_all(target.parent().unwrap())?;
        crate::file::write(target, "complete")?;
        assert_eq!(installed_cask_version(&cask, &artifacts)?, None);
        Ok(())
    }

    #[test]
    fn installed_cask_version_uses_metadata_token() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("metadata-token", "2.0.0");
        let app = AppArtifact {
            source: "Example.app".to_string(),
            target: Some("$HOMEBREW_PREFIX/Applications/Example.app".to_string()),
        };
        file::create_dir_all(caskroom_version_dir("configured-name", &cask.version))?;
        file::create_dir_all(app_target_path(app.target_name())?)?;

        assert_eq!(
            installed_cask_version(
                &cask,
                &CaskArtifacts {
                    apps: vec![app],
                    ..Default::default()
                }
            )?,
            None
        );

        let caskroom = caskroom_version_dir(&cask.token, &cask.version);
        file::create_dir_all(&caskroom)?;
        let receipt = CaskReceipt {
            schema_version: 0,
            version: cask.version.clone(),
            apps: vec![app_target_path(
                "$HOMEBREW_PREFIX/Applications/Example.app",
            )?],
            binaries: Vec::new(),
            fonts: Vec::new(),
            completions: Vec::new(),
            flight_directories: Vec::new(),
            generic: Vec::new(),
            pkg_ids: Vec::new(),
            targets: Vec::new(),
            prune_safe: false,
            prune_blocker: None,
        };
        file::write(
            caskroom.join(".mise-cask.toml"),
            toml::to_string_pretty(&receipt)?,
        )?;
        assert_eq!(
            installed_cask_version(
                &cask,
                &CaskArtifacts {
                    apps: vec![AppArtifact {
                        source: "Example.app".to_string(),
                        target: Some("$HOMEBREW_PREFIX/Applications/Example.app".to_string()),
                    }],
                    ..Default::default()
                }
            )?,
            Some("2.0.0".to_string())
        );
        Ok(())
    }

    #[test]
    fn installed_version_ignores_homebrew_metadata() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let token_dir = caskroom_token_dir("actual-token");
        file::create_dir_all(token_dir.join("2.0.0"))?;
        file::create_dir_all(token_dir.join(".metadata/2.0.0/timestamp/Casks"))?;
        file::create_dir_all(token_dir.join(".mise-tmp-interrupted"))?;

        assert_eq!(installed_version("actual-token"), Some("2.0.0".to_string()));
        Ok(())
    }

    #[test]
    fn installed_versions_preserve_conflict_presence_with_multiple_versions() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let token_dir = caskroom_token_dir("conflicting-cask");
        file::create_dir_all(token_dir.join("1.0.0"))?;
        file::create_dir_all(token_dir.join("2.0.0"))?;

        assert_eq!(installed_version("conflicting-cask"), None);
        assert_eq!(installed_versions("conflicting-cask").len(), 2);
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn failed_activation_restores_caskroom_and_external_links() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let cask = test_cask("completion-only", "1.0.0");
        let destination = caskroom_version_dir(&cask.token, &cask.version);
        let staged = caskroom_tmp_dir(&cask);
        let relative = Path::new("etc/bash_completion.d/tool");
        file::create_dir_all(destination.join(relative).parent().unwrap())?;
        file::create_dir_all(staged.join(relative).parent().unwrap())?;
        crate::file::write(destination.join(relative), "previous")?;
        crate::file::write(staged.join(relative), "replacement")?;
        let target = tmp.path().join(relative);
        let new_target = tmp.path().join("bin/new-tool");
        file::create_dir_all(target.parent().unwrap())?;
        file::create_dir_all(new_target.parent().unwrap())?;
        file::make_symlink(&destination.join(relative), &target)?;
        let mut link_transaction =
            ArtifactLinkTransaction::begin(vec![target.clone(), new_target.clone()])?;

        let err = replace_caskroom(&cask, &staged, &destination, || {
            file::make_symlink(&destination.join(relative), &target)?;
            file::make_symlink(&destination.join("bin/new-tool"), &new_target)?;
            Err(eyre!("link failed"))
        })
        .unwrap_err();
        link_transaction.rollback()?;

        assert_eq!(err.to_string(), "link failed");
        assert_eq!(crate::file::read_to_string(&target)?, "previous");
        assert!(new_target.symlink_metadata().is_err());
        assert!(!caskroom_backup_dir(&cask).exists());
        Ok(())
    }

    #[test]
    fn remove_stale_versions_keeps_current_version_and_homebrew_metadata() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;
        let _guard = BrewPrefixGuard::set(tmp.path());
        let token_dir = caskroom_token_dir("actual-token");
        file::create_dir_all(token_dir.join("1.0.0"))?;
        file::create_dir_all(token_dir.join("2.0.0"))?;
        let metadata = token_dir.join(".metadata/2.0.0/timestamp/Casks");
        file::create_dir_all(&metadata)?;
        crate::file::write(metadata.join("actual-token.json"), "metadata")?;

        remove_stale_versions(&token_dir, "2.0.0")?;

        assert!(!token_dir.join("1.0.0").exists());
        assert!(token_dir.join("2.0.0").exists());
        assert_eq!(
            crate::file::read_to_string(metadata.join("actual-token.json"))?,
            "metadata"
        );
        Ok(())
    }

    #[test]
    fn fetch_git_clone_and_stage_clones_and_restructures_only_path() -> Result<()> {
        let _lock = crate::test::lock_ignoring_poison(&ENV_LOCK);
        let tmp = tempfile::tempdir()?;

        // Create a local git repo to clone from
        let repo = tmp.path().join("repo.git");
        std::fs::create_dir_all(repo.join("fonts").join("sample"))?;
        std::fs::write(
            repo.join("fonts").join("sample").join("font.ttf"),
            "initial content",
        )?;
        std::fs::write(
            repo.join("fonts").join("sample").join("font-bold.ttf"),
            "bold",
        )?;

        // Use --initial-branch to avoid depending on the configured default branch name.
        let repo_str = repo.to_string_lossy().to_string();
        let run = |args: &[&str]| -> Result<()> {
            let mut cmd = std::process::Command::new("git");
            if !cmd.args(args).status()?.success() {
                bail!("git {} failed", args.join(" "));
            }
            Ok(())
        };
        run(&["-C", &repo_str, "init", "-q", "--initial-branch=main"])?;
        run(&[
            "-C",
            &repo_str,
            "-c",
            "user.email=test@test",
            "-c",
            "user.name=test",
            "add",
            "-A",
        ])?;
        run(&[
            "-C",
            &repo_str,
            "-c",
            "user.email=test@test",
            "-c",
            "user.name=test",
            "commit",
            "-q",
            "-m",
            "baseline",
        ])?;

        // Create a dedicated branch with different content to verify branch selection.
        run(&["-C", &repo_str, "checkout", "-q", "-b", "fonts-v2"])?;
        std::fs::write(
            repo.join("fonts").join("sample").join("font.ttf"),
            "branch content",
        )?;
        run(&[
            "-C",
            &repo_str,
            "-c",
            "user.email=test@test",
            "-c",
            "user.name=test",
            "commit",
            "-q",
            "-a",
            "-m",
            "updated fonts",
        ])?;

        let url = format!("file://{}", repo.display());

        let cask = Cask {
            token: "font-test".to_string(),
            aliases: vec![],
            old_tokens: vec![],
            version: "latest".to_string(),
            url,
            url_specs: CaskUrlSpecs {
                branch: Some("fonts-v2".to_string()),
                only_path: Some("fonts/sample".to_string()),
            },
            sha256: Some("no_check".to_string()),
            artifacts: vec![],
            depends_on: CaskDependencies::default(),
            conflicts_with: CaskConflicts::default(),
            ruby_source_path: None,
            ruby_source_checksum: None,
            tap_git_head: None,
            raw_base: None,
        };

        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()?;
        let stage = rt.block_on(fetch_git_clone_and_stage(&cask, None))?;

        assert!(stage.join("font.ttf").is_file());
        assert!(stage.join("font-bold.ttf").is_file());
        // Verify the content from the dedicated branch, not the default branch.
        assert_eq!(
            std::fs::read_to_string(stage.join("font.ttf"))?,
            "branch content"
        );
        Ok(())
    }
}
