Skip to main content

agent_float_term/
install.rs

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