1use std::collections::BTreeMap;
33use std::fs;
34use std::io::IsTerminal;
35use std::io::Read;
36use std::path::{Path, PathBuf};
37#[cfg(unix)]
38use std::process::{Command, Stdio};
39use std::time::Duration;
40
41use semver::Version;
42use sha2::{Digest, Sha256};
43
44use crate::error::{CtxError, Result};
45
46const DEFAULT_BASE_URL: &str = "https://api.github.com/repos/agentis-tools/ctx";
48
49pub const PASSIVE_CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
51
52const STAMP_FILE: &str = "last-update-check";
55
56const PASSIVE_TIMEOUT: Duration = Duration::from_secs(1);
58
59const EXPLICIT_TIMEOUT: Duration = Duration::from_secs(30);
61
62pub fn release_target() -> Option<&'static str> {
72 if cfg!(all(
73 target_os = "linux",
74 target_arch = "x86_64",
75 target_env = "gnu"
76 )) {
77 Some("x86_64-unknown-linux-gnu")
78 } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
79 Some("x86_64-apple-darwin")
80 } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
81 Some("aarch64-apple-darwin")
82 } else if cfg!(all(
83 target_os = "windows",
84 target_arch = "x86_64",
85 target_env = "msvc"
86 )) {
87 Some("x86_64-pc-windows-msvc")
88 } else {
89 None
90 }
91}
92
93pub fn artifact_name(tag: &str, target: &str) -> String {
98 let ext = if target.contains("windows") {
99 "zip"
100 } else {
101 "tar.gz"
102 };
103 format!("ctx-{tag}-{target}.{ext}")
104}
105
106pub fn current_version() -> Version {
108 Version::parse(env!("CARGO_PKG_VERSION")).expect("CARGO_PKG_VERSION is valid semver")
109}
110
111#[derive(Debug, Clone)]
117pub struct Asset {
118 pub name: String,
119 pub download_url: String,
120}
121
122#[derive(Debug, Clone)]
124pub struct Release {
125 pub tag: String,
126 pub assets: Vec<Asset>,
127}
128
129impl Release {
130 pub fn version(&self) -> Result<Version> {
132 Version::parse(self.tag.trim_start_matches('v'))
133 .map_err(|e| CtxError::Other(format!("release tag '{}' is not semver: {e}", self.tag)))
134 }
135
136 pub fn asset(&self, name: &str) -> Option<&Asset> {
138 self.assets.iter().find(|a| a.name == name)
139 }
140}
141
142fn base_url() -> String {
144 std::env::var("CTX_UPDATE_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
145}
146
147fn http_client(timeout: Duration) -> Result<reqwest::blocking::Client> {
148 reqwest::blocking::Client::builder()
149 .timeout(timeout)
150 .user_agent(concat!("ctx/", env!("CARGO_PKG_VERSION")))
151 .build()
152 .map_err(CtxError::Network)
153}
154
155pub fn latest_release(client: &reqwest::blocking::Client) -> Result<Release> {
157 fetch_release(client, &format!("{}/releases/latest", base_url()))
158}
159
160pub fn release_by_tag(client: &reqwest::blocking::Client, tag: &str) -> Result<Release> {
162 fetch_release(client, &format!("{}/releases/tags/{tag}", base_url()))
163}
164
165fn fetch_release(client: &reqwest::blocking::Client, url: &str) -> Result<Release> {
166 let response = client
167 .get(url)
168 .header("Accept", "application/vnd.github+json")
169 .send()?;
170 let status = response.status();
171 if !status.is_success() {
172 return Err(CtxError::Other(format!(
173 "release query failed: {url} returned HTTP {status}"
174 )));
175 }
176 let value: serde_json::Value = response.json()?;
177 parse_release(&value)
178}
179
180pub fn parse_release(value: &serde_json::Value) -> Result<Release> {
182 let tag = value["tag_name"]
183 .as_str()
184 .ok_or_else(|| CtxError::Other("release JSON has no tag_name".to_string()))?
185 .to_string();
186 let assets = value["assets"]
187 .as_array()
188 .map(|assets| {
189 assets
190 .iter()
191 .filter_map(|a| {
192 Some(Asset {
193 name: a["name"].as_str()?.to_string(),
194 download_url: a["browser_download_url"].as_str()?.to_string(),
195 })
196 })
197 .collect()
198 })
199 .unwrap_or_default();
200 Ok(Release { tag, assets })
201}
202
203pub fn parse_sha256sums(text: &str) -> BTreeMap<String, String> {
211 let mut sums = BTreeMap::new();
212 for line in text.lines() {
213 let line = line.trim();
214 if line.is_empty() {
215 continue;
216 }
217 let Some((hex, name)) = line.split_once(char::is_whitespace) else {
218 continue;
219 };
220 let name = name.trim_start().trim_start_matches('*');
221 if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) && !name.is_empty() {
222 sums.insert(name.to_string(), hex.to_ascii_lowercase());
223 }
224 }
225 sums
226}
227
228fn sha256_hex(data: &[u8]) -> String {
229 let digest = Sha256::digest(data);
230 digest.iter().map(|b| format!("{b:02x}")).collect()
231}
232
233fn extract_binary(archive: &[u8], artifact: &str) -> Result<Vec<u8>> {
243 if artifact.ends_with(".tar.gz") {
244 return extract_from_tar_gz(archive);
245 }
246 #[cfg(windows)]
247 if artifact.ends_with(".zip") {
248 return extract_from_zip(archive);
249 }
250 Err(CtxError::Other(format!(
251 "cannot extract '{artifact}': unsupported archive format for this platform"
252 )))
253}
254
255fn extract_from_tar_gz(archive: &[u8]) -> Result<Vec<u8>> {
256 let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(archive));
257 for entry in tar.entries()? {
258 let mut entry = entry?;
259 if !entry.header().entry_type().is_file() {
260 continue;
261 }
262 let is_ctx = entry.path()?.file_name().is_some_and(|name| name == "ctx");
263 if is_ctx {
264 let mut binary = Vec::new();
265 entry.read_to_end(&mut binary)?;
266 return Ok(binary);
267 }
268 }
269 Err(CtxError::Other(
270 "release archive contains no 'ctx' binary member".to_string(),
271 ))
272}
273
274#[cfg(windows)]
275fn extract_from_zip(archive: &[u8]) -> Result<Vec<u8>> {
276 let cursor = std::io::Cursor::new(archive);
277 let mut zip = zip::ZipArchive::new(cursor)
278 .map_err(|e| CtxError::Other(format!("invalid release zip: {e}")))?;
279 for i in 0..zip.len() {
280 let mut entry = zip
281 .by_index(i)
282 .map_err(|e| CtxError::Other(format!("invalid release zip entry: {e}")))?;
283 if entry.is_dir() {
284 continue;
285 }
286 let is_ctx = entry
287 .enclosed_name()
288 .and_then(|p| p.file_name().map(|n| n.to_os_string()))
289 .is_some_and(|name| name == "ctx.exe");
290 if is_ctx {
291 let mut binary = Vec::new();
292 entry.read_to_end(&mut binary)?;
293 return Ok(binary);
294 }
295 }
296 Err(CtxError::Other(
297 "release archive contains no 'ctx.exe' binary member".to_string(),
298 ))
299}
300
301fn ensure_writable(dir: &Path) -> Result<()> {
308 let probe = dir.join(format!(".ctx-write-probe-{}", std::process::id()));
309 let outcome = fs::write(&probe, b"").and_then(|()| fs::remove_file(&probe));
310 outcome.map_err(|e| {
311 CtxError::Other(format!(
312 "cannot update: install location '{}' is not writable ({e}); \
313 re-run with elevated permissions or update through the package \
314 manager that installed ctx (e.g. 'cargo install agentis-ctx')",
315 dir.display()
316 ))
317 })
318}
319
320fn replace_executable(exe: &Path, data: &[u8]) -> Result<()> {
328 let dir = exe
329 .parent()
330 .ok_or_else(|| CtxError::Other("executable has no parent directory".to_string()))?;
331 let staged = dir.join(format!(".ctx-update-{}", std::process::id()));
332 if let Err(e) = fs::write(&staged, data) {
333 let _ = fs::remove_file(&staged);
334 return Err(e.into());
335 }
336
337 #[cfg(unix)]
338 {
339 use std::os::unix::fs::PermissionsExt;
340 if let Err(e) = fs::set_permissions(&staged, fs::Permissions::from_mode(0o755)) {
341 let _ = fs::remove_file(&staged);
342 return Err(e.into());
343 }
344 if let Err(e) = fs::rename(&staged, exe) {
345 let _ = fs::remove_file(&staged);
346 return Err(e.into());
347 }
348 }
349
350 #[cfg(windows)]
351 {
352 let old = old_binary_path(exe);
353 let _ = fs::remove_file(&old);
354 if let Err(e) = fs::rename(exe, &old) {
355 let _ = fs::remove_file(&staged);
356 return Err(e.into());
357 }
358 if let Err(e) = fs::rename(&staged, exe) {
359 let _ = fs::rename(&old, exe);
361 let _ = fs::remove_file(&staged);
362 return Err(e.into());
363 }
364 }
365
366 Ok(())
367}
368
369#[cfg(windows)]
370fn old_binary_path(exe: &Path) -> PathBuf {
371 let mut name = exe
372 .file_name()
373 .map(|n| n.to_os_string())
374 .unwrap_or_default();
375 name.push(".old");
376 exe.with_file_name(name)
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384enum ManagedInstall {
385 Cargo,
386 Homebrew,
387 Arch,
388 Debian,
389 Rpm,
390 Scoop,
391}
392
393impl ManagedInstall {
394 fn update_guidance(self) -> &'static str {
395 match self {
396 Self::Cargo => "ctx was installed with Cargo. Update it with:\n cargo install agentis-ctx",
397 Self::Homebrew => "ctx was installed with Homebrew. Update it with:\n brew upgrade ctx",
398 Self::Arch => "ctx is owned by an Arch package. Update it with your AUR helper, for example:\n yay -Syu ctx-bin",
399 Self::Debian => "ctx is owned by a Debian package. Update it by installing a newer .deb package",
400 Self::Rpm => "ctx is owned by an RPM package. Update it by installing a newer .rpm package",
401 Self::Scoop => "ctx was installed with Scoop. Update it with:\n scoop update ctx",
402 }
403 }
404}
405
406fn path_managed_install(exe: &Path) -> Option<ManagedInstall> {
407 let normalized = exe
408 .to_string_lossy()
409 .replace('\\', "/")
410 .to_ascii_lowercase();
411 let components: Vec<_> = normalized
412 .split('/')
413 .filter(|part| !part.is_empty())
414 .collect();
415
416 if components.windows(2).any(|pair| pair == [".cargo", "bin"]) {
417 return Some(ManagedInstall::Cargo);
418 }
419 if components.contains(&"cellar") {
420 return Some(ManagedInstall::Homebrew);
421 }
422 if components
423 .windows(3)
424 .any(|parts| parts == ["scoop", "apps", "ctx"])
425 {
426 return Some(ManagedInstall::Scoop);
427 }
428 None
429}
430
431#[cfg(unix)]
432fn command_owns_path(program: &str, args: &[&str], exe: &Path) -> bool {
433 Command::new(program)
434 .args(args)
435 .arg(exe)
436 .stdin(Stdio::null())
437 .stdout(Stdio::null())
438 .stderr(Stdio::null())
439 .status()
440 .is_ok_and(|status| status.success())
441}
442
443fn managed_install(exe: &Path) -> Option<ManagedInstall> {
448 let canonical = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
449 if let Some(manager) = path_managed_install(&canonical) {
450 return Some(manager);
451 }
452
453 #[cfg(unix)]
454 {
455 if command_owns_path("pacman", &["-Qo", "--"], &canonical) {
456 return Some(ManagedInstall::Arch);
457 }
458 if command_owns_path("dpkg-query", &["-S", "--"], &canonical) {
459 return Some(ManagedInstall::Debian);
460 }
461 if command_owns_path("rpm", &["-qf", "--"], &canonical) {
462 return Some(ManagedInstall::Rpm);
463 }
464 }
465 None
466}
467
468#[derive(Debug, Clone)]
470pub struct SelfUpdateReport {
471 pub old_version: Version,
472 pub new_version: Version,
473 pub updated: bool,
475}
476
477pub fn self_update(pin: Option<&str>) -> Result<SelfUpdateReport> {
486 let exe = std::env::current_exe()?;
487 let dir = exe
488 .parent()
489 .ok_or_else(|| CtxError::Other("cannot locate the running executable".to_string()))?;
490
491 if let Some(manager) = managed_install(&exe) {
494 return Err(CtxError::Other(manager.update_guidance().to_string()));
495 }
496
497 #[cfg(windows)]
499 {
500 let _ = fs::remove_file(old_binary_path(&exe));
501 }
502
503 ensure_writable(dir)?;
505
506 let client = http_client(EXPLICIT_TIMEOUT)?;
507 let release = match pin {
508 Some(version) => {
509 let tag = format!("v{}", version.trim_start_matches('v'));
510 release_by_tag(&client, &tag)?
511 }
512 None => latest_release(&client)?,
513 };
514 let new_version = release.version()?;
515 let old_version = current_version();
516
517 let update_needed = match pin {
520 Some(_) => new_version != old_version,
521 None => new_version > old_version,
522 };
523 if !update_needed {
524 return Ok(SelfUpdateReport {
525 old_version,
526 new_version,
527 updated: false,
528 });
529 }
530
531 let target = release_target().ok_or_else(|| {
532 CtxError::Other(format!(
533 "no prebuilt release artifact for this platform ({}-{}); \
534 update with 'cargo install agentis-ctx' instead",
535 std::env::consts::ARCH,
536 std::env::consts::OS
537 ))
538 })?;
539 let artifact = artifact_name(&release.tag, target);
540 let asset = release.asset(&artifact).ok_or_else(|| {
541 CtxError::Other(format!(
542 "release {} has no artifact named '{artifact}'",
543 release.tag
544 ))
545 })?;
546 let sums_asset = release.asset("SHA256SUMS").ok_or_else(|| {
547 CtxError::Other(format!(
548 "release {} publishes no SHA256SUMS file; refusing to install an unverifiable binary",
549 release.tag
550 ))
551 })?;
552
553 let sums_text = download_text(&client, &sums_asset.download_url)?;
554 let sums = parse_sha256sums(&sums_text);
555 let expected = sums.get(&artifact).ok_or_else(|| {
556 CtxError::Other(format!(
557 "SHA256SUMS for release {} has no entry for '{artifact}'",
558 release.tag
559 ))
560 })?;
561
562 let archive = download_bytes(&client, &asset.download_url)?;
563 let actual = sha256_hex(&archive);
564 if actual != *expected {
565 return Err(CtxError::Other(format!(
566 "checksum mismatch for '{artifact}': expected {expected}, got {actual}; \
567 aborting update (the installed binary is unchanged)"
568 )));
569 }
570
571 let binary = extract_binary(&archive, &artifact)?;
572 replace_executable(&exe, &binary)?;
573
574 Ok(SelfUpdateReport {
575 old_version,
576 new_version,
577 updated: true,
578 })
579}
580
581fn download_text(client: &reqwest::blocking::Client, url: &str) -> Result<String> {
582 let response = client.get(url).send()?;
583 let status = response.status();
584 if !status.is_success() {
585 return Err(CtxError::Other(format!(
586 "download failed: {url} returned HTTP {status}"
587 )));
588 }
589 Ok(response.text()?)
590}
591
592fn download_bytes(client: &reqwest::blocking::Client, url: &str) -> Result<Vec<u8>> {
593 let response = client.get(url).send()?;
594 let status = response.status();
595 if !status.is_success() {
596 return Err(CtxError::Other(format!(
597 "download failed: {url} returned HTTP {status}"
598 )));
599 }
600 Ok(response.bytes()?.to_vec())
601}
602
603pub fn update_notice(latest: &Version, current: &Version) -> String {
610 format!("ctx {latest} available (you have {current}) — run 'ctx self-update'")
611}
612
613pub fn explicit_check() -> Result<(Version, Version)> {
618 let client = http_client(EXPLICIT_TIMEOUT)?;
619 let latest = latest_release(&client)?.version()?;
620 Ok((current_version(), latest))
621}
622
623pub fn cache_dir() -> Option<PathBuf> {
630 if let Ok(dir) = std::env::var("CTX_CACHE_DIR") {
631 if !dir.is_empty() {
632 return Some(PathBuf::from(dir));
633 }
634 }
635 dirs::cache_dir().map(|d| d.join("ctx"))
636}
637
638pub fn check_is_due(stamp_file: &Path, now: u64) -> bool {
642 let last = fs::read_to_string(stamp_file)
643 .ok()
644 .and_then(|s| s.trim().parse::<u64>().ok());
645 match last {
646 Some(last) => now >= last.saturating_add(PASSIVE_CHECK_INTERVAL_SECS),
647 None => true,
648 }
649}
650
651pub fn record_check(stamp_file: &Path, now: u64) -> std::io::Result<()> {
653 if let Some(parent) = stamp_file.parent() {
654 fs::create_dir_all(parent)?;
655 }
656 fs::write(stamp_file, format!("{now}\n"))
657}
658
659pub fn passive_check_allowed<F>(json: bool, stderr_is_tty: bool, env: F) -> bool
671where
672 F: Fn(&str) -> Option<String>,
673{
674 if json || !stderr_is_tty {
675 return false;
676 }
677 if env("CTX_NO_UPDATE_CHECK").is_some_and(|v| !v.is_empty()) {
678 return false;
679 }
680 for var in ["CLAUDECODE", "CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_ROOT"] {
682 if env(var).is_some() {
683 return false;
684 }
685 }
686 true
687}
688
689pub fn passive_check(json: bool) {
700 let stderr_is_tty = std::env::var("CTX_UPDATE_FORCE_TTY").map(|v| v == "1") == Ok(true)
703 || std::io::stderr().is_terminal();
704 if !passive_check_allowed(json, stderr_is_tty, |k| std::env::var(k).ok()) {
705 return;
706 }
707 let _ = passive_check_inner();
710}
711
712fn passive_check_inner() -> Result<()> {
713 let dir = cache_dir().ok_or_else(|| CtxError::Other("no cache dir".to_string()))?;
714 let stamp = dir.join(STAMP_FILE);
715 let now = std::time::SystemTime::now()
716 .duration_since(std::time::UNIX_EPOCH)
717 .map(|d| d.as_secs())
718 .unwrap_or(0);
719 if !check_is_due(&stamp, now) {
720 return Ok(());
721 }
722 record_check(&stamp, now)?;
725
726 let client = http_client(PASSIVE_TIMEOUT)?;
727 let latest = latest_release(&client)?.version()?;
728 let current = current_version();
729 if latest > current {
730 eprintln!("{}", update_notice(&latest, ¤t));
731 }
732 Ok(())
733}
734
735#[cfg(test)]
740mod tests {
741 use super::*;
742
743 #[test]
744 fn test_managed_install_path_detection() {
745 assert_eq!(
746 path_managed_install(Path::new("/Users/alice/.cargo/bin/ctx")),
747 Some(ManagedInstall::Cargo)
748 );
749 assert_eq!(
750 path_managed_install(Path::new("/opt/homebrew/Cellar/ctx/0.3.4/bin/ctx")),
751 Some(ManagedInstall::Homebrew)
752 );
753 assert_eq!(
754 path_managed_install(Path::new(r"C:\Users\Alice\scoop\apps\ctx\current\ctx.exe")),
755 Some(ManagedInstall::Scoop)
756 );
757 assert_eq!(path_managed_install(Path::new("/usr/local/bin/ctx")), None);
758 }
759
760 #[test]
761 fn test_managed_install_guidance_names_update_command() {
762 assert!(ManagedInstall::Cargo
763 .update_guidance()
764 .contains("cargo install agentis-ctx"));
765 assert!(ManagedInstall::Homebrew
766 .update_guidance()
767 .contains("brew upgrade ctx"));
768 assert!(ManagedInstall::Arch
769 .update_guidance()
770 .contains("yay -Syu ctx-bin"));
771 assert!(ManagedInstall::Scoop
772 .update_guidance()
773 .contains("scoop update ctx"));
774 }
775
776 #[test]
779 fn test_artifact_names_mirror_release_workflow() {
780 let expected = [
782 (
783 "x86_64-unknown-linux-gnu",
784 "ctx-v0.3.0-x86_64-unknown-linux-gnu.tar.gz",
785 ),
786 (
787 "x86_64-apple-darwin",
788 "ctx-v0.3.0-x86_64-apple-darwin.tar.gz",
789 ),
790 (
791 "aarch64-apple-darwin",
792 "ctx-v0.3.0-aarch64-apple-darwin.tar.gz",
793 ),
794 (
795 "x86_64-pc-windows-msvc",
796 "ctx-v0.3.0-x86_64-pc-windows-msvc.zip",
797 ),
798 ];
799 for (target, name) in expected {
800 assert_eq!(artifact_name("v0.3.0", target), name);
801 }
802 }
803
804 #[test]
805 fn test_release_target_matches_compile_target() {
806 let expected = if cfg!(all(
807 target_os = "linux",
808 target_arch = "x86_64",
809 target_env = "gnu"
810 )) {
811 Some("x86_64-unknown-linux-gnu")
812 } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
813 Some("x86_64-apple-darwin")
814 } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
815 Some("aarch64-apple-darwin")
816 } else if cfg!(all(
817 target_os = "windows",
818 target_arch = "x86_64",
819 target_env = "msvc"
820 )) {
821 Some("x86_64-pc-windows-msvc")
822 } else {
823 None
824 };
825 assert_eq!(release_target(), expected);
826 if let Some(target) = release_target() {
828 let name = artifact_name("v1.0.0", target);
829 assert!(name.starts_with("ctx-v1.0.0-"));
830 assert!(name.ends_with(".tar.gz") || name.ends_with(".zip"));
831 }
832 }
833
834 #[test]
837 fn test_parse_sha256sums_multi_line() {
838 let hex_a = "a".repeat(64);
839 let hex_b = "B".repeat(64);
840 let text = format!(
841 "{hex_a} ctx-v0.3.0-x86_64-unknown-linux-gnu.tar.gz\n\
842 \n\
843 {hex_b} *ctx-v0.3.0-x86_64-pc-windows-msvc.zip\n\
844 not-a-sum-line\n\
845 deadbeef too-short-digest.tar.gz\n"
846 );
847 let sums = parse_sha256sums(&text);
848 assert_eq!(sums.len(), 2);
849 assert_eq!(
850 sums["ctx-v0.3.0-x86_64-unknown-linux-gnu.tar.gz"], hex_a,
851 "plain entry parses"
852 );
853 assert_eq!(
855 sums["ctx-v0.3.0-x86_64-pc-windows-msvc.zip"],
856 hex_b.to_ascii_lowercase()
857 );
858 }
859
860 #[test]
861 fn test_sha256_hex_matches_known_vector() {
862 assert_eq!(
864 sha256_hex(b"abc"),
865 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
866 );
867 }
868
869 #[test]
872 fn test_parse_release_and_version_comparison() {
873 let value = serde_json::json!({
874 "tag_name": "v0.3.0",
875 "assets": [
876 {"name": "SHA256SUMS", "browser_download_url": "https://x/SHA256SUMS"},
877 {"name": "ctx-v0.3.0-aarch64-apple-darwin.tar.gz",
878 "browser_download_url": "https://x/ctx.tar.gz"},
879 {"malformed": true}
880 ]
881 });
882 let release = parse_release(&value).unwrap();
883 assert_eq!(release.tag, "v0.3.0");
884 assert_eq!(release.assets.len(), 2, "malformed asset entries skipped");
885 assert_eq!(release.version().unwrap(), Version::new(0, 3, 0));
886 assert!(release.asset("SHA256SUMS").is_some());
887 assert!(release.asset("nope.tar.gz").is_none());
888
889 assert!(Version::new(1, 0, 0) > Version::new(0, 9, 9));
891 assert!(Version::parse("0.3.0-rc.1").unwrap() < Version::new(0, 3, 0));
892 }
893
894 #[test]
897 fn test_check_is_due_with_injected_clock() {
898 let temp = tempfile::tempdir().unwrap();
899 let stamp = temp.path().join("nested").join("last-update-check");
900
901 assert!(check_is_due(&stamp, 1_000_000));
903
904 record_check(&stamp, 1_000_000).unwrap();
905 assert!(!check_is_due(&stamp, 1_000_000));
907 assert!(!check_is_due(
908 &stamp,
909 1_000_000 + PASSIVE_CHECK_INTERVAL_SECS - 1
910 ));
911 assert!(check_is_due(
913 &stamp,
914 1_000_000 + PASSIVE_CHECK_INTERVAL_SECS
915 ));
916
917 fs::write(&stamp, "not-a-number").unwrap();
919 assert!(check_is_due(&stamp, 1_000_000));
920 }
921
922 #[test]
925 fn test_passive_check_suppression_matrix() {
926 let no_env = |_: &str| -> Option<String> { None };
927 let env_with = |key: &'static str| move |k: &str| (k == key).then(|| "1".to_string());
928
929 assert!(passive_check_allowed(false, true, no_env));
931
932 assert!(!passive_check_allowed(true, true, no_env));
934 assert!(!passive_check_allowed(false, false, no_env));
936 assert!(!passive_check_allowed(
938 false,
939 true,
940 env_with("CTX_NO_UPDATE_CHECK")
941 ));
942 assert!(passive_check_allowed(false, true, |k: &str| (k
944 == "CTX_NO_UPDATE_CHECK")
945 .then(String::new)));
946 for var in ["CLAUDECODE", "CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_ROOT"] {
948 assert!(
949 !passive_check_allowed(false, true, env_with(var)),
950 "{var} must suppress the passive check"
951 );
952 }
953 assert!(!passive_check_allowed(true, false, env_with("CLAUDECODE")));
955 }
956
957 #[test]
960 fn test_update_notice_format() {
961 let notice = update_notice(&Version::new(9, 9, 9), &Version::new(0, 2, 1));
962 assert_eq!(
963 notice,
964 "ctx 9.9.9 available (you have 0.2.1) — run 'ctx self-update'"
965 );
966 }
967
968 #[test]
971 fn test_extract_binary_from_tar_gz() {
972 let payload = b"#!/bin/sh\necho fake ctx\n".to_vec();
973 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
974 let mut builder = tar::Builder::new(gz);
975 let mut header = tar::Header::new_gnu();
976 header.set_size(payload.len() as u64);
977 header.set_mode(0o755);
978 header.set_cksum();
979 builder
980 .append_data(
981 &mut header,
982 "ctx-v9.9.9-aarch64-apple-darwin/ctx",
983 payload.as_slice(),
984 )
985 .unwrap();
986 let archive = builder.into_inner().unwrap().finish().unwrap();
987
988 let extracted = extract_binary(&archive, "ctx-v9.9.9-aarch64-apple-darwin.tar.gz").unwrap();
989 assert_eq!(extracted, payload);
990 }
991
992 #[test]
993 fn test_extract_binary_missing_member_errors() {
994 let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
995 let mut builder = tar::Builder::new(gz);
996 let mut header = tar::Header::new_gnu();
997 header.set_size(5);
998 header.set_mode(0o644);
999 header.set_cksum();
1000 builder
1001 .append_data(&mut header, "dir/README.md", &b"hello"[..])
1002 .unwrap();
1003 let archive = builder.into_inner().unwrap().finish().unwrap();
1004
1005 let err = extract_binary(&archive, "ctx-v9.9.9-x.tar.gz").unwrap_err();
1006 assert!(err.to_string().contains("no 'ctx' binary member"));
1007 }
1008
1009 #[cfg(unix)]
1012 #[test]
1013 fn test_replace_executable_atomically_swaps_content() {
1014 use std::os::unix::fs::PermissionsExt;
1015
1016 let temp = tempfile::tempdir().unwrap();
1017 let exe = temp.path().join("ctx");
1018 fs::write(&exe, b"old binary").unwrap();
1019
1020 replace_executable(&exe, b"new binary").unwrap();
1021 assert_eq!(fs::read(&exe).unwrap(), b"new binary");
1022 let mode = fs::metadata(&exe).unwrap().permissions().mode();
1023 assert_eq!(mode & 0o755, 0o755, "replacement is executable");
1024
1025 let leftovers: Vec<_> = fs::read_dir(temp.path())
1027 .unwrap()
1028 .filter_map(|e| e.ok())
1029 .filter(|e| e.file_name() != "ctx")
1030 .collect();
1031 assert!(leftovers.is_empty(), "leftovers: {leftovers:?}");
1032 }
1033
1034 #[test]
1035 fn test_ensure_writable_accepts_tempdir() {
1036 let temp = tempfile::tempdir().unwrap();
1037 ensure_writable(temp.path()).unwrap();
1038 }
1039
1040 #[cfg(unix)]
1041 #[test]
1042 fn test_ensure_writable_rejects_readonly_dir() {
1043 use std::os::unix::fs::PermissionsExt;
1044
1045 let temp = tempfile::tempdir().unwrap();
1046 let dir = temp.path().join("ro");
1047 fs::create_dir(&dir).unwrap();
1048 fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
1049
1050 let err = ensure_writable(&dir).unwrap_err();
1051 assert!(err.to_string().contains("not writable"), "{err}");
1052
1053 fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
1055 }
1056}