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