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