Skip to main content

agent_float_term/
install.rs

1//! User-local installation. Install/uninstall without `yes` are read-only previews.
2//! Explicit bind/start also register verified Homebrew packages without startup config edits.
3//! `yes` authorizes payload/templates, not implicit edits to shell or tmux user configuration.
4//! Each user integration requires its own explicit configuration path; omitted integrations
5//! are retained on reinstall, except exact owned references migrated to a new layout.
6//! Run an extracted binary's `install --yes` directly, rather
7//! than placing an unowned regular file at the managed bin-symlink destination first.
8//! Updates accept local native executables only; no downloads or version probes are performed.
9
10#[path = "install/fsops.rs"]
11mod fsops;
12
13use crate::config::{checked_path, ensure_user_dir, private_dir, Paths};
14use anyhow::{bail, Context, Result};
15use fsops::{lock, regular, snapshot, Content, Snapshot, Transaction};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use std::env;
19use std::fs;
20use std::os::unix::fs::MetadataExt;
21use std::path::{Path, PathBuf};
22
23const BEGIN: &str = "# BEGIN agent-float-term managed v1";
24const END: &str = "# END agent-float-term managed v1";
25const BINARY: &str = "agent-float-term";
26
27#[derive(Debug, Clone, Default)]
28pub struct InstallOptions {
29    /// Register integrations only; the package manager retains ownership of this stable path.
30    pub external_binary: Option<PathBuf>,
31    /// Opt into editing this tmux configuration. None never selects a default user file.
32    pub tmux_config: Option<PathBuf>,
33    /// Independently opt into editing this shell configuration; does not imply tmux integration.
34    pub shell_config: Option<PathBuf>,
35    /// Bash or zsh. Login-shell inference is used only when shell_config is supplied.
36    pub shell_kind: Option<String>,
37    /// Apply the previewed plan. This does not opt into either user configuration integration.
38    pub yes: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43struct OwnedText {
44    path: PathBuf,
45    text: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50struct Manifest {
51    format: u32,
52    paths: Paths,
53    shell_kind: String,
54    current: Option<String>,
55    previous: Option<String>,
56    releases: Vec<String>,
57    files: Vec<OwnedText>,
58    blocks: Vec<OwnedText>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    external: Option<External>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65struct External {
66    path: PathBuf,
67    active: bool,
68}
69
70fn read_manifest(paths: &Paths) -> Result<(Snapshot, Option<Manifest>)> {
71    let before = snapshot(&paths.state.join("install.json"))?;
72    let value = regular(&before)?
73        .map(serde_json::from_slice::<Manifest>)
74        .transpose()
75        .context("invalid installation manifest")?;
76    Ok((before, value))
77}
78
79fn manifest(paths: &Paths) -> Result<(Snapshot, Option<Manifest>)> {
80    let (before, value) = read_manifest(paths)?;
81    if let Some(value) = &value {
82        validate_manifest(paths, value)?;
83    }
84    Ok((before, value))
85}
86
87fn validate_manifest(paths: &Paths, value: &Manifest) -> Result<()> {
88    if !matches!(value.format, 1..=3) || value.paths != *paths {
89        bail!("installation manifest version or directory roots do not match");
90    }
91    match (value.format, &value.external) {
92        (3, Some(external))
93            if value.current.is_none() && value.previous.is_none() && value.releases.is_empty() =>
94        {
95            // Descriptor checks must not depend on the package still being installed.
96            external_location(paths, &external.path)?;
97            if value.blocks.iter().any(|block| block.path == external.path) {
98                bail!("external binary cannot be an owned integration target");
99            }
100        }
101        (1 | 2, None) => (),
102        _ => bail!("invalid or mixed managed/external installation manifest"),
103    }
104    if !matches!(value.shell_kind.as_str(), "bash" | "zsh")
105        || value.releases.iter().any(|s| !valid_digest(s))
106        || value
107            .current
108            .iter()
109            .chain(value.previous.iter())
110            .any(|s| !value.releases.contains(s))
111        || value.files.len() > 2
112        || value.blocks.len() > 2
113    {
114        bail!("invalid installation manifest entries");
115    }
116    let root = if value.format == 1 {
117        &paths.config
118    } else {
119        &paths.data
120    };
121    let mut names = std::collections::HashSet::new();
122    for file in &value.files {
123        if (file.path != root.join("integration.tmux") && file.path != root.join("integration.sh"))
124            || !names.insert(file.path.file_name())
125        {
126            bail!("manifest contains an unexpected owned file");
127        }
128    }
129    let mut kinds = std::collections::HashSet::new();
130    let mut targets = std::collections::HashSet::new();
131    for block in &value.blocks {
132        user_target(paths, &block.path)?;
133        block_range(block.text.as_bytes(), &block.text)?;
134        if !kinds.insert(block_kind(block)?) || !targets.insert(&block.path) {
135            bail!("manifest contains duplicate integration kinds or targets");
136        }
137    }
138    Ok(())
139}
140
141fn valid_digest(text: &str) -> bool {
142    text.len() == 64
143        && text
144            .bytes()
145            .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
146}
147
148fn digest(bytes: &[u8]) -> String {
149    format!("{:x}", Sha256::digest(bytes))
150}
151
152/// POSIX shell word quoting, also usable by other integration modules.
153pub fn shell_quote(text: &str) -> String {
154    format!("'{}'", text.replace('\'', "'\\''"))
155}
156
157// Both callers explicitly enable tmux format expansion. Protect literal '#' first,
158// then quote for tmux's command parser (which is not the shell parser).
159fn tmux_quote(text: &str) -> String {
160    format!(
161        "\"{}\"",
162        text.replace('#', "##")
163            .replace('\\', "\\\\")
164            .replace('"', "\\\"")
165            .replace('$', "\\$")
166    )
167}
168
169fn glob_quote(text: &str) -> String {
170    let mut quoted = String::new();
171    for c in text.chars() {
172        if matches!(c, '\\' | '*' | '?' | '[' | ']' | '{' | '}') {
173            quoted.push('\\');
174        }
175        quoted.push(c);
176    }
177    quoted
178}
179
180fn native_binary(path: &Path) -> Result<Vec<u8>> {
181    checked_path(path)?;
182    let file = snapshot(path)?;
183    let Content::File(bytes, mode) = file.content else {
184        bail!("update/install source must be a regular file, not a symlink");
185    };
186    if mode & 0o111 == 0 || mode & 0o6000 != 0 {
187        bail!("source must be executable and must not be setuid/setgid");
188    }
189    #[cfg(target_os = "linux")]
190    let native = bytes.len() >= 64
191        && bytes.starts_with(b"\x7fELF")
192        && matches!(bytes[4], 1 | 2)
193        && matches!(bytes[5], 1 | 2)
194        && bytes[6] == 1;
195    #[cfg(target_os = "macos")]
196    let native = bytes.len() >= 32
197        && matches!(
198            &bytes[..4],
199            b"\xfe\xed\xfa\xce"
200                | b"\xce\xfa\xed\xfe"
201                | b"\xfe\xed\xfa\xcf"
202                | b"\xcf\xfa\xed\xfe"
203                | b"\xca\xfe\xba\xbe"
204                | b"\xbe\xba\xfe\xca"
205                | b"\xca\xfe\xba\xbf"
206                | b"\xbf\xba\xfe\xca"
207        );
208    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
209    let native = false;
210    if !native {
211        bail!("source is not a recognizable native ELF/Mach-O executable for this OS");
212    }
213    Ok(bytes)
214}
215
216fn absolute(path: PathBuf) -> Result<PathBuf> {
217    let path = if path.is_absolute() {
218        path
219    } else {
220        env::current_dir()?.join(path)
221    };
222    checked_path(&path)?;
223    Ok(path)
224}
225
226fn external_location(paths: &Paths, path: &Path) -> Result<()> {
227    checked_path(path).context("--external-binary requires an absolute stable path")?;
228    if [&paths.config, &paths.data, &paths.state]
229        .iter()
230        .any(|root| path.starts_with(root))
231    {
232        bail!("external binary cannot overlap app-managed config/data/state directories");
233    }
234    let components: Vec<_> = path.components().collect();
235    if components.windows(4).any(|parts| {
236        parts[0].as_os_str() == "Cellar"
237            && parts[2].as_os_str().to_str().is_some_and(|version| {
238                version.starts_with(|c: char| c.is_ascii_digit()) || version.starts_with("HEAD")
239            })
240    }) {
241        bail!("external binary must be stable, not a versioned Homebrew Cellar path; use the opt path");
242    }
243    Ok(())
244}
245
246// Inspect every directory and symlink hop, not only the canonical destination. Relative
247// symlinks (including Homebrew's ../Cellar targets) are intentional package indirection.
248fn trusted_path(path: &Path, package_directories: &[PathBuf]) -> Result<PathBuf> {
249    use std::collections::VecDeque;
250    use std::path::Component;
251
252    checked_path(path)?;
253    let mut pending: VecDeque<_> = path
254        .components()
255        .map(|c| c.as_os_str().to_owned())
256        .collect();
257    let mut resolved = PathBuf::new();
258    let mut links = 0;
259    let package_group = if package_directories.is_empty() {
260        None
261    } else {
262        homebrew_group()
263    };
264    while let Some(part) = pending.pop_front() {
265        match Path::new(&part)
266            .components()
267            .next()
268            .context("empty path component")?
269        {
270            Component::RootDir => resolved = PathBuf::from("/"),
271            Component::CurDir => continue,
272            Component::ParentDir => {
273                resolved.pop();
274                continue;
275            }
276            Component::Normal(_) => resolved.push(part),
277            _ => bail!("unsupported path component"),
278        }
279        let metadata = fs::symlink_metadata(&resolved)
280            .with_context(|| format!("inspect external path {}", resolved.display()))?;
281        if metadata.uid() != 0 && metadata.uid() != crate::config::uid() {
282            bail!(
283                "external path must be owned by root or the current user: {}",
284                resolved.display()
285            );
286        }
287        if metadata.file_type().is_symlink() {
288            links += 1;
289            if links > 40 {
290                bail!("too many external path symlinks");
291            }
292            let target = fs::read_link(&resolved)?;
293            if target
294                .to_str()
295                .context("non-UTF-8 symlink target")?
296                .chars()
297                .any(char::is_control)
298            {
299                bail!("unsafe external symlink target");
300            }
301            resolved.pop();
302            for component in target.components().rev() {
303                pending.push_front(component.as_os_str().to_owned());
304            }
305            continue;
306        }
307        let system_temporary = metadata.is_dir()
308            && metadata.uid() == 0
309            && metadata.mode() & 0o1000 != 0
310            && matches!(
311                resolved.to_str(),
312                Some(
313                    "/tmp"
314                        | "/private/tmp"
315                        | "/var/tmp"
316                        | "/private/var/tmp"
317                        | "/private/var/folders"
318                        | "/var/folders"
319                )
320            );
321        let package_directory = metadata.is_dir()
322            && package_group == Some(metadata.gid())
323            && package_directories.contains(&resolved);
324        if metadata.mode() & 0o6000 != 0
325            || (metadata.mode() & 0o022 != 0
326                && !system_temporary
327                && !(package_directory && metadata.mode() & 0o002 == 0))
328        {
329            bail!(
330                "external path is setid or writable by untrusted group/others: {}",
331                resolved.display()
332            );
333        }
334        if !pending.is_empty() && !metadata.is_dir() {
335            bail!(
336                "external path ancestor is not a directory: {}",
337                resolved.display()
338            );
339        }
340    }
341    Ok(resolved)
342}
343
344#[cfg(target_os = "linux")]
345fn homebrew_group() -> Option<u32> {
346    // Homebrew install.sh uses `id -gn`, the invoking user's effective primary group.
347    // SAFETY: getegid has no preconditions.
348    Some(unsafe { libc::getegid() })
349}
350
351#[cfg(not(target_os = "linux"))]
352fn homebrew_group() -> Option<u32> {
353    let mut group = std::mem::MaybeUninit::<libc::group>::uninit();
354    let mut buffer = [0u8; 16384];
355    let mut result = std::ptr::null_mut();
356    // SAFETY: the name is NUL-terminated and all output pointers refer to live buffers.
357    // Use the reentrant lookup: runtime helpers and tests can validate concurrently.
358    let status = unsafe {
359        libc::getgrnam_r(
360            c"admin".as_ptr(),
361            group.as_mut_ptr(),
362            buffer.as_mut_ptr().cast(),
363            buffer.len(),
364            &mut result,
365        )
366    };
367    if status != 0 || result.is_null() {
368        return None;
369    }
370    // SAFETY: successful getgrnam_r populated result; copy the gid before buffers expire.
371    Some(unsafe { (*result).gr_gid })
372}
373
374// Package registration trusts macOS's admin group or Linux's effective primary group
375// ONLY on a verified same-prefix/formula opt -> Cellar route (or sibling bin symlink).
376// Linux also permits a group-writable prefix: install.sh creates it 0755 but retains
377// existing prefix modes. Ancestors ABOVE the prefix and the executable stay strict.
378// This is neither an ownership-layout requirement for other external paths nor an
379// app manifest/config exception. Policy source: Homebrew/install/HEAD/install.sh.
380fn homebrew_directories(path: &Path) -> Option<Vec<PathBuf>> {
381    if path.file_name()? != BINARY || path.parent()?.file_name()? != "bin" {
382        return None;
383    }
384    let target = path.canonicalize().ok()?;
385    if target.file_name()? != BINARY || target.parent()?.file_name()? != "bin" {
386        return None;
387    }
388    let keg = target.parent()?.parent()?;
389    let formula = keg.parent()?;
390    let cellar = formula.parent()?;
391    if cellar.file_name()? != "Cellar" {
392        return None;
393    }
394    let prefix = cellar.parent()?;
395    let opt = prefix.join("opt").join(formula.file_name()?);
396    if !fs::symlink_metadata(&opt).ok()?.file_type().is_symlink() || opt.canonicalize().ok()? != keg
397    {
398        return None;
399    }
400    let parent = path.parent()?.parent()?;
401    let sibling_bin = parent.canonicalize().ok()? == prefix
402        && fs::symlink_metadata(path).ok()?.file_type().is_symlink();
403    let stable_opt = parent.file_name() == formula.file_name()
404        && parent.parent()?.file_name()? == "opt"
405        && parent.parent()?.parent()?.canonicalize().ok()? == prefix;
406    if !sibling_bin && !stable_opt {
407        return None;
408    }
409    let mut directories = vec![
410        prefix.join("opt"),
411        prefix.join("bin"),
412        cellar.into(),
413        formula.into(),
414        keg.into(),
415        keg.join("bin"),
416    ];
417    if cfg!(target_os = "linux") {
418        directories.push(prefix.into());
419    }
420    // A sibling bin -> Cellar link does not itself traverse opt; the opt route
421    // authorizing this exception must still have trusted ownership/permissions.
422    trusted_path(&opt, &directories).ok()?;
423    Some(directories)
424}
425
426fn verify_external(paths: &Paths, path: &Path) -> Result<PathBuf> {
427    external_location(paths, path)?;
428    let resolved = trusted_path(path, &homebrew_directories(path).unwrap_or_default())?;
429    for root in [&paths.config, &paths.data, &paths.state] {
430        // Resolve existing ancestry even before the installer has created its directories.
431        let mut ancestor = root.as_path();
432        let mut suffix = Vec::new();
433        let canonical = loop {
434            match ancestor.canonicalize() {
435                Ok(canonical) => break canonical,
436                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
437                    suffix.push(ancestor.file_name().context("missing root ancestor")?);
438                    ancestor = ancestor.parent().context("missing root parent")?;
439                }
440                Err(error) => return Err(error).context("resolve app directory"),
441            }
442        };
443        let canonical = suffix
444            .into_iter()
445            .rev()
446            .fold(canonical, |path, part| path.join(part));
447        if resolved.starts_with(canonical) {
448            bail!("external binary resolves into an app-managed directory");
449        }
450    }
451    let metadata = fs::metadata(&resolved)?;
452    if !metadata.is_file() || metadata.mode() & 0o111 == 0 || metadata.mode() & 0o6022 != 0 {
453        bail!(
454            "external target must be a regular executable, non-setid and not group/world writable"
455        );
456    }
457    let cpath = std::ffi::CString::new(resolved.as_os_str().as_encoded_bytes())?;
458    // SAFETY: cpath is NUL-terminated and access does not retain the pointer.
459    if unsafe { libc::access(cpath.as_ptr(), libc::X_OK) } != 0 {
460        return Err(std::io::Error::last_os_error())
461            .context("external target is not executable by this user");
462    }
463    Ok(resolved)
464}
465
466/// Read-only runtime selection. Preserve package indirection across upgrades, independently
467/// of the running image (which may already have been removed from the old Cellar).
468pub(crate) fn external_helper_path(paths: &Paths) -> Result<Option<PathBuf>> {
469    let (_, value) = read_manifest(paths)?;
470    let Some(value) = value else {
471        return Ok(None);
472    };
473    // Managed runtime selection historically ignores installer roots. Alternate XDG
474    // config/data roots must not invalidate its stable binary path. Still parse the
475    // complete schema so malformed/mixed external descriptors cannot fail open.
476    if matches!(value.format, 1 | 2) && value.external.is_none() {
477        return Ok(None);
478    }
479    validate_manifest(paths, &value)?;
480    let Some(external) = value.external.filter(|external| external.active) else {
481        return Ok(None);
482    };
483    trusted_path(&paths.state.join("install.json"), &[])?;
484    verify_external(paths, &external.path)
485        .context("registered external binary is unavailable or unsafe; repair it with your package manager, or uninstall the integrations")?;
486    Ok(Some(external.path))
487}
488
489fn external_state_ancestry(paths: &Paths) -> Result<()> {
490    checked_path(&paths.state)?;
491    // Check the nearest existing ancestor without creating future private components.
492    // lstat distinguishes an absent component from a dangling, untrusted symlink.
493    for ancestor in paths.state.ancestors() {
494        match fs::symlink_metadata(ancestor) {
495            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
496            Err(error) => {
497                return Err(error).context("inspect external registration state ancestry")
498            }
499            Ok(_) => {
500                let resolved = trusted_path(ancestor, &[])
501                    .context("external registration requires trusted state ancestry")?;
502                if !fs::metadata(resolved)?.is_dir() {
503                    bail!("external registration state ancestor is not a directory");
504                }
505                return Ok(());
506            }
507        }
508    }
509    bail!("external registration state has no existing ancestor")
510}
511
512fn user_target(paths: &Paths, path: &Path) -> Result<()> {
513    checked_path(path)?;
514    if [&paths.config, &paths.data, &paths.state, &paths.bin]
515        .iter()
516        .any(|root| path.starts_with(root))
517    {
518        bail!(
519            "user configuration cannot overlap installer-owned directories: {}",
520            path.display()
521        );
522    }
523    Ok(())
524}
525
526fn integration(
527    paths: &Paths,
528    options: &InstallOptions,
529) -> Result<(String, Vec<OwnedText>, Vec<OwnedText>)> {
530    let kind = options.shell_kind.clone().unwrap_or_else(|| {
531        options
532            .shell_config
533            .as_ref()
534            .and_then(|_| env::var_os("SHELL"))
535            .and_then(|s| {
536                Path::new(&s)
537                    .file_name()
538                    .map(|s| s.to_string_lossy().into_owned())
539            })
540            .unwrap_or_else(|| "bash".into())
541    });
542    if !matches!(kind.as_str(), "bash" | "zsh") {
543        bail!("automatic shell integration supports bash and zsh only; specify --shell-kind");
544    }
545    let tmux_path = options.tmux_config.clone().map(absolute).transpose()?;
546    let shell_path = options.shell_config.clone().map(absolute).transpose()?;
547    for path in tmux_path.iter().chain(shell_path.iter()) {
548        user_target(paths, path)?;
549    }
550    if tmux_path.is_some() && tmux_path == shell_path {
551        bail!("tmux and shell configuration must be different files");
552    }
553    for path in [&paths.config, &paths.data, &paths.state, &paths.bin] {
554        checked_path(path)?;
555    }
556    let binary_path = options
557        .external_binary
558        .clone()
559        .unwrap_or_else(|| paths.bin.join(BINARY));
560    let binary = shell_quote(binary_path.to_str().context("non-UTF-8 binary path")?);
561    let tmux_file = paths.data.join("integration.tmux");
562    let shell_file = paths.data.join("integration.sh");
563    // Setness matters: even `-ic ''` is a tool command, not a human prompt.
564    let guards = format!(
565        r#"[ -z "${{BASH_EXECUTION_STRING+x}}" ] && [ -z "${{ZSH_EXECUTION_STRING+x}}" ] &&
566       [ -z "${{ZSH_SCRIPT+x}}" ] &&
567       [ -z "${{SSH_CONNECTION-}}" ] && [ -z "${{SSH_CLIENT-}}" ] && [ -z "${{SSH_TTY-}}" ] &&
568       [ -z "${{AFT_DISABLE-}}" ] && [ -z "${{AFT_STARTING-}}" ] &&
569       [ -z "${{_AFT_AUTO_STARTED-}}" ] && [ -x {binary} ]"#
570    );
571    let start = format!(
572        r#"if {guards} && [ -t 0 ] && [ -t 1 ]; then
573      _AFT_AUTO_STARTED=1
574      AFT_STARTING=1 AFT_QUIET=1 {binary} start || printf '%s\n' 'agent-float-term: startup failed; continuing this shell' >&2
575    fi"#
576    );
577    let initialize = if kind == "zsh" {
578        format!(
579            r#"if {guards} && (( ! ${{+functions[__aft_initialize]}} )); then
580    __aft_initialize() {{
581      emulate -L zsh
582      # Instant-prompt cleanup normally precedes us; allow just one late cleanup.
583      if [[ -o interactive ]] && {guards} &&
584         {{ [ ! -t 0 ] || [ ! -t 1 ]; }} && [ -z "${{_AFT_INIT_RETRIED-}}" ]; then
585        _AFT_INIT_RETRIED=1
586        return 0
587      fi
588      precmd_functions=("${{precmd_functions[@]:#__aft_initialize}}")
589      unset _AFT_INIT_RETRIED
590      unfunction __aft_initialize
591      {start}
592      return 0
593    }}
594    typeset -ga precmd_functions
595    precmd_functions+=(__aft_initialize)
596  fi"#
597        )
598    } else {
599        start
600    };
601    let files = vec![
602        OwnedText {
603            path: tmux_file.clone(),
604            text: format!("# Generated by agent-float-term; local bind refuses key collisions.\nrun-shell {}\n", tmux_quote(&format!("{binary} bind"))),
605        },
606        OwnedText {
607            path: shell_file.clone(),
608            text: format!("# Generated by agent-float-term for {kind}.\ncase $- in\n  *i*)\n  {initialize}\n  ;;\nesac\n"),
609        },
610    ];
611    let mut blocks = Vec::new();
612    if let Some(path) = tmux_path {
613        blocks.push(OwnedText {
614            path,
615            text: format!(
616                "{BEGIN}\nsource-file -q -F {}\n{END}\n",
617                tmux_quote(&glob_quote(
618                    tmux_file.to_str().context("non-UTF-8 integration path")?
619                ))
620            ),
621        });
622    }
623    if let Some(path) = shell_path {
624        blocks.push(OwnedText {
625            path,
626            text: format!(
627                "{BEGIN}\nif [ -r {0} ]; then . {0}; fi\n{END}\n",
628                shell_quote(shell_file.to_str().context("non-UTF-8 integration path")?)
629            ),
630        });
631    }
632    Ok((kind, files, blocks))
633}
634
635// Version-1 manifests already persist the exact generated text, but not an integration
636// kind. Identify that existing format without changing the persisted schema.
637fn block_kind(block: &OwnedText) -> Result<&'static str> {
638    let body = block
639        .text
640        .trim_start_matches('\n')
641        .strip_prefix(BEGIN)
642        .and_then(|text| text.strip_prefix('\n'))
643        .context("invalid recorded integration block")?;
644    if body.starts_with("source-file ") {
645        Ok("tmux")
646    } else if body.starts_with("if [ -r ") {
647        Ok("shell")
648    } else {
649        bail!("unknown recorded integration block kind");
650    }
651}
652
653fn occurrences(bytes: &[u8], needle: &[u8]) -> Vec<usize> {
654    bytes
655        .windows(needle.len())
656        .enumerate()
657        .filter_map(|(i, s)| (s == needle).then_some(i))
658        .collect()
659}
660
661fn block_range(bytes: &[u8], expected: &str) -> Result<std::ops::Range<usize>> {
662    if expected.is_empty() || !expected.contains(BEGIN) || !expected.contains(END) {
663        bail!("invalid recorded managed block");
664    }
665    let begins = occurrences(bytes, b"# BEGIN agent-float-term");
666    let ends = occurrences(bytes, b"# END agent-float-term");
667    let exact = occurrences(bytes, expected.as_bytes());
668    if begins.len() != 1 || ends.len() != 1 || exact.len() != 1 || begins[0] >= ends[0] {
669        bail!("managed block is missing, malformed, duplicated, or user-edited; refusing to overwrite it");
670    }
671    let start = exact[0];
672    let marker = begins[0];
673    if (marker > 0 && bytes[marker - 1] != b'\n') || !expected.ends_with('\n') {
674        bail!("managed markers must occupy complete lines");
675    }
676    Ok(start..start + expected.len())
677}
678
679fn append_block(bytes: &[u8], block: &mut OwnedText) -> Result<Vec<u8>> {
680    if !occurrences(bytes, b"# BEGIN agent-float-term").is_empty()
681        || !occurrences(bytes, b"# END agent-float-term").is_empty()
682    {
683        bail!("unowned or malformed agent-float-term markers; manual review is required");
684    }
685    if !bytes.is_empty() && !bytes.ends_with(b"\n") {
686        // Include the separator in our ownership record so uninstall restores the exact bytes.
687        block.text.insert(0, '\n');
688    }
689    let mut result = bytes.to_vec();
690    result.extend_from_slice(block.text.as_bytes());
691    Ok(result)
692}
693
694fn file_content(before: &Snapshot, bytes: Vec<u8>) -> Content {
695    let mode = match before.content {
696        Content::File(_, mode) => mode,
697        _ => 0o600,
698    };
699    Content::File(bytes, mode)
700}
701
702fn owned_link(path: &Path, target: Option<&Path>) -> Result<Snapshot> {
703    let before = snapshot(path)?;
704    match (&before.content, target) {
705        (Content::Missing, None) => (),
706        (Content::Link(actual), Some(expected)) if actual == expected => (),
707        _ => bail!(
708            "refusing missing, changed, or unowned installation pointer: {}",
709            path.display()
710        ),
711    }
712    Ok(before)
713}
714
715fn release_binary(paths: &Paths, id: &str) -> PathBuf {
716    paths.data.join("releases").join(id).join(BINARY)
717}
718
719fn release_target(id: &str) -> PathBuf {
720    PathBuf::from("releases").join(id)
721}
722
723fn verify_release(paths: &Paths, id: &str) -> Result<()> {
724    if !valid_digest(id) {
725        bail!("invalid release digest");
726    }
727    fs::symlink_metadata(paths.data.join("releases").join(id))
728        .context("release directory is missing")?;
729    private_dir(&paths.data.join("releases"))?;
730    private_dir(&paths.data.join("releases").join(id))?;
731    let bytes = native_binary(&release_binary(paths, id))?;
732    if digest(&bytes) != id {
733        bail!("installed release was modified: {id}");
734    }
735    Ok(())
736}
737
738fn write_manifest(
739    tx: &mut Transaction,
740    paths: &Paths,
741    before: &Snapshot,
742    value: &Manifest,
743) -> Result<()> {
744    let mut bytes = serde_json::to_vec_pretty(value)?;
745    bytes.push(b'\n');
746    tx.change(
747        &paths.state.join("install.json"),
748        before,
749        Content::File(bytes, 0o600),
750    )
751}
752
753/// Print a plan and return without creating even a lock file unless `options.yes` is true.
754/// Reinstall retains unselected integrations, except exact owned references that
755/// must move with the generated scripts during a layout migration.
756pub fn install(options: InstallOptions) -> Result<()> {
757    // macOS may report the invoked symlink, including our stable installed entry
758    // point. Resolve our own image; explicit update sources still reject symlinks.
759    let source = env::current_exe()?
760        .canonicalize()
761        .context("resolve running executable")?;
762    install_at(&Paths::discover()?, options, &source)
763}
764
765/// Called only by explicit bind/start, after their basic runtime validation.
766pub(crate) fn register_homebrew() -> Result<()> {
767    let source = env::current_exe()?
768        .canonicalize()
769        .context("resolve running executable")?;
770    let Some(bin) = source
771        .parent()
772        .filter(|bin| bin.file_name().is_some_and(|n| n == "bin"))
773    else {
774        return Ok(());
775    };
776    let Some(formula) = bin.parent().and_then(Path::parent) else {
777        return Ok(());
778    };
779    if source.file_name().is_none_or(|name| name != BINARY)
780        || formula.file_name().is_none_or(|name| name != BINARY)
781        || formula
782            .parent()
783            .and_then(Path::file_name)
784            .is_none_or(|name| name != "Cellar")
785    {
786        // Cargo, archives, and source builds retain their explicit setup behavior.
787        return Ok(());
788    }
789    let prefix = formula
790        .parent()
791        .and_then(Path::parent)
792        .context("Homebrew prefix")?;
793    let stable = prefix.join("opt").join(BINARY).join("bin").join(BINARY);
794    let paths = Paths::discover()?;
795    if homebrew_directories(&stable).is_none() || verify_external(&paths, &stable)? != source {
796        bail!("Homebrew opt path must be trusted and resolve to the running executable; repair the package links and run {} bind or start", stable.display());
797    }
798    install_at_mode(
799        &paths,
800        InstallOptions {
801            external_binary: Some(stable),
802            yes: true,
803            ..InstallOptions::default()
804        },
805        &source,
806        true,
807    )
808}
809
810fn install_at(paths: &Paths, options: InstallOptions, source: &Path) -> Result<()> {
811    install_at_mode(paths, options, source, false)
812}
813
814fn install_at_mode(
815    paths: &Paths,
816    options: InstallOptions,
817    source: &Path,
818    first_use: bool,
819) -> Result<()> {
820    if let Some(path) = &options.external_binary {
821        external_location(paths, path)?;
822    }
823    integration(paths, &options)?;
824    if options.external_binary.is_some()
825        || read_manifest(paths)?
826            .1
827            .is_some_and(|value| value.external.is_some() || value.format == 3)
828    {
829        external_state_ancestry(paths)?;
830    }
831    let _lock = if options.yes {
832        Some(lock(&paths.state)?)
833    } else {
834        None
835    };
836    let (manifest_before, old) = manifest(paths)?;
837    if first_use {
838        if let Some(value) = &old {
839            match &value.external {
840                Some(external) if external.active && Some(&external.path) == options.external_binary.as_ref() => {
841                    // Do not regenerate templates or opt-in blocks on ordinary bind/start.
842                    return Ok(());
843                }
844                _ => bail!("Homebrew first-use registration conflicts with the existing installation mode or external path; explicitly uninstall the existing integration (review `agent-float-term uninstall`, then use --yes) before running this Homebrew binary's bind/start; retained user edits must be reviewed first"),
845            }
846        }
847    }
848    let migrating = old.as_ref().is_some_and(|value| value.format == 1);
849    let mut effective = options.clone();
850    if let Some(value) = &old {
851        match (&value.external, &options.external_binary) {
852            (Some(external), requested) => {
853                if !external.active {
854                    bail!("external integrations were uninstalled with residual user edits; review the retained content and finish uninstall before reinstalling");
855                }
856                if requested.as_ref().is_some_and(|path| path != &external.path) {
857                    bail!("external binary path cannot change; explicitly uninstall and reinstall with --external-binary");
858                }
859                effective.external_binary = Some(external.path.clone());
860            }
861            (None, Some(_)) => bail!("cannot switch a managed installation to external mode; explicitly uninstall and reinstall first"),
862            (None, None) => (),
863        }
864    }
865    if let Some(path) = &effective.external_binary {
866        external_state_ancestry(paths)?;
867        let resolved = verify_external(paths, path)?;
868        if resolved
869            != source
870                .canonicalize()
871                .context("resolve installation source")?
872        {
873            bail!("--external-binary must resolve to the running installation source; run {} install with this stable path (uninstall/reinstall to change registrations)", path.display());
874        }
875        for owned in [paths.data.join("current"), paths.data.join("releases")] {
876            match fs::symlink_metadata(&owned) {
877                Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
878                Err(error) => return Err(error).context("inspect managed payload"),
879                Ok(_) => bail!("external registration conflicts with managed payload at {}; uninstall/review it first", owned.display()),
880            }
881        }
882        if fs::read_link(paths.bin.join(BINARY))
883            .is_ok_and(|target| target == paths.data.join("current").join(BINARY))
884        {
885            bail!("external registration conflicts with a managed bin pointer; uninstall/review it first");
886        }
887        for target in options
888            .shell_config
889            .iter()
890            .chain(&options.tmux_config)
891            .chain(
892                old.iter()
893                    .flat_map(|value| value.blocks.iter().map(|block| &block.path)),
894            )
895        {
896            if absolute(target.clone())? == *path
897                || target.canonicalize().is_ok_and(|target| target == resolved)
898                || fs::metadata(target).is_ok_and(|target| {
899                    fs::metadata(&resolved).is_ok_and(|binary| {
900                        target.dev() == binary.dev() && target.ino() == binary.ino()
901                    })
902                })
903            {
904                bail!("external executable cannot be edited as a user integration");
905            }
906        }
907    }
908    if let Some(old) = &old {
909        if options.shell_config.is_none() {
910            effective.shell_kind = Some(old.shell_kind.clone());
911        }
912        if migrating {
913            for installed in &old.blocks {
914                match block_kind(installed)? {
915                    "shell" if effective.shell_config.is_none() => {
916                        effective.shell_config = Some(installed.path.clone())
917                    }
918                    "tmux" if effective.tmux_config.is_none() => {
919                        effective.tmux_config = Some(installed.path.clone())
920                    }
921                    _ => (),
922                }
923            }
924        }
925    }
926    let (kind, files, mut blocks) = integration(paths, &effective)?;
927    println!("Install agent-float-term from {}", source.display());
928    if let Some(path) = &effective.external_binary {
929        println!(
930            "  External integration-only registration: {} (retained on plain reinstall)",
931            path.display()
932        );
933        println!("  The package manager owns the executable; no binary copy, chmod, release payload, or bin/current links. Use your package manager to update or roll back.");
934    } else {
935        println!(
936            "  Releases: {}",
937            paths.data.join("releases/<sha256>").display()
938        );
939        println!(
940            "  Stable binary: {} -> {}",
941            paths.bin.join(BINARY).display(),
942            paths.data.join("current").join(BINARY).display()
943        );
944    }
945    for file in &files {
946        println!("  Owned integration: {}", file.path.display());
947    }
948    for block in &blocks {
949        println!("  Managed block: {}", block.path.display());
950    }
951    if migrating {
952        println!("  Migrate legacy layout: update exact recorded startup blocks and remove only intact legacy scripts.");
953        for file in &old.as_ref().context("missing migration manifest")?.files {
954            if !files.iter().any(|new| new.path == file.path) {
955                println!(
956                    "  Remove after migration (only if intact): {}",
957                    file.path.display()
958                );
959            }
960        }
961    } else {
962        println!("  Unselected user configurations will not be edited; existing integration blocks are retained.");
963    }
964    println!("  Private manifest/backups: {}", paths.state.display());
965    if !options.yes {
966        println!("Preview only; pass --yes to apply. No files changed.");
967        return Ok(());
968    }
969    let payload = if effective.external_binary.is_none() {
970        let bytes = native_binary(source)?;
971        Some((digest(&bytes), bytes))
972    } else {
973        None
974    };
975    if let Some(old) = &old {
976        for selected in &blocks {
977            for installed in &old.blocks {
978                let same_kind = block_kind(selected)? == block_kind(installed)?;
979                if same_kind != (selected.path == installed.path) {
980                    bail!("selected integration conflicts with the recorded target {}; uninstall the old integration before changing its target", installed.path.display());
981                }
982            }
983        }
984    }
985    let mut edits = Vec::new();
986    let mut removals = Vec::new();
987    if let Some(old) = old.as_ref().filter(|_| migrating) {
988        for file in &old.files {
989            if files.iter().any(|new| new.path == file.path) {
990                continue;
991            }
992            let before = snapshot(&file.path)?;
993            if regular(&before)? != Some(file.text.as_bytes()) {
994                bail!(
995                    "legacy integration is missing or edited; migration left it untouched: {}",
996                    file.path.display()
997                );
998            }
999            removals.push((file.path.clone(), before, Content::Missing));
1000        }
1001    }
1002    for file in &files {
1003        let before = snapshot(&file.path)?;
1004        let existing = regular(&before)?;
1005        let owned = old
1006            .as_ref()
1007            .and_then(|m| m.files.iter().find(|f| f.path == file.path));
1008        match (existing, owned) {
1009            (None, None) => (),
1010            (Some(bytes), Some(owned)) if bytes == owned.text.as_bytes() => (),
1011            _ => bail!(
1012                "owned integration is missing, edited, or pre-existing: {}",
1013                file.path.display()
1014            ),
1015        }
1016        edits.push((
1017            file.path.clone(),
1018            before,
1019            Content::File(file.text.as_bytes().to_vec(), 0o600),
1020        ));
1021    }
1022    for block in &mut blocks {
1023        let before = snapshot(&block.path)?;
1024        let bytes = regular(&before)?.unwrap_or_default();
1025        let replacement = if let Some(owned) = old
1026            .as_ref()
1027            .and_then(|m| m.blocks.iter().find(|b| b.path == block.path))
1028        {
1029            let range = block_range(bytes, &owned.text)
1030                .with_context(|| block.path.display().to_string())?;
1031            if owned.text.starts_with('\n') {
1032                block.text.insert(0, '\n');
1033            }
1034            let mut replacement = bytes.to_vec();
1035            replacement.splice(range, block.text.bytes());
1036            replacement
1037        } else {
1038            append_block(bytes, block).with_context(|| block.path.display().to_string())?
1039        };
1040        let content = file_content(&before, replacement);
1041        edits.push((block.path.clone(), before, content));
1042    }
1043    private_dir(&paths.data)?;
1044    let pointers = if payload.is_some() {
1045        ensure_user_dir(&paths.bin)?;
1046        private_dir(&paths.data.join("releases"))?;
1047        let current_before = owned_link(
1048            &paths.data.join("current"),
1049            old.as_ref()
1050                .and_then(|m| m.current.as_deref())
1051                .map(release_target)
1052                .as_deref(),
1053        )?;
1054        let bin_before = owned_link(
1055            &paths.bin.join(BINARY),
1056            old.as_ref()
1057                .and_then(|m| m.current.as_ref())
1058                .map(|_| paths.data.join("current").join(BINARY))
1059                .as_deref(),
1060        )?;
1061        Some((current_before, bin_before))
1062    } else {
1063        None
1064    };
1065    let mut next = old.clone().unwrap_or(Manifest {
1066        format: 2,
1067        paths: paths.clone(),
1068        shell_kind: kind.clone(),
1069        current: None,
1070        previous: None,
1071        releases: Vec::new(),
1072        files: Vec::new(),
1073        blocks: Vec::new(),
1074        external: None,
1075    });
1076    if let Some(current) = &next.current {
1077        verify_release(paths, current)?;
1078    }
1079    let mut tx = Transaction::new(&paths.state)?;
1080    if let Some((id, bytes)) = &payload {
1081        stage_release(&mut tx, paths, id, bytes)?;
1082    }
1083    // Install the new targets and repoint exact managed blocks before removing
1084    // legacy scripts. The transaction restores every changed file on failure.
1085    for (path, before, content) in edits.into_iter().chain(removals) {
1086        tx.change(&path, &before, content)?;
1087    }
1088    if let Some((id, _)) = &payload {
1089        if next.current.as_deref() != Some(id) {
1090            next.previous = next.current.replace(id.clone());
1091        }
1092        if !next.releases.contains(id) {
1093            next.releases.push(id.clone());
1094        }
1095    }
1096    next.external = effective
1097        .external_binary
1098        .clone()
1099        .map(|path| External { path, active: true });
1100    next.format = if next.external.is_some() { 3 } else { 2 };
1101    next.files = files;
1102    if options.shell_config.is_some() {
1103        next.shell_kind = kind;
1104    }
1105    for block in blocks {
1106        if let Some(installed) = next.blocks.iter_mut().find(|old| old.path == block.path) {
1107            *installed = block;
1108        } else {
1109            next.blocks.push(block);
1110        }
1111    }
1112    if let (Some((id, _)), Some((current_before, bin_before))) = (&payload, &pointers) {
1113        tx.change(
1114            &paths.data.join("current"),
1115            current_before,
1116            Content::Link(release_target(id)),
1117        )?;
1118        tx.change(
1119            &paths.bin.join(BINARY),
1120            bin_before,
1121            Content::Link(paths.data.join("current").join(BINARY)),
1122        )?;
1123    }
1124    write_manifest(&mut tx, paths, &manifest_before, &next)?;
1125    tx.commit()?;
1126    println!("Installed {}. Only selected or migration-owned startup blocks were edited; existing tmux sessions are untouched.", payload.as_ref().map_or("external integrations", |(id, _)| id.as_str()));
1127    Ok(())
1128}
1129
1130fn stage_release(tx: &mut Transaction, paths: &Paths, id: &str, bytes: &[u8]) -> Result<()> {
1131    let directory = paths.data.join("releases").join(id);
1132    private_dir(&directory)?;
1133    let path = directory.join(BINARY);
1134    let before = snapshot(&path)?;
1135    match &before.content {
1136        Content::Missing => tx.change(&path, &before, Content::File(bytes.to_vec(), 0o700))?,
1137        Content::File(existing, mode) if existing == bytes && *mode == 0o700 => (),
1138        _ => bail!(
1139            "release destination is not an intact owned executable: {}",
1140            path.display()
1141        ),
1142    }
1143    Ok(())
1144}
1145
1146/// Activate a checksum-verified local executable. Never execute the supplied file to inspect it.
1147pub fn update(from: &Path, sha256: &str) -> Result<()> {
1148    update_at(&Paths::discover()?, &absolute(from.into())?, sha256)
1149}
1150
1151fn update_at(paths: &Paths, from: &Path, sha256: &str) -> Result<()> {
1152    reject_external_update(paths)?;
1153    let expected = sha256.to_ascii_lowercase();
1154    if !valid_digest(&expected) {
1155        bail!("--sha256 must be exactly 64 hexadecimal characters");
1156    }
1157    let bytes = native_binary(from)?;
1158    let id = digest(&bytes);
1159    if id != expected {
1160        bail!("SHA-256 mismatch: expected {expected}, got {id}; nothing activated");
1161    }
1162    let _lock = lock(&paths.state)?;
1163    private_dir(&paths.data)?;
1164    let (before, value) = manifest(paths)?;
1165    let mut value = value.context("not installed; run install --yes first")?;
1166    if value.external.is_some() {
1167        bail!("external executable is package-managed; update or roll back with your package manager instead");
1168    }
1169    let current = value
1170        .current
1171        .as_deref()
1172        .context("installation has been uninstalled")?;
1173    verify_release(paths, current)?;
1174    let pointer = owned_link(&paths.data.join("current"), Some(&release_target(current)))?;
1175    owned_link(
1176        &paths.bin.join(BINARY),
1177        Some(&paths.data.join("current").join(BINARY)),
1178    )?;
1179    if id == current {
1180        println!("Already using {id}; nothing changed.");
1181        return Ok(());
1182    }
1183    let mut tx = Transaction::new(&paths.state)?;
1184    stage_release(&mut tx, paths, &id, &bytes)?;
1185    value.previous = value.current.replace(id.clone());
1186    if !value.releases.contains(&id) {
1187        value.releases.push(id.clone());
1188    }
1189    tx.change(
1190        &paths.data.join("current"),
1191        &pointer,
1192        Content::Link(release_target(&id)),
1193    )?;
1194    write_manifest(&mut tx, paths, &before, &value)?;
1195    tx.commit()?;
1196    println!("Activated {id}; rollback is available. Existing sessions were not changed.");
1197    Ok(())
1198}
1199
1200pub fn rollback() -> Result<()> {
1201    rollback_at(&Paths::discover()?)
1202}
1203
1204fn reject_external_update(paths: &Paths) -> Result<()> {
1205    if manifest(paths)?
1206        .1
1207        .is_some_and(|value| value.external.is_some())
1208    {
1209        bail!("external executable is package-managed; update or roll back with your package manager (for example brew upgrade or cargo install), not agent-float-term update/rollback");
1210    }
1211    Ok(())
1212}
1213
1214fn rollback_at(paths: &Paths) -> Result<()> {
1215    reject_external_update(paths)?;
1216    let _lock = lock(&paths.state)?;
1217    private_dir(&paths.data)?;
1218    let (before, value) = manifest(paths)?;
1219    let mut value = value.context("not installed")?;
1220    if value.external.is_some() {
1221        bail!("external executable is package-managed; update or roll back with your package manager instead");
1222    }
1223    let previous = value
1224        .previous
1225        .clone()
1226        .context("no previous release available")?;
1227    let current = value
1228        .current
1229        .as_deref()
1230        .context("installation has been uninstalled")?;
1231    verify_release(paths, &previous)?;
1232    let pointer = owned_link(&paths.data.join("current"), Some(&release_target(current)))?;
1233    owned_link(
1234        &paths.bin.join(BINARY),
1235        Some(&paths.data.join("current").join(BINARY)),
1236    )?;
1237    let mut tx = Transaction::new(&paths.state)?;
1238    value.previous = value.current.replace(previous.clone());
1239    tx.change(
1240        &paths.data.join("current"),
1241        &pointer,
1242        Content::Link(release_target(&previous)),
1243    )?;
1244    write_manifest(&mut tx, paths, &before, &value)?;
1245    tx.commit()?;
1246    println!("Rolled back to {previous}; existing sessions were not changed.");
1247    Ok(())
1248}
1249
1250/// Remove only manifest-owned, unmodified content. Never terminate tmux or shell sessions.
1251/// This filesystem-only entry point does not restore live tmux bindings. CLI callers
1252/// should use `uninstall_with_hook` to restore runtime-owned bindings before removal.
1253pub fn uninstall(yes: bool) -> Result<()> {
1254    uninstall_at(&Paths::discover()?, yes)
1255}
1256
1257fn uninstall_at(paths: &Paths, yes: bool) -> Result<()> {
1258    uninstall_at_with_hook(paths, yes, || Ok(()))
1259}
1260
1261/// Preview the file-removal plan, then restore runtime integration before removing files.
1262/// The hook runs once, under the installer lock, only with `yes` and an existing manifest,
1263/// after file checks but before any planned removal. An error aborts removal and keeps
1264/// installed files. The hook must not reenter the installer or terminate user sessions;
1265/// restore only bindings whose exact ownership the runtime can verify.
1266///
1267/// A preview (`yes == false`) never invokes the hook. Runtime bindings still need an
1268/// executable-absence fallback for servers not reachable during this cleanup. Runtime
1269/// changes are not rolled back if a subsequent filesystem operation fails.
1270pub fn uninstall_with_hook(yes: bool, before_remove: impl FnOnce() -> Result<()>) -> Result<()> {
1271    uninstall_at_with_hook(&Paths::discover()?, yes, before_remove)
1272}
1273
1274fn uninstall_at_with_hook(
1275    paths: &Paths,
1276    yes: bool,
1277    before_remove: impl FnOnce() -> Result<()>,
1278) -> Result<()> {
1279    let _lock = if yes { Some(lock(&paths.state)?) } else { None };
1280    let (_, preview) = manifest(paths)?;
1281    let Some(preview) = preview else {
1282        println!("No installation manifest; nothing to remove.");
1283        return Ok(());
1284    };
1285    println!("Uninstall owned installation at {}", paths.data.display());
1286    for block in &preview.blocks {
1287        println!(
1288            "  Remove unmodified managed block: {}",
1289            block.path.display()
1290        );
1291    }
1292    for file in &preview.files {
1293        println!("  Remove unmodified integration: {}", file.path.display());
1294    }
1295    if let Some(external) = &preview.external {
1296        println!(
1297            "  Leave package-owned executable untouched (even if missing/replaced): {}",
1298            external.path.display()
1299        );
1300    } else {
1301        println!("  Remove owned binary pointers and checksum-intact release payloads; retain private backups and user configuration.");
1302    }
1303    if !yes {
1304        println!("Preview only; pass --yes to apply. No files changed.");
1305        return Ok(());
1306    }
1307    private_dir(&paths.data)?;
1308    let (before, value) = manifest(paths)?;
1309    let mut value = value.context("manifest disappeared concurrently")?;
1310    let mut edits = Vec::new();
1311    let mut retained_blocks = Vec::new();
1312    for block in &value.blocks {
1313        let removal = (|| -> Result<()> {
1314            let before = snapshot(&block.path)?;
1315            let Some(bytes) = regular(&before)? else {
1316                return Ok(());
1317            };
1318            let mut range = block_range(bytes, &block.text)?;
1319            // A separator we originally inserted may now separate two user-owned
1320            // lines if the user appended configuration after our block.
1321            if block.text.starts_with('\n')
1322                && range.end < bytes.len()
1323                && range.start > 0
1324                && bytes[range.start - 1] != b'\n'
1325            {
1326                range.start += 1;
1327            }
1328            let mut bytes = bytes.to_vec();
1329            bytes.drain(range);
1330            let content = file_content(&before, bytes);
1331            edits.push((block.path.clone(), before, content));
1332            Ok(())
1333        })();
1334        if let Err(error) = removal {
1335            eprintln!("Preserving {}: {error:#}", block.path.display());
1336            retained_blocks.push(block.clone());
1337        }
1338    }
1339    let mut retained_files = Vec::new();
1340    for file in &value.files {
1341        let removal = (|| -> Result<()> {
1342            let before = snapshot(&file.path)?;
1343            match regular(&before)? {
1344                None => Ok(()),
1345                Some(bytes) if bytes == file.text.as_bytes() => {
1346                    edits.push((file.path.clone(), before, Content::Missing));
1347                    Ok(())
1348                }
1349                _ => bail!("owned file was edited"),
1350            }
1351        })();
1352        if let Err(error) = removal {
1353            eprintln!("Preserving {}: {error:#}", file.path.display());
1354            retained_files.push(file.clone());
1355        }
1356    }
1357    let mut retained_pointer = false;
1358    if let Some(current) = &value.current {
1359        for (path, target) in [
1360            (
1361                paths.bin.join(BINARY),
1362                paths.data.join("current").join(BINARY),
1363            ),
1364            (paths.data.join("current"), release_target(current)),
1365        ] {
1366            let removal = (|| -> Result<()> {
1367                if snapshot(&path)?.content == Content::Missing {
1368                    return Ok(());
1369                }
1370                let before = owned_link(&path, Some(&target))?;
1371                edits.push((path.clone(), before, Content::Missing));
1372                Ok(())
1373            })();
1374            if let Err(error) = removal {
1375                eprintln!("Preserving {}: {error:#}", path.display());
1376                retained_pointer = true;
1377            }
1378        }
1379    }
1380    let mut retained_releases = Vec::new();
1381    for id in &value.releases {
1382        let path = release_binary(paths, id);
1383        let removal = (|| -> Result<()> {
1384            if snapshot(&path)?.content == Content::Missing {
1385                return Ok(());
1386            }
1387            verify_release(paths, id)?;
1388            let before = snapshot(&path)?;
1389            if regular(&before)?.map(digest).as_deref() != Some(id) {
1390                bail!("release changed concurrently");
1391            }
1392            edits.push((path.clone(), before, Content::Missing));
1393            Ok(())
1394        })();
1395        if let Err(error) = removal {
1396            eprintln!("Preserving {}: {error:#}", path.display());
1397            retained_releases.push(id.clone());
1398        }
1399    }
1400    value.blocks = retained_blocks;
1401    value.files = retained_files;
1402    value.releases = retained_releases;
1403    value.current = None;
1404    value.previous = None;
1405    if let Some(external) = &mut value.external {
1406        external.active = false;
1407    }
1408    before_remove()
1409        .context("runtime integration cleanup failed; installed files were not removed")?;
1410    let mut tx = Transaction::new(&paths.state)?;
1411    for (path, before, content) in edits {
1412        tx.change(&path, &before, content)?;
1413    }
1414    if value.blocks.is_empty()
1415        && value.files.is_empty()
1416        && value.releases.is_empty()
1417        && !retained_pointer
1418    {
1419        tx.change(&paths.state.join("install.json"), &before, Content::Missing)?;
1420    } else {
1421        write_manifest(&mut tx, paths, &before, &value)?;
1422        eprintln!("Some changed/unowned content was preserved; the residual manifest is retained for review.");
1423    }
1424    tx.commit()?;
1425    // Empty directories only: never recursively delete user additions or private backups.
1426    for id in &preview.releases {
1427        if !value.releases.contains(id) {
1428            let _ = fs::remove_dir(paths.data.join("releases").join(id));
1429        }
1430    }
1431    if preview.external.is_none() {
1432        let _ = fs::remove_dir(paths.data.join("releases"));
1433    }
1434    println!("Uninstalled owned content. Sessions, user configuration, user edits, and private backups were preserved.");
1435    Ok(())
1436}
1437
1438#[cfg(test)]
1439mod tests {
1440    use super::*;
1441    use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt};
1442
1443    struct Fixture {
1444        _temp: tempfile::TempDir,
1445        paths: Paths,
1446        options: InstallOptions,
1447        source: PathBuf,
1448    }
1449
1450    impl Fixture {
1451        fn new() -> Self {
1452            let temp = tempfile::tempdir().unwrap();
1453            let home = temp
1454                .path()
1455                .join("home # '$\\\" [*?]{x} #{socket_path} #(false) space");
1456            private_dir(&home).unwrap();
1457            let paths = Paths {
1458                config: home.join(".config/agent-float-term"),
1459                data: home.join(".local/share/agent-float-term"),
1460                state: home.join(".local/state/agent-float-term"),
1461                bin: home.join(".local/bin"),
1462            };
1463            let options = InstallOptions {
1464                external_binary: None,
1465                tmux_config: Some(home.join(".tmux.conf")),
1466                shell_config: Some(home.join(".bashrc")),
1467                shell_kind: Some("bash".into()),
1468                yes: true,
1469            };
1470            let source = home.join("download");
1471            write_binary(&source, 1);
1472            Self {
1473                _temp: temp,
1474                paths,
1475                options,
1476                source,
1477            }
1478        }
1479
1480        fn install(&self) -> Result<()> {
1481            install_at(&self.paths, self.options.clone(), &self.source)
1482        }
1483    }
1484
1485    fn write_binary(path: &Path, discriminator: u8) {
1486        let mut bytes = vec![0u8; 64];
1487        #[cfg(target_os = "macos")]
1488        bytes[..4].copy_from_slice(b"\xcf\xfa\xed\xfe");
1489        #[cfg(target_os = "linux")]
1490        bytes[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
1491        bytes[63] = discriminator;
1492        fs::write(path, bytes).unwrap();
1493        fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap();
1494    }
1495
1496    #[test]
1497    fn external_preview_reinstall_and_uninstall_never_own_package_payload() {
1498        let mut fixture = Fixture::new();
1499        fixture.options.external_binary = Some(fixture.source.clone());
1500        fs::set_permissions(&fixture.source, fs::Permissions::from_mode(0o755)).unwrap();
1501        let original = snapshot(&fixture.source).unwrap();
1502        let mut preview = fixture.options.clone();
1503        preview.yes = false;
1504        install_at(&fixture.paths, preview, &fixture.source).unwrap();
1505        for path in [
1506            &fixture.paths.config,
1507            &fixture.paths.data,
1508            &fixture.paths.state,
1509            &fixture.paths.bin,
1510        ] {
1511            assert!(!path.exists());
1512        }
1513        fixture.install().unwrap();
1514        let value = manifest(&fixture.paths).unwrap().1.unwrap();
1515        assert_eq!(value.format, 3);
1516        assert!(value.current.is_none() && value.previous.is_none() && value.releases.is_empty());
1517        assert_eq!(
1518            external_helper_path(&fixture.paths).unwrap(),
1519            Some(fixture.source.clone())
1520        );
1521        let before = snapshot(&fixture.paths.state.join("install.json")).unwrap();
1522        install_at(
1523            &fixture.paths,
1524            InstallOptions {
1525                yes: true,
1526                ..Default::default()
1527            },
1528            &fixture.source,
1529        )
1530        .unwrap();
1531        assert_eq!(
1532            snapshot(&fixture.paths.state.join("install.json")).unwrap(),
1533            before
1534        );
1535        assert!(update_at(&fixture.paths, Path::new("/missing"), "invalid")
1536            .unwrap_err()
1537            .to_string()
1538            .contains("package manager"));
1539        assert!(rollback_at(&fixture.paths)
1540            .unwrap_err()
1541            .to_string()
1542            .contains("package manager"));
1543        assert_eq!(
1544            snapshot(&fixture.paths.state.join("install.json")).unwrap(),
1545            before
1546        );
1547        assert_eq!(snapshot(&fixture.source).unwrap(), original);
1548        assert!(!fixture.paths.data.join("releases").exists());
1549        assert!(!fixture.paths.data.join("current").exists());
1550        assert!(!fixture.paths.bin.exists());
1551        uninstall_at_with_hook(&fixture.paths, false, || panic!("preview hook")).unwrap();
1552        assert_eq!(
1553            snapshot(&fixture.paths.state.join("install.json")).unwrap(),
1554            before
1555        );
1556        assert!(uninstall_at_with_hook(&fixture.paths, true, || bail!("hook failed")).is_err());
1557        assert_eq!(
1558            snapshot(&fixture.paths.state.join("install.json")).unwrap(),
1559            before
1560        );
1561        assert!(fixture.paths.data.join("integration.sh").exists());
1562        let called = std::cell::Cell::new(false);
1563        uninstall_at_with_hook(&fixture.paths, true, || {
1564            called.set(true);
1565            Ok(())
1566        })
1567        .unwrap();
1568        assert!(called.get());
1569        assert_eq!(snapshot(&fixture.source).unwrap(), original);
1570        assert!(external_helper_path(&fixture.paths).unwrap().is_none());
1571    }
1572
1573    #[test]
1574    fn external_opt_retarget_survives_removed_running_image() {
1575        let mut fixture = Fixture::new();
1576        let prefix = fixture.source.parent().unwrap().join("brew");
1577        let old = prefix.join("Cellar/agent-float-term/1/bin");
1578        let new = prefix.join("Cellar/agent-float-term/2/bin");
1579        let opt = prefix.join("opt/agent-float-term");
1580        private_dir(&old).unwrap();
1581        private_dir(&new).unwrap();
1582        private_dir(opt.parent().unwrap()).unwrap();
1583        write_binary(&old.join(BINARY), 1);
1584        write_binary(&new.join(BINARY), 2);
1585        symlink("../Cellar/agent-float-term/1", &opt).unwrap();
1586        let stable = opt.join("bin").join(BINARY);
1587        fixture.source = old.join(BINARY);
1588        fixture.options.external_binary = Some(stable.clone());
1589        fixture.install().unwrap();
1590        fs::remove_file(&opt).unwrap();
1591        symlink("../Cellar/agent-float-term/2", &opt).unwrap();
1592        fs::remove_dir_all(old.parent().unwrap()).unwrap();
1593        let before = snapshot(&fixture.paths.state.join("install.json")).unwrap();
1594        assert_eq!(
1595            external_helper_path(&fixture.paths).unwrap(),
1596            Some(stable.clone())
1597        );
1598        assert_eq!(
1599            snapshot(&fixture.paths.state.join("install.json")).unwrap(),
1600            before
1601        );
1602        install_at(
1603            &fixture.paths,
1604            InstallOptions {
1605                yes: true,
1606                ..Default::default()
1607            },
1608            &new.join(BINARY),
1609        )
1610        .unwrap();
1611        assert_eq!(
1612            manifest(&fixture.paths)
1613                .unwrap()
1614                .1
1615                .unwrap()
1616                .external
1617                .unwrap()
1618                .path,
1619            stable
1620        );
1621        assert!(external_location(&fixture.paths, &new.join(BINARY))
1622            .unwrap_err()
1623            .to_string()
1624            .contains("opt"));
1625        fs::set_permissions(new.join(BINARY), fs::Permissions::from_mode(0o777)).unwrap();
1626        assert!(external_helper_path(&fixture.paths).is_err());
1627        uninstall_at(&fixture.paths, true).unwrap();
1628        assert_eq!(
1629            fs::metadata(new.join(BINARY)).unwrap().mode() & 0o777,
1630            0o777
1631        );
1632        assert!(fs::symlink_metadata(opt).unwrap().file_type().is_symlink());
1633    }
1634
1635    #[test]
1636    fn external_residual_is_inactive_even_when_package_is_missing_or_replaced() {
1637        for missing in [false, true] {
1638            let mut fixture = Fixture::new();
1639            fixture.options.external_binary = Some(fixture.source.clone());
1640            fixture.install().unwrap();
1641            let script = fixture.paths.data.join("integration.sh");
1642            fs::write(&script, "# user-edited integration\n").unwrap();
1643            if missing {
1644                fs::remove_file(&fixture.source).unwrap();
1645            } else {
1646                write_binary(&fixture.source, 9);
1647                fs::set_permissions(&fixture.source, fs::Permissions::from_mode(0o600)).unwrap();
1648            }
1649            let original = snapshot(&fixture.source).unwrap();
1650            uninstall_at(&fixture.paths, true).unwrap();
1651            let value = manifest(&fixture.paths).unwrap().1.unwrap();
1652            assert!(!value.external.unwrap().active);
1653            assert_eq!(value.files.len(), 1);
1654            assert!(external_helper_path(&fixture.paths).unwrap().is_none());
1655            assert_eq!(
1656                fs::read_to_string(&script).unwrap(),
1657                "# user-edited integration\n"
1658            );
1659            assert_eq!(snapshot(&fixture.source).unwrap(), original);
1660            assert!(fixture.install().is_err());
1661            fs::remove_file(script).unwrap();
1662            uninstall_at(&fixture.paths, true).unwrap();
1663            assert!(!fixture.paths.state.join("install.json").exists());
1664            assert_eq!(snapshot(&fixture.source).unwrap(), original);
1665        }
1666    }
1667
1668    #[test]
1669    fn homebrew_platform_group_directories_are_a_package_only_trust_exception() {
1670        let Some(group) = homebrew_group() else {
1671            eprintln!("no Homebrew platform group; group-writable exception is disabled");
1672            return;
1673        };
1674        let mut fixture = Fixture::new();
1675        let prefix = fixture
1676            .source
1677            .parent()
1678            .unwrap()
1679            .join(if cfg!(target_os = "linux") {
1680                "linuxbrew/.linuxbrew"
1681            } else {
1682                "brew"
1683            });
1684        let keg = prefix.join("Cellar/agent-float-term/1");
1685        let opt = prefix.join("opt/agent-float-term");
1686        let bin = prefix.join("bin");
1687        private_dir(&keg.join("bin")).unwrap();
1688        private_dir(opt.parent().unwrap()).unwrap();
1689        private_dir(&bin).unwrap();
1690        if std::os::unix::fs::chown(&bin, None, Some(group)).is_err() {
1691            eprintln!("test user cannot create platform-group-owned fixtures; group-writable exception not exercised");
1692            return;
1693        }
1694        fs::set_permissions(&prefix, fs::Permissions::from_mode(0o755)).unwrap();
1695        write_binary(&keg.join("bin").join(BINARY), 1);
1696        symlink("../Cellar/agent-float-term/1", &opt).unwrap();
1697        let stable = opt.join("bin").join(BINARY);
1698        let sibling = bin.join(BINARY);
1699        symlink(
1700            "../Cellar/agent-float-term/1/bin/agent-float-term",
1701            &sibling,
1702        )
1703        .unwrap();
1704        let directories = homebrew_directories(&stable).unwrap();
1705        for directory in &directories {
1706            std::os::unix::fs::chown(directory, None, Some(group)).unwrap();
1707            fs::set_permissions(directory, fs::Permissions::from_mode(0o775)).unwrap();
1708        }
1709        fixture.source = keg.join("bin").join(BINARY);
1710        fixture.options.external_binary = Some(stable.clone());
1711        fixture.install().unwrap();
1712        assert_eq!(
1713            external_helper_path(&fixture.paths).unwrap(),
1714            Some(stable.clone())
1715        );
1716        assert!(verify_external(&fixture.paths, &sibling).is_ok());
1717        fs::set_permissions(opt.parent().unwrap(), fs::Permissions::from_mode(0o777)).unwrap();
1718        assert!(verify_external(&fixture.paths, &sibling).is_err());
1719        fs::set_permissions(opt.parent().unwrap(), fs::Permissions::from_mode(0o775)).unwrap();
1720        // Both forms shipped by package managers: sibling bin -> Cellar and bin -> opt.
1721        fs::remove_file(&sibling).unwrap();
1722        symlink("../opt/agent-float-term/bin/agent-float-term", &sibling).unwrap();
1723        assert!(verify_external(&fixture.paths, &sibling).is_ok());
1724        assert!(trusted_path(&stable, &[]).is_err());
1725        let mut app_paths = fixture.paths.clone();
1726        app_paths.state = keg.join("app-state");
1727        fs::create_dir(&app_paths.state).unwrap();
1728        fs::set_permissions(&app_paths.state, fs::Permissions::from_mode(0o700)).unwrap();
1729        let mut app_manifest = manifest(&fixture.paths).unwrap().1.unwrap();
1730        app_manifest.paths = app_paths.clone();
1731        let app_manifest_path = app_paths.state.join("install.json");
1732        fs::write(
1733            &app_manifest_path,
1734            serde_json::to_vec(&app_manifest).unwrap(),
1735        )
1736        .unwrap();
1737        fs::set_permissions(&app_manifest_path, fs::Permissions::from_mode(0o600)).unwrap();
1738        // Even a private manifest leaf cannot borrow the package ancestry exception.
1739        assert!(external_helper_path(&app_paths).is_err());
1740        for directory in &directories {
1741            for mode in [0o777, 0o1777, 0o2775, 0o4775] {
1742                fs::set_permissions(directory, fs::Permissions::from_mode(mode)).unwrap();
1743                // opt does not traverse the sibling bin directory.
1744                let candidate = if directory == &bin.canonicalize().unwrap() {
1745                    &sibling
1746                } else {
1747                    &stable
1748                };
1749                assert!(
1750                    verify_external(&fixture.paths, candidate).is_err(),
1751                    "{} {mode:o}",
1752                    directory.display()
1753                );
1754            }
1755            fs::set_permissions(directory, fs::Permissions::from_mode(0o775)).unwrap();
1756        }
1757        fs::set_permissions(&prefix, fs::Permissions::from_mode(0o775)).unwrap();
1758        assert_eq!(
1759            verify_external(&fixture.paths, &stable).is_ok(),
1760            cfg!(target_os = "linux")
1761        );
1762        fs::set_permissions(&prefix, fs::Permissions::from_mode(0o755)).unwrap();
1763        let parent_mode = fs::metadata(prefix.parent().unwrap())
1764            .unwrap()
1765            .permissions();
1766        fs::set_permissions(prefix.parent().unwrap(), fs::Permissions::from_mode(0o775)).unwrap();
1767        assert!(verify_external(&fixture.paths, &stable).is_err());
1768        fs::set_permissions(prefix.parent().unwrap(), parent_mode).unwrap();
1769        fs::set_permissions(&fixture.source, fs::Permissions::from_mode(0o775)).unwrap();
1770        assert!(verify_external(&fixture.paths, &stable).is_err());
1771        fs::set_permissions(&fixture.source, fs::Permissions::from_mode(0o755)).unwrap();
1772        // A different group does not inherit platform-group trust, even with matching layout.
1773        if std::os::unix::fs::chown(opt.parent().unwrap(), None, Some(group.wrapping_add(1)))
1774            .is_ok()
1775        {
1776            assert!(verify_external(&fixture.paths, &stable).is_err());
1777            std::os::unix::fs::chown(opt.parent().unwrap(), None, Some(group)).unwrap();
1778        }
1779        // A sibling bin link must resolve to the currently selected opt keg.
1780        let other = prefix.join("Cellar/agent-float-term/2/bin");
1781        fs::create_dir_all(&other).unwrap();
1782        write_binary(&other.join(BINARY), 2);
1783        fs::remove_file(&sibling).unwrap();
1784        symlink(
1785            "../Cellar/agent-float-term/2/bin/agent-float-term",
1786            &sibling,
1787        )
1788        .unwrap();
1789        assert!(verify_external(&fixture.paths, &sibling).is_err());
1790        // Unrelated group-writable paths and cross-prefix/cross-formula opt links fail.
1791        let unrelated = prefix.join("unrelated");
1792        private_dir(&unrelated).unwrap();
1793        std::os::unix::fs::chown(&unrelated, None, Some(group)).unwrap();
1794        fs::set_permissions(&unrelated, fs::Permissions::from_mode(0o775)).unwrap();
1795        symlink(&fixture.source, unrelated.join(BINARY)).unwrap();
1796        assert!(verify_external(&fixture.paths, &unrelated.join(BINARY)).is_err());
1797        let alias = prefix.join("opt/other-formula");
1798        symlink("../Cellar/agent-float-term/1", &alias).unwrap();
1799        assert!(verify_external(&fixture.paths, &alias.join("bin").join(BINARY)).is_err());
1800        let outside = fixture._temp.path().join("other-prefix/opt");
1801        fs::create_dir_all(&outside).unwrap();
1802        symlink(&keg, outside.join("agent-float-term")).unwrap();
1803        assert!(verify_external(
1804            &fixture.paths,
1805            &outside.join("agent-float-term/bin").join(BINARY)
1806        )
1807        .is_err());
1808        uninstall_at(&fixture.paths, true).unwrap();
1809        assert!(fixture.source.exists());
1810    }
1811
1812    #[test]
1813    fn external_metadata_and_mode_changes_are_rejected() {
1814        let mut fixture = Fixture::new();
1815        fixture.install().unwrap();
1816        fixture.options.external_binary = Some(fixture.source.clone());
1817        assert!(fixture
1818            .install()
1819            .unwrap_err()
1820            .to_string()
1821            .contains("uninstall"));
1822        uninstall_at(&fixture.paths, true).unwrap();
1823        fixture.install().unwrap();
1824        let path = fixture.paths.state.join("install.json");
1825        let original = fs::read(&path).unwrap();
1826        let value: serde_json::Value = serde_json::from_slice(&original).unwrap();
1827        for case in [
1828            "format",
1829            "missing descriptor",
1830            "unknown field",
1831            "missing active",
1832            "relative",
1833            "overlap",
1834            "cellar",
1835            "current",
1836            "previous",
1837            "releases",
1838        ] {
1839            let mut invalid = value.clone();
1840            match case {
1841                "format" => invalid["format"] = 2.into(),
1842                "missing descriptor" => {
1843                    invalid.as_object_mut().unwrap().remove("external");
1844                }
1845                "unknown field" => invalid["external"]["owned"] = true.into(),
1846                "missing active" => {
1847                    invalid["external"]
1848                        .as_object_mut()
1849                        .unwrap()
1850                        .remove("active");
1851                }
1852                "relative" => invalid["external"]["path"] = "relative/bin".into(),
1853                "overlap" => {
1854                    invalid["external"]["path"] =
1855                        fixture.paths.data.join(BINARY).to_str().unwrap().into()
1856                }
1857                "cellar" => {
1858                    invalid["external"]["path"] =
1859                        "/opt/homebrew/Cellar/agent-float-term/1/bin/agent-float-term".into()
1860                }
1861                "current" => invalid["current"] = "a".repeat(64).into(),
1862                "previous" => invalid["previous"] = "a".repeat(64).into(),
1863                "releases" => invalid["releases"] = serde_json::json!(["a".repeat(64)]),
1864                _ => unreachable!(),
1865            }
1866            fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
1867            assert!(manifest(&fixture.paths).is_err(), "{case}");
1868            assert!(external_helper_path(&fixture.paths).is_err(), "{case}");
1869            assert!(uninstall_at(&fixture.paths, true).is_err(), "{case}");
1870        }
1871        fs::write(&path, original).unwrap();
1872        let alias = fixture.source.with_file_name("another-stable-name");
1873        symlink(&fixture.source, &alias).unwrap();
1874        fixture.options.external_binary = Some(alias);
1875        assert!(fixture
1876            .install()
1877            .unwrap_err()
1878            .to_string()
1879            .contains("uninstall"));
1880    }
1881
1882    #[test]
1883    fn external_registration_preflights_state_ancestry_before_any_writes() {
1884        for existing in [false, true] {
1885            for symlinked in [false, true] {
1886                let mut fixture = Fixture::new();
1887                let shared = fixture._temp.path().join("shared");
1888                private_dir(&shared).unwrap();
1889                let parent = if symlinked {
1890                    let link = fixture._temp.path().join("shared-link");
1891                    symlink(&shared, &link).unwrap();
1892                    link
1893                } else {
1894                    shared.clone()
1895                };
1896                fixture.paths.state = parent.join("future/agent-float-term");
1897                if existing {
1898                    fs::create_dir_all(shared.join("future/agent-float-term")).unwrap();
1899                    fs::set_permissions(&fixture.paths.state, fs::Permissions::from_mode(0o700))
1900                        .unwrap();
1901                }
1902                fs::set_permissions(&shared, fs::Permissions::from_mode(0o775)).unwrap();
1903                fixture.options.external_binary = Some(fixture.source.clone());
1904                let source_before = snapshot(&fixture.source).unwrap();
1905                for yes in [false, true] {
1906                    fixture.options.yes = yes;
1907                    assert!(fixture
1908                        .install()
1909                        .unwrap_err()
1910                        .to_string()
1911                        .contains("trusted state ancestry"));
1912                    assert_eq!(fixture.paths.state.exists(), existing);
1913                    assert!(!fixture.paths.state.join("install.lock").exists());
1914                    assert!(!fixture.paths.state.join("install.json").exists());
1915                    assert!(!fixture.paths.state.join("backups").exists());
1916                    assert!(!fixture.paths.data.exists());
1917                    assert!(!fixture.paths.config.exists());
1918                    assert!(!fixture.options.shell_config.as_ref().unwrap().exists());
1919                    assert!(!fixture.options.tmux_config.as_ref().unwrap().exists());
1920                    assert_eq!(snapshot(&fixture.source).unwrap(), source_before);
1921                    if !existing {
1922                        assert!(!shared.join("future").exists());
1923                    }
1924                }
1925            }
1926        }
1927        // A plain reinstall must detect the recorded external mode before taking a lock.
1928        let mut fixture = Fixture::new();
1929        let shared = fixture._temp.path().join("shared");
1930        private_dir(&shared).unwrap();
1931        fixture.paths.state = shared.join("agent-float-term");
1932        fixture.options.external_binary = Some(fixture.source.clone());
1933        fixture.install().unwrap();
1934        fs::remove_file(fixture.paths.state.join("install.lock")).unwrap();
1935        let before = snapshot(&fixture.paths.state.join("install.json")).unwrap();
1936        fs::set_permissions(&shared, fs::Permissions::from_mode(0o775)).unwrap();
1937        for yes in [false, true] {
1938            let error = install_at(
1939                &fixture.paths,
1940                InstallOptions {
1941                    yes,
1942                    ..Default::default()
1943                },
1944                &fixture.source,
1945            )
1946            .unwrap_err();
1947            assert!(error.to_string().contains("trusted state ancestry"));
1948            assert_eq!(
1949                snapshot(&fixture.paths.state.join("install.json")).unwrap(),
1950                before
1951            );
1952            assert!(!fixture.paths.state.join("install.lock").exists());
1953        }
1954    }
1955
1956    #[test]
1957    fn runtime_managed_selection_allows_alternate_xdg_roots_but_external_does_not() {
1958        for legacy in [false, true] {
1959            let fixture = Fixture::new();
1960            assert!(external_helper_path(&fixture.paths).unwrap().is_none());
1961            if legacy {
1962                legacy_install(&fixture);
1963            } else {
1964                fixture.install().unwrap();
1965            }
1966            let mut alternate = fixture.paths.clone();
1967            alternate.config = fixture
1968                ._temp
1969                .path()
1970                .join("alternate-config/agent-float-term");
1971            alternate.data = fixture._temp.path().join("alternate-data/agent-float-term");
1972            let before = snapshot(&fixture.paths.state.join("install.json")).unwrap();
1973            assert!(manifest(&alternate).is_err());
1974            // None leaves helper_path's existing managed bin/current-exe fallback intact.
1975            assert!(external_helper_path(&alternate).unwrap().is_none());
1976            assert_eq!(
1977                snapshot(&fixture.paths.state.join("install.json")).unwrap(),
1978                before
1979            );
1980            assert!(!alternate.config.exists() && !alternate.data.exists());
1981        }
1982        let mut fixture = Fixture::new();
1983        fixture.options.external_binary = Some(fixture.source.clone());
1984        fixture.install().unwrap();
1985        let mut alternate = fixture.paths.clone();
1986        alternate.config = fixture
1987            ._temp
1988            .path()
1989            .join("alternate-config/agent-float-term");
1990        alternate.data = fixture._temp.path().join("alternate-data/agent-float-term");
1991        assert!(external_helper_path(&alternate).is_err());
1992        let path = fixture.paths.state.join("install.json");
1993        let value = manifest(&fixture.paths).unwrap().1.unwrap();
1994        for case in [
1995            "managed with external",
1996            "missing external",
1997            "bad owned file",
1998            "bad external type",
1999        ] {
2000            let mut invalid = serde_json::to_value(&value).unwrap();
2001            match case {
2002                "managed with external" => invalid["format"] = 2.into(),
2003                "missing external" => {
2004                    invalid.as_object_mut().unwrap().remove("external");
2005                }
2006                "bad owned file" => {
2007                    invalid["files"][0]["path"] = "/unowned/integration.tmux".into()
2008                }
2009                "bad external type" => invalid["external"] = true.into(),
2010                _ => unreachable!(),
2011            }
2012            fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
2013            assert!(external_helper_path(&fixture.paths).is_err(), "{case}");
2014        }
2015    }
2016
2017    #[test]
2018    fn external_paths_require_trusted_ancestry_and_the_running_source() {
2019        let mut fixture = Fixture::new();
2020        fixture.options.yes = false;
2021        for mode in [0o644, 0o775, 0o757, 0o4755, 0o2755] {
2022            fs::set_permissions(&fixture.source, fs::Permissions::from_mode(mode)).unwrap();
2023            assert!(
2024                verify_external(&fixture.paths, &fixture.source).is_err(),
2025                "{mode:o}"
2026            );
2027        }
2028        fs::set_permissions(&fixture.source, fs::Permissions::from_mode(0o755)).unwrap();
2029        for path in [
2030            PathBuf::from("relative"),
2031            fixture.source.with_file_name("bad\nname"),
2032            fixture.source.join("../download"),
2033        ] {
2034            assert!(verify_external(&fixture.paths, &path).is_err());
2035        }
2036        let directory = fixture.source.with_file_name("untrusted");
2037        private_dir(&directory).unwrap();
2038        let alias = directory.join(BINARY);
2039        symlink(&fixture.source, &alias).unwrap();
2040        for mode in [0o777, 0o1777, 0o775] {
2041            fs::set_permissions(&directory, fs::Permissions::from_mode(mode)).unwrap();
2042            assert!(verify_external(&fixture.paths, &alias).is_err(), "{mode:o}");
2043        }
2044        fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap();
2045        assert!(verify_external(&fixture.paths, &alias).is_ok());
2046        // System-owned executables are trusted too, without changing their permissions.
2047        assert!(verify_external(&fixture.paths, Path::new("/usr/bin/true")).is_ok());
2048        assert!(verify_external(&fixture.paths, &directory).is_err());
2049        let cycle = directory.join("cycle");
2050        symlink("cycle", &cycle).unwrap();
2051        assert!(verify_external(&fixture.paths, &cycle).is_err());
2052        let other = fixture.source.with_file_name("different-source");
2053        write_binary(&other, 1);
2054        fixture.options.external_binary = Some(other);
2055        assert!(fixture
2056            .install()
2057            .unwrap_err()
2058            .to_string()
2059            .contains("running installation source"));
2060        private_dir(&fixture.paths.data).unwrap();
2061        let managed = fixture.paths.data.join(BINARY);
2062        write_binary(&managed, 1);
2063        let alias = fixture.source.with_file_name("managed-alias");
2064        symlink(managed, &alias).unwrap();
2065        assert!(verify_external(&fixture.paths, &alias).is_err());
2066        assert!(!fixture.paths.state.exists());
2067    }
2068
2069    #[test]
2070    fn external_registration_refuses_untracked_managed_payload_and_binary_edits() {
2071        let mut fixture = Fixture::new();
2072        fixture.options.external_binary = Some(fixture.source.clone());
2073        fixture.options.yes = false;
2074        private_dir(&fixture.paths.data).unwrap();
2075        for name in ["releases", "current"] {
2076            let occupied = fixture.paths.data.join(name);
2077            private_dir(&occupied).unwrap();
2078            assert!(fixture
2079                .install()
2080                .unwrap_err()
2081                .to_string()
2082                .contains("managed payload"));
2083            fs::remove_dir(occupied).unwrap();
2084        }
2085        private_dir(&fixture.paths.bin).unwrap();
2086        let pointer = fixture.paths.bin.join(BINARY);
2087        symlink(fixture.paths.data.join("current").join(BINARY), &pointer).unwrap();
2088        assert!(fixture
2089            .install()
2090            .unwrap_err()
2091            .to_string()
2092            .contains("managed bin pointer"));
2093        fs::remove_file(pointer).unwrap();
2094        let alias = fixture.source.with_file_name("binary-hardlink");
2095        fs::hard_link(&fixture.source, &alias).unwrap();
2096        fixture.options.shell_config = Some(alias);
2097        assert!(fixture
2098            .install()
2099            .unwrap_err()
2100            .to_string()
2101            .contains("cannot be edited"));
2102        assert!(!fixture.paths.state.exists());
2103    }
2104
2105    #[test]
2106    fn preview_has_no_filesystem_side_effects() {
2107        let fixture = Fixture::new();
2108        let mut options = fixture.options.clone();
2109        options.yes = false;
2110        install_at(&fixture.paths, options, Path::new("/nonexistent/source")).unwrap();
2111        uninstall_at(&fixture.paths, false).unwrap();
2112        for path in [
2113            &fixture.paths.config,
2114            &fixture.paths.data,
2115            &fixture.paths.state,
2116            &fixture.paths.bin,
2117        ] {
2118            assert!(!path.exists());
2119        }
2120        assert!(!fixture.options.tmux_config.as_ref().unwrap().exists());
2121        assert!(!fixture.options.shell_config.as_ref().unwrap().exists());
2122    }
2123
2124    fn legacy_install(fixture: &Fixture) -> Manifest {
2125        fixture.install().unwrap();
2126        let mut value = manifest(&fixture.paths).unwrap().1.unwrap();
2127        let mut legacy_paths = fixture.paths.clone();
2128        legacy_paths.data = fixture.paths.config.clone();
2129        let (_, files, mut blocks) = integration(&legacy_paths, &fixture.options).unwrap();
2130        private_dir(&fixture.paths.config).unwrap();
2131        for (current, legacy) in value.files.iter().zip(&files) {
2132            fs::write(&legacy.path, &legacy.text).unwrap();
2133            fs::remove_file(&current.path).unwrap();
2134        }
2135        for (current, legacy) in value.blocks.iter().zip(&mut blocks) {
2136            if current.text.starts_with('\n') {
2137                legacy.text.insert(0, '\n');
2138            }
2139            let mut bytes = fs::read(&current.path).unwrap();
2140            let range = block_range(&bytes, &current.text).unwrap();
2141            bytes.splice(range, legacy.text.bytes());
2142            fs::write(&legacy.path, bytes).unwrap();
2143        }
2144        value.format = 1;
2145        value.files = files;
2146        value.blocks = blocks;
2147        fs::write(
2148            fixture.paths.state.join("install.json"),
2149            serde_json::to_vec(&value).unwrap(),
2150        )
2151        .unwrap();
2152        value
2153    }
2154
2155    #[test]
2156    fn legacy_layout_migration_preserves_settings_and_unrelated_startup_bytes() {
2157        for kind in ["bash", "zsh"] {
2158            let mut fixture = Fixture::new();
2159            fixture.options.shell_kind = Some(kind.into());
2160            for path in [&fixture.options.shell_config, &fixture.options.tmux_config]
2161                .into_iter()
2162                .flatten()
2163            {
2164                fs::write(path, "# user prefix without newline").unwrap();
2165                fs::set_permissions(path, fs::Permissions::from_mode(0o640)).unwrap();
2166            }
2167            let legacy = legacy_install(&fixture);
2168            let settings = fixture.paths.config.join("config.json");
2169            fs::write(&settings, "{\"shortcut\":\"F8\",\"width\":70}").unwrap();
2170            let settings_before = snapshot(&settings).unwrap();
2171            for block in &legacy.blocks {
2172                let mut bytes = fs::read(&block.path).unwrap();
2173                bytes.extend_from_slice(b"# user suffix\n");
2174                fs::write(&block.path, bytes).unwrap();
2175            }
2176            let paths: Vec<_> = legacy
2177                .files
2178                .iter()
2179                .chain(&legacy.blocks)
2180                .map(|f| f.path.clone())
2181                .chain([
2182                    fixture.paths.state.join("install.json"),
2183                    fixture.paths.data.join("current"),
2184                ])
2185                .collect();
2186            let before: Vec<_> = paths.iter().map(|path| snapshot(path).unwrap()).collect();
2187            install_at(
2188                &fixture.paths,
2189                InstallOptions::default(),
2190                Path::new("/no/source/required/for/preview"),
2191            )
2192            .unwrap();
2193            for (path, original) in paths.iter().zip(&before) {
2194                assert_eq!(snapshot(path).unwrap(), *original);
2195            }
2196            assert!(!fixture.paths.data.join("integration.sh").exists());
2197
2198            let options = InstallOptions {
2199                yes: true,
2200                ..InstallOptions::default()
2201            };
2202            install_at(&fixture.paths, options.clone(), &fixture.source).unwrap();
2203            let migrated = manifest(&fixture.paths).unwrap().1.unwrap();
2204            assert_eq!(migrated.format, 2);
2205            assert_eq!(migrated.shell_kind, kind);
2206            assert_eq!(migrated.blocks.len(), legacy.blocks.len());
2207            for file in &migrated.files {
2208                assert_eq!(file.path.parent(), Some(fixture.paths.data.as_path()));
2209                assert_eq!(fs::read_to_string(&file.path).unwrap(), file.text);
2210            }
2211            for file in &legacy.files {
2212                assert!(!file.path.exists());
2213            }
2214            for block in &migrated.blocks {
2215                let bytes = fs::read(&block.path).unwrap();
2216                let range = block_range(&bytes, &block.text).unwrap();
2217                assert_eq!(&bytes[..range.start], b"# user prefix without newline");
2218                assert_eq!(&bytes[range.end..], b"# user suffix\n");
2219                assert_eq!(fs::metadata(&block.path).unwrap().mode() & 0o777, 0o640);
2220            }
2221            assert_eq!(snapshot(&settings).unwrap(), settings_before);
2222            let manifest_before = snapshot(&fixture.paths.state.join("install.json")).unwrap();
2223            install_at(&fixture.paths, options, &fixture.source).unwrap();
2224            assert_eq!(
2225                snapshot(&fixture.paths.state.join("install.json")).unwrap(),
2226                manifest_before
2227            );
2228            uninstall_at(&fixture.paths, true).unwrap();
2229            assert_eq!(snapshot(&settings).unwrap(), settings_before);
2230            for block in &migrated.blocks {
2231                assert_eq!(
2232                    fs::read_to_string(&block.path).unwrap(),
2233                    "# user prefix without newline\n# user suffix\n"
2234                );
2235            }
2236            for file in &migrated.files {
2237                assert!(!file.path.exists());
2238            }
2239        }
2240    }
2241
2242    #[test]
2243    fn legacy_migration_refuses_modified_missing_and_unowned_content() {
2244        for case in [
2245            "edited script",
2246            "missing script",
2247            "edited block",
2248            "missing block",
2249            "occupied destination",
2250            "symlink destination",
2251        ] {
2252            let fixture = Fixture::new();
2253            let legacy = legacy_install(&fixture);
2254            let destination = fixture.paths.data.join("integration.sh");
2255            match case {
2256                "edited script" => fs::write(&legacy.files[1].path, "# user script\n").unwrap(),
2257                "missing script" => fs::remove_file(&legacy.files[1].path).unwrap(),
2258                "edited block" => {
2259                    fs::write(&legacy.blocks[1].path, "# user removed the block\n").unwrap()
2260                }
2261                "missing block" => fs::remove_file(&legacy.blocks[1].path).unwrap(),
2262                "occupied destination" => fs::write(&destination, &legacy.files[1].text).unwrap(),
2263                "symlink destination" => symlink(&legacy.files[1].path, &destination).unwrap(),
2264                _ => unreachable!(),
2265            }
2266            let paths: Vec<_> = legacy
2267                .files
2268                .iter()
2269                .chain(&legacy.blocks)
2270                .map(|file| file.path.clone())
2271                .chain([
2272                    fixture.paths.state.join("install.json"),
2273                    fixture.paths.data.join("current"),
2274                    fixture.paths.data.join("integration.tmux"),
2275                    destination,
2276                ])
2277                .collect();
2278            let before: Vec<_> = paths.iter().map(|path| snapshot(path).unwrap()).collect();
2279            assert!(
2280                install_at(
2281                    &fixture.paths,
2282                    InstallOptions {
2283                        yes: true,
2284                        ..InstallOptions::default()
2285                    },
2286                    &fixture.source
2287                )
2288                .is_err(),
2289                "{case}"
2290            );
2291            for (path, original) in paths.iter().zip(before) {
2292                assert_eq!(
2293                    snapshot(path).unwrap(),
2294                    original,
2295                    "{case}: {}",
2296                    path.display()
2297                );
2298            }
2299        }
2300    }
2301
2302    #[test]
2303    fn manifest_layout_version_and_unique_owned_files_are_enforced() {
2304        let fixture = Fixture::new();
2305        fixture.install().unwrap();
2306        let path = fixture.paths.state.join("install.json");
2307        let value = manifest(&fixture.paths).unwrap().1.unwrap();
2308        for case in ["version", "wrong root", "duplicate"] {
2309            let mut invalid = value.clone();
2310            match case {
2311                "version" => invalid.format = 3,
2312                "wrong root" => {
2313                    invalid.files[0].path = fixture.paths.config.join("integration.tmux")
2314                }
2315                "duplicate" => invalid.files[1] = invalid.files[0].clone(),
2316                _ => unreachable!(),
2317            }
2318            fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
2319            assert!(manifest(&fixture.paths).is_err(), "{case}");
2320        }
2321    }
2322
2323    #[test]
2324    fn payload_only_install_never_selects_default_user_configs() {
2325        for existing in [false, true] {
2326            let fixture = Fixture::new();
2327            let home = fixture.source.parent().unwrap();
2328            let defaults = [
2329                home.join(".tmux.conf"),
2330                home.join(".bashrc"),
2331                home.join(".zshrc"),
2332            ];
2333            if existing {
2334                for path in &defaults {
2335                    // Even an unowned/malformed marker is irrelevant without path consent.
2336                    fs::write(path, format!("# keep this file\n{BEGIN}\n")).unwrap();
2337                }
2338            }
2339            let originals: Vec<_> = defaults
2340                .iter()
2341                .map(|path| snapshot(path).unwrap())
2342                .collect();
2343            install_at(
2344                &fixture.paths,
2345                InstallOptions {
2346                    yes: true,
2347                    ..InstallOptions::default()
2348                },
2349                &fixture.source,
2350            )
2351            .unwrap();
2352            for (path, original) in defaults.iter().zip(&originals) {
2353                assert_eq!(snapshot(path).unwrap(), *original);
2354            }
2355            let (_, value) = manifest(&fixture.paths).unwrap();
2356            let value = value.unwrap();
2357            assert!(value.blocks.is_empty());
2358            assert_eq!(value.files.len(), 2);
2359            for file in &value.files {
2360                assert!(file.path.is_file());
2361            }
2362            assert_eq!(
2363                fs::read(fixture.paths.bin.join(BINARY)).unwrap(),
2364                fs::read(&fixture.source).unwrap()
2365            );
2366
2367            // The OS can report the canonical digest payload as current_exe on reinstall.
2368            let installed_source = fs::canonicalize(fixture.paths.bin.join(BINARY)).unwrap();
2369            install_at(
2370                &fixture.paths,
2371                InstallOptions {
2372                    yes: true,
2373                    ..InstallOptions::default()
2374                },
2375                &installed_source,
2376            )
2377            .unwrap();
2378            uninstall_at(&fixture.paths, true).unwrap();
2379            for (path, original) in defaults.iter().zip(&originals) {
2380                assert_eq!(snapshot(path).unwrap(), *original);
2381            }
2382        }
2383    }
2384
2385    #[test]
2386    fn integration_flags_select_independent_paths() {
2387        for selected in ["tmux", "bash", "zsh"] {
2388            let fixture = Fixture::new();
2389            let home = fixture.source.parent().unwrap();
2390            let defaults = [
2391                home.join(".tmux.conf"),
2392                home.join(".bashrc"),
2393                home.join(".zshrc"),
2394            ];
2395            for path in &defaults {
2396                fs::write(path, "# user content\n").unwrap();
2397            }
2398            let originals: Vec<_> = defaults
2399                .iter()
2400                .map(|path| snapshot(path).unwrap())
2401                .collect();
2402            let index = match selected {
2403                "tmux" => 0,
2404                "bash" => 1,
2405                _ => 2,
2406            };
2407            let options = InstallOptions {
2408                tmux_config: (index == 0).then(|| defaults[index].clone()),
2409                external_binary: None,
2410                shell_config: (index != 0).then(|| defaults[index].clone()),
2411                shell_kind: (index != 0).then(|| selected.into()),
2412                yes: true,
2413            };
2414            install_at(&fixture.paths, options.clone(), &fixture.source).unwrap();
2415            let (_, value) = manifest(&fixture.paths).unwrap();
2416            assert_eq!(value.as_ref().unwrap().blocks.len(), 1);
2417            assert_eq!(value.as_ref().unwrap().blocks[0].path, defaults[index]);
2418            for (i, (path, original)) in defaults.iter().zip(&originals).enumerate() {
2419                if i != index {
2420                    assert_eq!(snapshot(path).unwrap(), *original);
2421                }
2422            }
2423            let installed = snapshot(&defaults[index]).unwrap();
2424            install_at(&fixture.paths, options, &fixture.source).unwrap();
2425            install_at(
2426                &fixture.paths,
2427                InstallOptions {
2428                    yes: true,
2429                    ..InstallOptions::default()
2430                },
2431                &fixture.source,
2432            )
2433            .unwrap();
2434            assert_eq!(snapshot(&defaults[index]).unwrap(), installed);
2435            let (_, retained) = manifest(&fixture.paths).unwrap();
2436            assert_eq!(
2437                retained.as_ref().unwrap().blocks[0].text,
2438                value.as_ref().unwrap().blocks[0].text
2439            );
2440            assert_eq!(retained.unwrap().shell_kind, value.unwrap().shell_kind);
2441        }
2442    }
2443
2444    #[test]
2445    fn plain_reinstall_refreshes_owned_template_with_recorded_shell_kind() {
2446        for kind in ["bash", "zsh"] {
2447            let mut fixture = Fixture::new();
2448            fixture.options.shell_kind = Some(kind.into());
2449            fixture.install().unwrap();
2450            let manifest_path = fixture.paths.state.join("install.json");
2451            let mut value = manifest(&fixture.paths).unwrap().1.unwrap();
2452            let template = value
2453                .files
2454                .iter_mut()
2455                .find(|file| file.path.ends_with("integration.sh"))
2456                .unwrap();
2457            template.text = "# previous owned template\n".into();
2458            fs::write(&template.path, &template.text).unwrap();
2459            fs::write(&manifest_path, serde_json::to_vec(&value).unwrap()).unwrap();
2460            // An unselected, even user-edited rc must not be rewritten during refresh.
2461            let rc = fixture.options.shell_config.as_ref().unwrap();
2462            fs::write(rc, "# user edited the managed block too\n").unwrap();
2463            let before = snapshot(rc).unwrap();
2464            install_at(
2465                &fixture.paths,
2466                InstallOptions {
2467                    yes: true,
2468                    ..InstallOptions::default()
2469                },
2470                &fixture.source,
2471            )
2472            .unwrap();
2473            let refreshed = manifest(&fixture.paths).unwrap().1.unwrap();
2474            assert_eq!(refreshed.shell_kind, kind);
2475            let (_, expected, _) = integration(&fixture.paths, &fixture.options).unwrap();
2476            assert_eq!(refreshed.files[1].text, expected[1].text);
2477            assert_eq!(
2478                fs::read_to_string(&expected[1].path).unwrap(),
2479                expected[1].text
2480            );
2481            assert_eq!(snapshot(rc).unwrap(), before);
2482        }
2483    }
2484
2485    #[test]
2486    fn adding_an_integration_retains_unselected_edited_blocks() {
2487        for shell_first in [false, true] {
2488            let fixture = Fixture::new();
2489            let shell = InstallOptions {
2490                tmux_config: None,
2491                ..fixture.options.clone()
2492            };
2493            let tmux = InstallOptions {
2494                shell_config: None,
2495                shell_kind: None,
2496                ..fixture.options.clone()
2497            };
2498            let (first, second) = if shell_first {
2499                (shell, tmux)
2500            } else {
2501                (tmux, shell)
2502            };
2503            install_at(&fixture.paths, first.clone(), &fixture.source).unwrap();
2504            let first_path = first
2505                .shell_config
2506                .as_ref()
2507                .or(first.tmux_config.as_ref())
2508                .unwrap();
2509            let edited = fs::read_to_string(first_path)
2510                .unwrap()
2511                .replace(END, "# user changed the end marker");
2512            fs::write(first_path, &edited).unwrap();
2513            let before = snapshot(first_path).unwrap();
2514            install_at(&fixture.paths, second, &fixture.source).unwrap();
2515            install_at(
2516                &fixture.paths,
2517                InstallOptions {
2518                    yes: true,
2519                    ..InstallOptions::default()
2520                },
2521                &fixture.source,
2522            )
2523            .unwrap();
2524            assert_eq!(snapshot(first_path).unwrap(), before);
2525            assert_eq!(manifest(&fixture.paths).unwrap().1.unwrap().blocks.len(), 2);
2526            assert!(install_at(&fixture.paths, first, &fixture.source).is_err());
2527        }
2528    }
2529
2530    #[test]
2531    fn changing_a_recorded_integration_target_requires_uninstall() {
2532        let fixture = Fixture::new();
2533        fixture.install().unwrap();
2534        let different = fixture.source.parent().unwrap().join("different.tmux.conf");
2535        let before = snapshot(&fixture.paths.state.join("install.json")).unwrap();
2536        let options = InstallOptions {
2537            tmux_config: Some(different.clone()),
2538            shell_config: None,
2539            ..fixture.options.clone()
2540        };
2541        assert!(install_at(&fixture.paths, options, &fixture.source).is_err());
2542        assert!(!different.exists());
2543        assert_eq!(
2544            snapshot(&fixture.paths.state.join("install.json")).unwrap(),
2545            before
2546        );
2547    }
2548
2549    #[test]
2550    fn downloaded_binary_must_not_preoccupy_managed_bin_destination() {
2551        let fixture = Fixture::new();
2552        ensure_user_dir(&fixture.paths.bin).unwrap();
2553        let binary = fixture.paths.bin.join(BINARY);
2554        write_binary(&binary, 1);
2555        let before = snapshot(&binary).unwrap();
2556        assert!(install_at(
2557            &fixture.paths,
2558            InstallOptions {
2559                yes: true,
2560                ..InstallOptions::default()
2561            },
2562            &binary
2563        )
2564        .is_err());
2565        assert_eq!(snapshot(&binary).unwrap(), before);
2566        assert!(!fixture.paths.data.join("current").exists());
2567    }
2568
2569    #[test]
2570    fn uninstall_hook_requires_consent_and_runs_before_file_removal() {
2571        let fixture = Fixture::new();
2572        uninstall_at_with_hook(&fixture.paths, false, || panic!("preview called hook")).unwrap();
2573        assert!(!fixture.paths.state.exists());
2574        uninstall_at_with_hook(&fixture.paths, true, || {
2575            panic!("no installation called hook")
2576        })
2577        .unwrap();
2578        fixture.install().unwrap();
2579        let manifest_before = snapshot(&fixture.paths.state.join("install.json")).unwrap();
2580        uninstall_at_with_hook(&fixture.paths, false, || panic!("preview called hook")).unwrap();
2581        assert_eq!(
2582            snapshot(&fixture.paths.state.join("install.json")).unwrap(),
2583            manifest_before
2584        );
2585        let called = std::cell::Cell::new(false);
2586        uninstall_at_with_hook(&fixture.paths, true, || {
2587            called.set(true);
2588            assert!(fixture.paths.bin.join(BINARY).is_file());
2589            assert!(fixture.paths.data.join("integration.tmux").is_file());
2590            assert!(
2591                fs::read_to_string(fixture.options.tmux_config.as_ref().unwrap())
2592                    .unwrap()
2593                    .contains(BEGIN)
2594            );
2595            Ok(())
2596        })
2597        .unwrap();
2598        assert!(called.get());
2599        assert!(!fixture.paths.bin.join(BINARY).exists());
2600    }
2601
2602    #[test]
2603    fn failing_uninstall_hook_keeps_installed_files() {
2604        let fixture = Fixture::new();
2605        fixture.install().unwrap();
2606        let paths = [
2607            fixture.paths.bin.join(BINARY),
2608            fixture.paths.data.join("current"),
2609            fixture.paths.state.join("install.json"),
2610            fixture.paths.data.join("integration.sh"),
2611            fixture.options.tmux_config.clone().unwrap(),
2612            fixture.options.shell_config.clone().unwrap(),
2613        ];
2614        let originals: Vec<_> = paths.iter().map(|path| snapshot(path).unwrap()).collect();
2615        assert!(uninstall_at_with_hook(&fixture.paths, true, || bail!(
2616            "binding ownership could not be verified"
2617        ))
2618        .is_err());
2619        for (path, original) in paths.iter().zip(originals) {
2620            assert_eq!(snapshot(path).unwrap(), original);
2621        }
2622    }
2623
2624    #[test]
2625    fn install_idempotency_and_byte_preserving_uninstall() {
2626        let fixture = Fixture::new();
2627        let tmux = fixture.options.tmux_config.as_ref().unwrap();
2628        let shell = fixture.options.shell_config.as_ref().unwrap();
2629        let original = b"# user bytes\r\n\xff no final newline";
2630        fs::write(tmux, original).unwrap();
2631        fs::write(shell, b"# shell without final newline").unwrap();
2632        fixture.install().unwrap();
2633        let installed = snapshot(tmux).unwrap();
2634        let manifest_path = fixture.paths.state.join("install.json");
2635        let recorded = snapshot(&manifest_path).unwrap();
2636        fixture.install().unwrap();
2637        assert_eq!(snapshot(tmux).unwrap(), installed);
2638        assert_eq!(snapshot(&manifest_path).unwrap(), recorded);
2639        assert_eq!(fs::metadata(&manifest_path).unwrap().mode() & 0o777, 0o600);
2640        let mut extended = fs::read(tmux).unwrap();
2641        extended.extend_from_slice(b"# later user addition\n");
2642        fs::write(tmux, extended).unwrap();
2643        uninstall_at(&fixture.paths, false).unwrap();
2644        assert!(manifest_path.exists());
2645        uninstall_at(&fixture.paths, true).unwrap();
2646        assert_eq!(
2647            fs::read(tmux).unwrap(),
2648            [original.as_slice(), b"\n# later user addition\n"].concat()
2649        );
2650        assert_eq!(fs::read(shell).unwrap(), b"# shell without final newline");
2651        assert!(!manifest_path.exists());
2652        assert!(fs::symlink_metadata(fixture.paths.bin.join(BINARY)).is_err());
2653        assert!(fixture.paths.state.join("backups").is_dir());
2654        for backup in fs::read_dir(fixture.paths.state.join("backups")).unwrap() {
2655            assert_eq!(backup.unwrap().metadata().unwrap().mode() & 0o777, 0o600);
2656        }
2657        uninstall_at(&fixture.paths, true).unwrap();
2658    }
2659
2660    #[test]
2661    fn malformed_and_unowned_blocks_are_never_adopted() {
2662        for text in [
2663            format!("{BEGIN}\n"),
2664            format!("{END}\n{BEGIN}\n"),
2665            format!("{BEGIN}\n{END}\n"),
2666            "# BEGIN agent-float-term managed v9\n".into(),
2667        ] {
2668            let fixture = Fixture::new();
2669            let path = fixture.options.tmux_config.as_ref().unwrap();
2670            fs::write(path, &text).unwrap();
2671            assert!(fixture.install().is_err());
2672            assert_eq!(fs::read(path).unwrap(), text.as_bytes());
2673            assert!(!fixture.paths.data.join("current").exists());
2674        }
2675    }
2676
2677    #[test]
2678    fn edited_blocks_and_files_survive_uninstall() {
2679        let fixture = Fixture::new();
2680        fixture.install().unwrap();
2681        let tmux = fixture.options.tmux_config.as_ref().unwrap();
2682        let edited = fs::read_to_string(tmux)
2683            .unwrap()
2684            .replace("source-file", "# user edited source-file");
2685        fs::write(tmux, &edited).unwrap();
2686        let integration = fixture.paths.data.join("integration.sh");
2687        fs::write(&integration, "# user-owned now\n").unwrap();
2688        assert!(fixture.install().is_err());
2689        uninstall_at(&fixture.paths, true).unwrap();
2690        assert_eq!(fs::read_to_string(tmux).unwrap(), edited);
2691        assert_eq!(
2692            fs::read_to_string(integration).unwrap(),
2693            "# user-owned now\n"
2694        );
2695        let (_, manifest) = manifest(&fixture.paths).unwrap();
2696        let manifest = manifest.unwrap();
2697        assert_eq!(manifest.blocks.len(), 1);
2698        assert_eq!(manifest.files.len(), 1);
2699        assert!(manifest.current.is_none());
2700    }
2701
2702    #[test]
2703    fn symlinks_and_unowned_binary_are_refused() {
2704        let fixture = Fixture::new();
2705        let user_file = fixture.source.parent().unwrap().join("real-shell");
2706        fs::write(&user_file, "keep").unwrap();
2707        symlink(&user_file, fixture.options.shell_config.as_ref().unwrap()).unwrap();
2708        assert!(fixture.install().is_err());
2709        assert_eq!(fs::read_to_string(&user_file).unwrap(), "keep");
2710        fs::remove_file(fixture.options.shell_config.as_ref().unwrap()).unwrap();
2711        ensure_user_dir(&fixture.paths.bin).unwrap();
2712        fs::write(fixture.paths.bin.join(BINARY), "not ours").unwrap();
2713        assert!(fixture.install().is_err());
2714        assert_eq!(
2715            fs::read(fixture.paths.bin.join(BINARY)).unwrap(),
2716            b"not ours"
2717        );
2718    }
2719
2720    #[test]
2721    fn update_checksum_activation_rollback_and_modified_payload() {
2722        let fixture = Fixture::new();
2723        fixture.install().unwrap();
2724        let original = fs::read_link(fixture.paths.data.join("current")).unwrap();
2725        write_binary(&fixture.source, 2);
2726        let bytes = fs::read(&fixture.source).unwrap();
2727        let id = digest(&bytes);
2728        assert!(update_at(&fixture.paths, &fixture.source, &"0".repeat(64)).is_err());
2729        assert!(update_at(&fixture.paths, &fixture.source, "xyz").is_err());
2730        assert_eq!(
2731            fs::read_link(fixture.paths.data.join("current")).unwrap(),
2732            original
2733        );
2734        update_at(&fixture.paths, &fixture.source, &id.to_uppercase()).unwrap();
2735        assert_eq!(
2736            fs::read_link(fixture.paths.data.join("current")).unwrap(),
2737            release_target(&id)
2738        );
2739        assert_eq!(fs::read(fixture.paths.bin.join(BINARY)).unwrap(), bytes);
2740        let before = snapshot(&fixture.paths.state.join("install.json")).unwrap();
2741        update_at(&fixture.paths, &fixture.source, &id).unwrap();
2742        assert_eq!(
2743            snapshot(&fixture.paths.state.join("install.json")).unwrap(),
2744            before
2745        );
2746        rollback_at(&fixture.paths).unwrap();
2747        assert_eq!(
2748            fs::read_link(fixture.paths.data.join("current")).unwrap(),
2749            original
2750        );
2751        rollback_at(&fixture.paths).unwrap();
2752        assert_eq!(
2753            fs::read_link(fixture.paths.data.join("current")).unwrap(),
2754            release_target(&id)
2755        );
2756        let payload = release_binary(&fixture.paths, &id);
2757        fs::write(&payload, b"user modified").unwrap();
2758        uninstall_at(&fixture.paths, true).unwrap();
2759        assert_eq!(fs::read(&payload).unwrap(), b"user modified");
2760    }
2761
2762    #[test]
2763    fn transaction_detects_concurrent_edits_and_reverts_earlier_changes() {
2764        let fixture = Fixture::new();
2765        private_dir(&fixture.paths.state).unwrap();
2766        let first = fixture.source.parent().unwrap().join("first");
2767        let second = fixture.source.parent().unwrap().join("second");
2768        fs::write(&first, "first original").unwrap();
2769        fs::write(&second, "second original").unwrap();
2770        let before = snapshot(&second).unwrap();
2771        {
2772            let mut tx = Transaction::new(&fixture.paths.state).unwrap();
2773            tx.change(
2774                &first,
2775                &snapshot(&first).unwrap(),
2776                Content::File(b"replacement".to_vec(), 0o600),
2777            )
2778            .unwrap();
2779            fs::write(&second, "concurrent edit").unwrap();
2780            assert!(tx
2781                .change(
2782                    &second,
2783                    &before,
2784                    Content::File(b"bad overwrite".to_vec(), 0o600)
2785                )
2786                .is_err());
2787        }
2788        assert_eq!(fs::read(first).unwrap(), b"first original");
2789        assert_eq!(fs::read(second).unwrap(), b"concurrent edit");
2790    }
2791
2792    #[test]
2793    fn installer_lock_serializes_writers() {
2794        let fixture = Fixture::new();
2795        let held = lock(&fixture.paths.state).unwrap();
2796        assert!(lock(&fixture.paths.state).is_err());
2797        drop(held);
2798        lock(&fixture.paths.state).unwrap();
2799    }
2800
2801    #[test]
2802    fn interrupted_transaction_recovers_and_preserves_later_edits() {
2803        let fixture = Fixture::new();
2804        private_dir(&fixture.paths.state).unwrap();
2805        let original = snapshot(&fixture.source).unwrap();
2806        let mut tx = Transaction::new(&fixture.paths.state).unwrap();
2807        tx.change(
2808            &fixture.source,
2809            &original,
2810            Content::File(b"interrupted".to_vec(), 0o600),
2811        )
2812        .unwrap();
2813        std::mem::forget(tx); // Simulate process death, without running the rollback destructor.
2814        assert!(fixture.paths.state.join("transaction.json").is_file());
2815        drop(lock(&fixture.paths.state).unwrap());
2816        assert_eq!(snapshot(&fixture.source).unwrap().content, original.content);
2817        assert!(!fixture.paths.state.join("transaction.json").exists());
2818
2819        let mut tx = Transaction::new(&fixture.paths.state).unwrap();
2820        tx.change(
2821            &fixture.source,
2822            &snapshot(&fixture.source).unwrap(),
2823            Content::File(b"interrupted again".to_vec(), 0o600),
2824        )
2825        .unwrap();
2826        std::mem::forget(tx);
2827        fs::write(&fixture.source, b"later user edit").unwrap();
2828        assert!(lock(&fixture.paths.state).is_err());
2829        assert_eq!(fs::read(&fixture.source).unwrap(), b"later user edit");
2830        assert!(fixture.paths.state.join("transaction.json").exists());
2831    }
2832
2833    #[test]
2834    fn shell_quoting_and_noninteractive_guard() {
2835        use std::process::Command;
2836        let fixture = Fixture::new();
2837        let word = "space ' \" $ ` # \\ ; $(false)";
2838        let output = Command::new("/bin/sh")
2839            .arg("-c")
2840            .arg(format!("printf '%s' {}", shell_quote(word)))
2841            .output()
2842            .unwrap();
2843        assert!(output.status.success());
2844        assert_eq!(output.stdout, word.as_bytes());
2845        for program in ["/bin/bash", "/bin/zsh"] {
2846            if !Path::new(program).exists() {
2847                continue;
2848            }
2849            let (_, files, _) = integration(
2850                &fixture.paths,
2851                &InstallOptions {
2852                    shell_kind: Some(program.trim_start_matches("/bin/").into()),
2853                    ..fixture.options.clone()
2854                },
2855            )
2856            .unwrap();
2857            let shell = &files[1].text;
2858            assert!(!shell.contains("exec "));
2859            assert!(shell.contains("AFT_STARTING=1 AFT_QUIET=1"));
2860            let output = Command::new(program)
2861                .arg("-c")
2862                .arg(format!("{shell}\nprintf '%s' survived"))
2863                .env("HOME", fixture.source.parent().unwrap())
2864                .env("ZDOTDIR", fixture.source.parent().unwrap())
2865                .output()
2866                .unwrap();
2867            assert!(output.status.success(), "{:?}", output);
2868            assert_eq!(output.stdout, b"survived");
2869        }
2870    }
2871
2872    // Real stdin-driven interactive shells, not `-ic`, exercise actual prompt hooks.
2873    fn shell_session(
2874        program: &str,
2875        home: &Path,
2876        args: &[&str],
2877        envs: &[(&str, &str)],
2878        input: &str,
2879        tty: (bool, bool),
2880    ) -> String {
2881        use std::io::{Read, Write};
2882        use std::os::fd::FromRawFd;
2883        use std::os::unix::process::CommandExt;
2884        use std::process::{Command, Stdio};
2885        use std::time::{Duration, Instant};
2886        let (mut master, mut slave) = (-1, -1);
2887        // SAFETY: valid output pointers and null optional arguments request defaults.
2888        let result = unsafe {
2889            libc::openpty(
2890                &mut master,
2891                &mut slave,
2892                std::ptr::null_mut(),
2893                std::ptr::null_mut(),
2894                std::ptr::null_mut(),
2895            )
2896        };
2897        assert_eq!(result, 0);
2898        // SAFETY: openpty returned new owned descriptors.
2899        let (mut master_file, slave_file) =
2900            unsafe { (fs::File::from_raw_fd(master), fs::File::from_raw_fd(slave)) };
2901        for fd in [master, slave] {
2902            // SAFETY: openpty returned owned, live descriptors. dup2 onto stdio in
2903            // the child clears CLOEXEC; unrelated parallel fixtures must not inherit these.
2904            let result = unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
2905            assert_eq!(result, 0);
2906        }
2907        let mut command = Command::new(program);
2908        if program.ends_with("bash") {
2909            if !args.contains(&"-lic") {
2910                command.arg("--noprofile");
2911            }
2912            command.arg("--rcfile").arg(home.join(".bashrc"));
2913        } else {
2914            command.arg("-d");
2915        }
2916        command
2917            .args(args)
2918            .env_clear()
2919            .env("PATH", "/usr/bin:/bin")
2920            .env("TERM", "dumb")
2921            .env("HOME", home)
2922            .env("ZDOTDIR", home)
2923            .envs(envs.iter().copied())
2924            .stdin(if tty.0 {
2925                Stdio::from(slave_file.try_clone().unwrap())
2926            } else {
2927                Stdio::piped()
2928            })
2929            .stdout(if tty.1 {
2930                Stdio::from(slave_file.try_clone().unwrap())
2931            } else {
2932                Stdio::null()
2933            })
2934            .stderr(Stdio::from(slave_file.try_clone().unwrap()));
2935        // SAFETY: only async-signal-safe syscalls run between fork and exec. The slave
2936        // descriptor remains open until the child has acquired its controlling tty.
2937        unsafe {
2938            command.pre_exec(move || {
2939                if libc::setsid() == -1 || libc::ioctl(slave, libc::TIOCSCTTY as _, 0) == -1 {
2940                    return Err(std::io::Error::last_os_error());
2941                }
2942                Ok(())
2943            });
2944        }
2945        let mut child = command.spawn().unwrap();
2946        drop(slave_file);
2947        if tty.0 {
2948            master_file.write_all(input.as_bytes()).unwrap();
2949        } else {
2950            child
2951                .stdin
2952                .take()
2953                .unwrap()
2954                .write_all(input.as_bytes())
2955                .unwrap();
2956        }
2957        // SAFETY: master is a valid open descriptor; nonblocking reads allow a deadline.
2958        let result = unsafe { libc::fcntl(master, libc::F_SETFL, libc::O_NONBLOCK) };
2959        assert_ne!(result, -1);
2960        let deadline = Instant::now() + Duration::from_secs(5);
2961        let mut output = Vec::new();
2962        loop {
2963            let mut buffer = [0; 4096];
2964            while let Ok(count) = master_file.read(&mut buffer) {
2965                if count == 0 {
2966                    break;
2967                }
2968                output.extend_from_slice(&buffer[..count]);
2969            }
2970            if let Some(status) = child.try_wait().unwrap() {
2971                // Collect any final output written between the last read and exit.
2972                let _ = master_file.read_to_end(&mut output);
2973                assert!(
2974                    status.success(),
2975                    "{program} {args:?}: {status}: {}",
2976                    String::from_utf8_lossy(&output)
2977                );
2978                return String::from_utf8_lossy(&output).into_owned();
2979            }
2980            if Instant::now() >= deadline {
2981                child.kill().unwrap();
2982                child.wait().unwrap();
2983                panic!(
2984                    "{program} {args:?} hung: {}",
2985                    String::from_utf8_lossy(&output)
2986                );
2987            }
2988            std::thread::sleep(Duration::from_millis(10));
2989        }
2990    }
2991
2992    #[test]
2993    fn shell_tty_guards_failure_and_recursion() {
2994        let fixture = Fixture::new();
2995        ensure_user_dir(&fixture.paths.bin).unwrap();
2996        let home = fixture.source.parent().unwrap();
2997        let marker = home.join("started");
2998        let binary = fixture.paths.bin.join(BINARY);
2999        fs::write(
3000            &binary,
3001            format!(
3002                "#!/bin/sh\nprintf '%s:%s:%s:%s\\n' \"$1\" \"$AFT_STARTING\" \"$AFT_QUIET\" \"${{TMUX-}}\" >> {}\nexit 17\n",
3003                shell_quote(marker.to_str().unwrap())
3004            ),
3005        )
3006        .unwrap();
3007        fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap();
3008        for program in ["/bin/bash", "/bin/zsh"] {
3009            if !Path::new(program).exists() {
3010                continue;
3011            }
3012            let kind = program.trim_start_matches("/bin/");
3013            let (_, files, _) = integration(
3014                &fixture.paths,
3015                &InstallOptions {
3016                    shell_kind: Some(kind.into()),
3017                    ..fixture.options.clone()
3018                },
3019            )
3020            .unwrap();
3021            let rc = home.join(format!(".{kind}rc"));
3022            let prompt = if kind == "zsh" { "unsetopt zle\n" } else { "" };
3023            fs::write(
3024                &rc,
3025                format!("PS1='aft-test> '\n{prompt}{0}\n{0}\n", files[1].text),
3026            )
3027            .unwrap();
3028            for guard in [
3029                None,
3030                Some("TMUX"),
3031                Some("SSH_CONNECTION"),
3032                Some("SSH_CLIENT"),
3033                Some("SSH_TTY"),
3034                Some("AFT_DISABLE"),
3035                Some("AFT_STARTING"),
3036                Some("_AFT_AUTO_STARTED"),
3037            ] {
3038                let _ = fs::remove_file(&marker);
3039                let envs: Vec<_> = guard.map(|name| (name, "1")).into_iter().collect();
3040                let output = shell_session(
3041                    program,
3042                    home,
3043                    &["-i"],
3044                    &envs,
3045                    &format!(
3046                        ". {}\nprintf 'survived\\n'\nexit 0\n",
3047                        shell_quote(rc.to_str().unwrap())
3048                    ),
3049                    (true, true),
3050                );
3051                if guard.is_none() || guard == Some("TMUX") {
3052                    let tmux = if guard.is_some() { "1" } else { "" };
3053                    assert_eq!(
3054                        fs::read_to_string(&marker).unwrap(),
3055                        format!("start:1:1:{tmux}\n"),
3056                        "{program}"
3057                    );
3058                    assert_eq!(
3059                        output
3060                            .matches("startup failed; continuing this shell")
3061                            .count(),
3062                        1,
3063                        "{output}"
3064                    );
3065                } else {
3066                    assert!(!marker.exists(), "{program}: {guard:?}");
3067                }
3068                assert!(output.contains("survived"), "{output}");
3069            }
3070            for tty in [(false, true), (true, false), (false, false)] {
3071                let _ = fs::remove_file(&marker);
3072                shell_session(program, home, &["-i"], &[], ":\n:\nexit 0\n", tty);
3073                assert!(!marker.exists(), "{program}: {tty:?}");
3074            }
3075        }
3076    }
3077
3078    #[test]
3079    fn shell_command_strings_and_zsh_scripts_never_initialize() {
3080        for kind in ["bash", "zsh"] {
3081            let program = format!("/bin/{kind}");
3082            if !Path::new(&program).exists() {
3083                continue;
3084            }
3085            let fixture = Fixture::new();
3086            let home = fixture.source.parent().unwrap();
3087            ensure_user_dir(&fixture.paths.bin).unwrap();
3088            let marker = home.join("started");
3089            let sourced = home.join("sourced");
3090            let binary = fixture.paths.bin.join(BINARY);
3091            fs::write(
3092                &binary,
3093                format!(
3094                    "#!/bin/sh\nprintf started >> {}\n",
3095                    shell_quote(marker.to_str().unwrap())
3096                ),
3097            )
3098            .unwrap();
3099            fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap();
3100            let (_, files, _) = integration(
3101                &fixture.paths,
3102                &InstallOptions {
3103                    shell_kind: Some(kind.into()),
3104                    ..fixture.options.clone()
3105                },
3106            )
3107            .unwrap();
3108            let rc = home.join(format!(".{kind}rc"));
3109            fs::write(&rc, format!("{}\nprintf sourced >> {}\nif typeset -f __aft_initialize >/dev/null; then printf hook >> {}; fi\n",
3110                files[1].text, shell_quote(sourced.to_str().unwrap()), shell_quote(marker.to_str().unwrap()))).unwrap();
3111            let source = format!(". {}\n", shell_quote(rc.to_str().unwrap()));
3112            let background = format!("( {source} ) & wait");
3113            fs::write(home.join(".bash_profile"), &source).unwrap();
3114            let script = home.join("interactive-script");
3115            fs::write(&script, format!("{source}\nexit 0\n")).unwrap();
3116            let mut cases = vec![
3117                vec!["-ic", ":"],
3118                vec!["-lic", ":"],
3119                vec!["-ic", ""],
3120                vec!["-lic", ""],
3121                vec!["-c", &source],
3122                vec!["-c", &background],
3123            ];
3124            if kind == "zsh" {
3125                cases.push(vec!["-i", script.to_str().unwrap()]);
3126            }
3127            for args in cases {
3128                for tmux in ["", "fixture-server,1,0"] {
3129                    let _ = fs::remove_file(&sourced);
3130                    shell_session(&program, home, &args, &[("TMUX", tmux)], "", (true, true));
3131                    assert!(
3132                        sourced.exists(),
3133                        "{kind} {args:?} did not source the fixture rc"
3134                    );
3135                    assert!(
3136                        !marker.exists(),
3137                        "{kind} {args:?} installed a hook or started"
3138                    );
3139                }
3140            }
3141        }
3142    }
3143
3144    #[test]
3145    fn zsh_prompt_cleanup_order_bounded_retry_and_hook_removal() {
3146        if !Path::new("/bin/zsh").exists() {
3147            return;
3148        }
3149        for restore_at in [1, 2, 99] {
3150            for guard in [
3151                "",
3152                "AFT_DISABLE=1",
3153                "AFT_STARTING=1",
3154                "SSH_CONNECTION=remote",
3155                "BASH_EXECUTION_STRING=''",
3156                "ZSH_EXECUTION_STRING=''",
3157                "ZSH_SCRIPT=''",
3158            ] {
3159                let fixture = Fixture::new();
3160                let home = fixture.source.parent().unwrap();
3161                ensure_user_dir(&fixture.paths.bin).unwrap();
3162                let marker = home.join("started");
3163                let hooks = home.join("hooks");
3164                let binary = fixture.paths.bin.join(BINARY);
3165                fs::write(&binary, format!("#!/bin/sh\n[ -t 0 ] && [ -t 1 ] || exit 19\nprintf '%s:%s:%s:%s\\n' \"$1\" \"$AFT_STARTING\" \"$AFT_QUIET\" \"$TMUX\" >> {}\n", shell_quote(marker.to_str().unwrap()))).unwrap();
3166                fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap();
3167                let (_, files, blocks) = integration(
3168                    &fixture.paths,
3169                    &InstallOptions {
3170                        shell_kind: Some("zsh".into()),
3171                        shell_config: Some(home.join(".zshrc")),
3172                        ..fixture.options.clone()
3173                    },
3174                )
3175                .unwrap();
3176                private_dir(&fixture.paths.data).unwrap();
3177                fs::write(&files[1].path, &files[1].text).unwrap();
3178                let block = &blocks
3179                    .iter()
3180                    .find(|block| block.path.ends_with(".zshrc"))
3181                    .unwrap()
3182                    .text;
3183                // Model p10k's rc-time redirection and existing precmd cleanup without
3184                // changing the installer's bottom-of-rc source block or other hooks.
3185                fs::write(
3186                    home.join(".zshrc"),
3187                    format!(
3188                        r#"PS1='aft-test> '
3189unsetopt zle
3190exec 3<&0 4>&1 </dev/null > /dev/null
3191prompt_count=0
3192fixture_cleanup() {{
3193  (( ++prompt_count ))
3194  if (( prompt_count == {restore_at} )); then exec 0<&3 1>&4; fi
3195  {guard}
3196  return 0
3197}}
3198fixture_other() {{ print -r -- other >> {hooks}; }}
3199precmd_functions=(fixture_cleanup fixture_other)
3200{block}
3201{block}
3202# Keep stdin available for the next prompt even while stdout is still redirected.
3203exec 0<&3
3204"#,
3205                        hooks = shell_quote(hooks.to_str().unwrap())
3206                    ),
3207                )
3208                .unwrap();
3209                let inspection = format!(
3210                    r#":
3211:
3212print -r -- "${{precmd_functions[*]}}:${{+functions[__aft_initialize]}}:${{_AFT_INIT_RETRIED-unset}}" >> {}
3213exit 0
3214"#,
3215                    shell_quote(hooks.to_str().unwrap())
3216                );
3217                shell_session(
3218                    "/bin/zsh",
3219                    home,
3220                    &["-i"],
3221                    &[("TMUX", "fixture-server,1,0")],
3222                    &inspection,
3223                    (true, true),
3224                );
3225                if restore_at <= 2 && guard.is_empty() {
3226                    assert_eq!(
3227                        fs::read_to_string(&marker).unwrap(),
3228                        "start:1:1:fixture-server,1,0\n"
3229                    );
3230                } else {
3231                    assert!(!marker.exists());
3232                }
3233                let actual = fs::read_to_string(&hooks).unwrap();
3234                assert!(
3235                    actual.contains("fixture_cleanup fixture_other:0:unset\n"),
3236                    "{actual}"
3237                );
3238                assert_eq!(
3239                    actual.lines().filter(|line| *line == "other").count(),
3240                    4,
3241                    "{actual}"
3242                );
3243            }
3244        }
3245    }
3246
3247    #[test]
3248    fn tmux_format_quoting_uses_inherited_socket() {
3249        use std::process::Command;
3250        if Command::new("tmux").arg("-V").output().is_err() {
3251            eprintln!("tmux unavailable; skipping isolated integration check");
3252            return;
3253        }
3254        let fixture = Fixture::new();
3255        let socket_dir = tempfile::tempdir().unwrap();
3256        let socket = socket_dir.path().join("tmux.sock");
3257        struct Server(PathBuf);
3258        impl Drop for Server {
3259            fn drop(&mut self) {
3260                let _ = Command::new("tmux")
3261                    .arg("-S")
3262                    .arg(&self.0)
3263                    .arg("kill-server")
3264                    .env_remove("TMUX")
3265                    .output();
3266            }
3267        }
3268        let _server = Server(socket.clone());
3269        let output = Command::new("tmux")
3270            .arg("-S")
3271            .arg(&socket)
3272            .args([
3273                "-f",
3274                "/dev/null",
3275                "new-session",
3276                "-d",
3277                "-s",
3278                "aft-test",
3279                "/bin/sleep 60",
3280            ])
3281            .env_remove("TMUX")
3282            .env("HOME", fixture.source.parent().unwrap())
3283            .output()
3284            .unwrap();
3285        assert!(
3286            output.status.success(),
3287            "{}",
3288            String::from_utf8_lossy(&output.stderr)
3289        );
3290        ensure_user_dir(&fixture.paths.bin).unwrap();
3291        private_dir(&fixture.paths.data).unwrap();
3292        let marker = fixture.source.parent().unwrap().join("bound");
3293        let binary = fixture.paths.bin.join(BINARY);
3294        fs::write(
3295            &binary,
3296            format!(
3297                "#!/bin/sh\nprintf '%s\\n' \"$#\" \"$1\" \"$TMUX\" > {}\n",
3298                shell_quote(marker.to_str().unwrap())
3299            ),
3300        )
3301        .unwrap();
3302        fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap();
3303        let (_, files, blocks) = integration(&fixture.paths, &fixture.options).unwrap();
3304        fs::write(&files[0].path, &files[0].text).unwrap();
3305        fs::write(&blocks[0].path, &blocks[0].text).unwrap();
3306        let output = Command::new("tmux")
3307            .arg("-S")
3308            .arg(&socket)
3309            .arg("source-file")
3310            .arg(glob_quote(blocks[0].path.to_str().unwrap()))
3311            .env_remove("TMUX")
3312            .output()
3313            .unwrap();
3314        assert!(
3315            output.status.success(),
3316            "{}",
3317            String::from_utf8_lossy(&output.stderr)
3318        );
3319        let actual =
3320            fs::read_to_string(&marker).expect("quoted integration must invoke our fixture binary");
3321        assert!(actual.starts_with("1\nbind\n"), "{actual:?}");
3322        assert!(actual.contains(socket.to_str().unwrap()), "{actual:?}");
3323    }
3324}