Skip to main content

ctx/
update.rs

1//! Release and update mechanism: `ctx self-update`, `ctx --version --check`,
2//! and the passive update notice.
3//!
4//! # Determinism rule
5//!
6//! ctx **never updates itself automatically**. The passive check only prints
7//! a one-line notice to stderr; replacing the binary always requires an
8//! explicit `ctx self-update` invocation.
9//!
10//! # How updates work
11//!
12//! Releases are published by `.github/workflows/release.yml`: one archive per
13//! platform named `ctx-<tag>-<target>.tar.gz` (`.zip` on Windows), plus an
14//! aggregated `SHA256SUMS` file. `self_update` queries the GitHub Releases
15//! API, downloads the artifact for the compile-time target, verifies its
16//! sha256 against `SHA256SUMS`, extracts the `ctx` binary member, and
17//! atomically renames it over the running executable. On checksum mismatch
18//! the update aborts with an error (exit code 2) and the installed binary is
19//! left untouched.
20//!
21//! # Test hooks (undocumented environment variables)
22//!
23//! - `CTX_UPDATE_BASE_URL`: overrides the GitHub API base URL so integration
24//!   tests can point at a local mock server (asset download URLs come from
25//!   the release JSON itself, so the mock controls those too).
26//! - `CTX_CACHE_DIR`: overrides the passive-check cache directory
27//!   (default: `dirs::cache_dir()/ctx`).
28//! - `CTX_UPDATE_FORCE_TTY=1`: makes the passive check treat stderr as a
29//!   terminal, so the end-to-end notice path can be exercised from a test
30//!   where stderr is a pipe.
31
32use std::collections::BTreeMap;
33use std::fs;
34use std::io::IsTerminal;
35use std::io::Read;
36use std::path::{Path, PathBuf};
37use std::time::Duration;
38
39use semver::Version;
40use sha2::{Digest, Sha256};
41
42use crate::error::{CtxError, Result};
43
44/// GitHub API base for this repository's releases.
45const DEFAULT_BASE_URL: &str = "https://api.github.com/repos/agentis-tools/ctx";
46
47/// How often the passive check may touch the network (24 hours).
48pub const PASSIVE_CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
49
50/// Cache file (under the cache dir) storing the unix timestamp of the last
51/// passive check attempt.
52const STAMP_FILE: &str = "last-update-check";
53
54/// Network budget for the passive (notice-only) check.
55const PASSIVE_TIMEOUT: Duration = Duration::from_secs(1);
56
57/// Network budget for explicit operations (`self-update`, `--version --check`).
58const EXPLICIT_TIMEOUT: Duration = Duration::from_secs(30);
59
60// ============================================================================
61// Platform / artifact mapping
62// ============================================================================
63
64/// The release target triple this binary was compiled for, or `None` when
65/// the release workflow publishes no artifact for this platform.
66///
67/// This table mirrors the build matrix in `.github/workflows/release.yml`
68/// exactly; keep the two in sync.
69pub fn release_target() -> Option<&'static str> {
70    if cfg!(all(
71        target_os = "linux",
72        target_arch = "x86_64",
73        target_env = "gnu"
74    )) {
75        Some("x86_64-unknown-linux-gnu")
76    } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
77        Some("x86_64-apple-darwin")
78    } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
79        Some("aarch64-apple-darwin")
80    } else if cfg!(all(
81        target_os = "windows",
82        target_arch = "x86_64",
83        target_env = "msvc"
84    )) {
85        Some("x86_64-pc-windows-msvc")
86    } else {
87        None
88    }
89}
90
91/// Release artifact file name for a tag and target, mirroring the packaging
92/// steps in `.github/workflows/release.yml` (`ctx-<tag>-<target>.tar.gz`,
93/// `.zip` for Windows). `tag` includes the leading `v` (it is
94/// `$GITHUB_REF_NAME` in the workflow).
95pub fn artifact_name(tag: &str, target: &str) -> String {
96    let ext = if target.contains("windows") {
97        "zip"
98    } else {
99        "tar.gz"
100    };
101    format!("ctx-{tag}-{target}.{ext}")
102}
103
104/// The version this binary was built as.
105pub fn current_version() -> Version {
106    Version::parse(env!("CARGO_PKG_VERSION")).expect("CARGO_PKG_VERSION is valid semver")
107}
108
109// ============================================================================
110// GitHub Releases API client
111// ============================================================================
112
113/// One downloadable asset attached to a release.
114#[derive(Debug, Clone)]
115pub struct Asset {
116    pub name: String,
117    pub download_url: String,
118}
119
120/// A GitHub release: tag (e.g. `v0.3.0`) plus its assets.
121#[derive(Debug, Clone)]
122pub struct Release {
123    pub tag: String,
124    pub assets: Vec<Asset>,
125}
126
127impl Release {
128    /// The semver version encoded in the tag (`v0.3.0` -> `0.3.0`).
129    pub fn version(&self) -> Result<Version> {
130        Version::parse(self.tag.trim_start_matches('v'))
131            .map_err(|e| CtxError::Other(format!("release tag '{}' is not semver: {e}", self.tag)))
132    }
133
134    /// Find an asset by exact file name.
135    pub fn asset(&self, name: &str) -> Option<&Asset> {
136        self.assets.iter().find(|a| a.name == name)
137    }
138}
139
140/// API base URL, overridable via `CTX_UPDATE_BASE_URL` (test hook).
141fn base_url() -> String {
142    std::env::var("CTX_UPDATE_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
143}
144
145fn http_client(timeout: Duration) -> Result<reqwest::blocking::Client> {
146    reqwest::blocking::Client::builder()
147        .timeout(timeout)
148        .user_agent(concat!("ctx/", env!("CARGO_PKG_VERSION")))
149        .build()
150        .map_err(CtxError::Network)
151}
152
153/// Fetch the latest (non-prerelease, non-draft) release.
154pub fn latest_release(client: &reqwest::blocking::Client) -> Result<Release> {
155    fetch_release(client, &format!("{}/releases/latest", base_url()))
156}
157
158/// Fetch a specific release by tag (e.g. `v0.3.0`).
159pub fn release_by_tag(client: &reqwest::blocking::Client, tag: &str) -> Result<Release> {
160    fetch_release(client, &format!("{}/releases/tags/{tag}", base_url()))
161}
162
163fn fetch_release(client: &reqwest::blocking::Client, url: &str) -> Result<Release> {
164    let response = client
165        .get(url)
166        .header("Accept", "application/vnd.github+json")
167        .send()?;
168    let status = response.status();
169    if !status.is_success() {
170        return Err(CtxError::Other(format!(
171            "release query failed: {url} returned HTTP {status}"
172        )));
173    }
174    let value: serde_json::Value = response.json()?;
175    parse_release(&value)
176}
177
178/// Parse the GitHub Releases API JSON for one release.
179pub fn parse_release(value: &serde_json::Value) -> Result<Release> {
180    let tag = value["tag_name"]
181        .as_str()
182        .ok_or_else(|| CtxError::Other("release JSON has no tag_name".to_string()))?
183        .to_string();
184    let assets = value["assets"]
185        .as_array()
186        .map(|assets| {
187            assets
188                .iter()
189                .filter_map(|a| {
190                    Some(Asset {
191                        name: a["name"].as_str()?.to_string(),
192                        download_url: a["browser_download_url"].as_str()?.to_string(),
193                    })
194                })
195                .collect()
196        })
197        .unwrap_or_default();
198    Ok(Release { tag, assets })
199}
200
201// ============================================================================
202// SHA256SUMS
203// ============================================================================
204
205/// Parse a `SHA256SUMS` file: one `<hex>  <name>` entry per line (the
206/// `sha256sum` / `shasum -a 256` format; a leading `*` on the name marks
207/// binary mode and is stripped). Returns file name -> lowercase hex digest.
208pub fn parse_sha256sums(text: &str) -> BTreeMap<String, String> {
209    let mut sums = BTreeMap::new();
210    for line in text.lines() {
211        let line = line.trim();
212        if line.is_empty() {
213            continue;
214        }
215        let Some((hex, name)) = line.split_once(char::is_whitespace) else {
216            continue;
217        };
218        let name = name.trim_start().trim_start_matches('*');
219        if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) && !name.is_empty() {
220            sums.insert(name.to_string(), hex.to_ascii_lowercase());
221        }
222    }
223    sums
224}
225
226fn sha256_hex(data: &[u8]) -> String {
227    let digest = Sha256::digest(data);
228    digest.iter().map(|b| format!("{b:02x}")).collect()
229}
230
231// ============================================================================
232// Archive extraction
233// ============================================================================
234
235/// Extract the ctx binary member from a downloaded release archive.
236///
237/// Unix artifacts are `.tar.gz` containing `ctx-<tag>-<target>/ctx`; Windows
238/// artifacts are `.zip` containing `ctx-<tag>-<target>/ctx.exe`.
239fn extract_binary(archive: &[u8], artifact: &str) -> Result<Vec<u8>> {
240    if artifact.ends_with(".tar.gz") {
241        return extract_from_tar_gz(archive);
242    }
243    #[cfg(windows)]
244    if artifact.ends_with(".zip") {
245        return extract_from_zip(archive);
246    }
247    Err(CtxError::Other(format!(
248        "cannot extract '{artifact}': unsupported archive format for this platform"
249    )))
250}
251
252fn extract_from_tar_gz(archive: &[u8]) -> Result<Vec<u8>> {
253    let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(archive));
254    for entry in tar.entries()? {
255        let mut entry = entry?;
256        if !entry.header().entry_type().is_file() {
257            continue;
258        }
259        let is_ctx = entry.path()?.file_name().is_some_and(|name| name == "ctx");
260        if is_ctx {
261            let mut binary = Vec::new();
262            entry.read_to_end(&mut binary)?;
263            return Ok(binary);
264        }
265    }
266    Err(CtxError::Other(
267        "release archive contains no 'ctx' binary member".to_string(),
268    ))
269}
270
271#[cfg(windows)]
272fn extract_from_zip(archive: &[u8]) -> Result<Vec<u8>> {
273    let cursor = std::io::Cursor::new(archive);
274    let mut zip = zip::ZipArchive::new(cursor)
275        .map_err(|e| CtxError::Other(format!("invalid release zip: {e}")))?;
276    for i in 0..zip.len() {
277        let mut entry = zip
278            .by_index(i)
279            .map_err(|e| CtxError::Other(format!("invalid release zip entry: {e}")))?;
280        if entry.is_dir() {
281            continue;
282        }
283        let is_ctx = entry
284            .enclosed_name()
285            .and_then(|p| p.file_name().map(|n| n.to_os_string()))
286            .is_some_and(|name| name == "ctx.exe");
287        if is_ctx {
288            let mut binary = Vec::new();
289            entry.read_to_end(&mut binary)?;
290            return Ok(binary);
291        }
292    }
293    Err(CtxError::Other(
294        "release archive contains no 'ctx.exe' binary member".to_string(),
295    ))
296}
297
298// ============================================================================
299// Atomic executable replacement
300// ============================================================================
301
302/// Check that we can create files in the executable's directory before doing
303/// any network work.
304fn ensure_writable(dir: &Path) -> Result<()> {
305    let probe = dir.join(format!(".ctx-write-probe-{}", std::process::id()));
306    let outcome = fs::write(&probe, b"").and_then(|()| fs::remove_file(&probe));
307    outcome.map_err(|e| {
308        CtxError::Other(format!(
309            "cannot update: install location '{}' is not writable ({e}); \
310             re-run with elevated permissions or update through the package \
311             manager that installed ctx (e.g. 'cargo install agentis-ctx')",
312            dir.display()
313        ))
314    })
315}
316
317/// Atomically replace `exe` with `data`: write to a temp file in the same
318/// directory, set executable permissions, then rename over the target.
319///
320/// On Windows the running executable cannot be overwritten, but it can be
321/// renamed: the current `ctx.exe` is moved aside to `ctx.exe.old` first, and
322/// the new binary is renamed into place. The `.old` file is removed on the
323/// next `ctx self-update` run (or can be deleted manually).
324fn replace_executable(exe: &Path, data: &[u8]) -> Result<()> {
325    let dir = exe
326        .parent()
327        .ok_or_else(|| CtxError::Other("executable has no parent directory".to_string()))?;
328    let staged = dir.join(format!(".ctx-update-{}", std::process::id()));
329    if let Err(e) = fs::write(&staged, data) {
330        let _ = fs::remove_file(&staged);
331        return Err(e.into());
332    }
333
334    #[cfg(unix)]
335    {
336        use std::os::unix::fs::PermissionsExt;
337        if let Err(e) = fs::set_permissions(&staged, fs::Permissions::from_mode(0o755)) {
338            let _ = fs::remove_file(&staged);
339            return Err(e.into());
340        }
341        if let Err(e) = fs::rename(&staged, exe) {
342            let _ = fs::remove_file(&staged);
343            return Err(e.into());
344        }
345    }
346
347    #[cfg(windows)]
348    {
349        let old = old_binary_path(exe);
350        let _ = fs::remove_file(&old);
351        if let Err(e) = fs::rename(exe, &old) {
352            let _ = fs::remove_file(&staged);
353            return Err(e.into());
354        }
355        if let Err(e) = fs::rename(&staged, exe) {
356            // Roll the original back into place before failing.
357            let _ = fs::rename(&old, exe);
358            let _ = fs::remove_file(&staged);
359            return Err(e.into());
360        }
361    }
362
363    Ok(())
364}
365
366#[cfg(windows)]
367fn old_binary_path(exe: &Path) -> PathBuf {
368    let mut name = exe
369        .file_name()
370        .map(|n| n.to_os_string())
371        .unwrap_or_default();
372    name.push(".old");
373    exe.with_file_name(name)
374}
375
376// ============================================================================
377// ctx self-update
378// ============================================================================
379
380/// Result of a [`self_update`] run.
381#[derive(Debug, Clone)]
382pub struct SelfUpdateReport {
383    pub old_version: Version,
384    pub new_version: Version,
385    /// `false` when there was nothing to do (already up to date).
386    pub updated: bool,
387}
388
389/// Update the running executable from the GitHub release feed.
390///
391/// `pin` installs an exact version (`X.Y.Z`, with or without a leading `v`)
392/// instead of the latest release; pinning also allows downgrades. Returns
393/// with `updated: false` when there is nothing to do. All failure modes
394/// (network, missing artifact, checksum mismatch, unwritable install
395/// location) return `Err`, which the CLI maps to exit code 2, and always
396/// leave the installed binary untouched.
397pub fn self_update(pin: Option<&str>) -> Result<SelfUpdateReport> {
398    let exe = std::env::current_exe()?;
399    let dir = exe
400        .parent()
401        .ok_or_else(|| CtxError::Other("cannot locate the running executable".to_string()))?;
402
403    // Clean up the renamed-aside binary a previous Windows update left behind.
404    #[cfg(windows)]
405    {
406        let _ = fs::remove_file(old_binary_path(&exe));
407    }
408
409    // Refuse before any network work when we could not replace the binary.
410    ensure_writable(dir)?;
411
412    let client = http_client(EXPLICIT_TIMEOUT)?;
413    let release = match pin {
414        Some(version) => {
415            let tag = format!("v{}", version.trim_start_matches('v'));
416            release_by_tag(&client, &tag)?
417        }
418        None => latest_release(&client)?,
419    };
420    let new_version = release.version()?;
421    let old_version = current_version();
422
423    // Latest: only move forward. Pinned: install anything that differs
424    // (explicit downgrades are allowed).
425    let update_needed = match pin {
426        Some(_) => new_version != old_version,
427        None => new_version > old_version,
428    };
429    if !update_needed {
430        return Ok(SelfUpdateReport {
431            old_version,
432            new_version,
433            updated: false,
434        });
435    }
436
437    let target = release_target().ok_or_else(|| {
438        CtxError::Other(format!(
439            "no prebuilt release artifact for this platform ({}-{}); \
440             update with 'cargo install agentis-ctx' instead",
441            std::env::consts::ARCH,
442            std::env::consts::OS
443        ))
444    })?;
445    let artifact = artifact_name(&release.tag, target);
446    let asset = release.asset(&artifact).ok_or_else(|| {
447        CtxError::Other(format!(
448            "release {} has no artifact named '{artifact}'",
449            release.tag
450        ))
451    })?;
452    let sums_asset = release.asset("SHA256SUMS").ok_or_else(|| {
453        CtxError::Other(format!(
454            "release {} publishes no SHA256SUMS file; refusing to install an unverifiable binary",
455            release.tag
456        ))
457    })?;
458
459    let sums_text = download_text(&client, &sums_asset.download_url)?;
460    let sums = parse_sha256sums(&sums_text);
461    let expected = sums.get(&artifact).ok_or_else(|| {
462        CtxError::Other(format!(
463            "SHA256SUMS for release {} has no entry for '{artifact}'",
464            release.tag
465        ))
466    })?;
467
468    let archive = download_bytes(&client, &asset.download_url)?;
469    let actual = sha256_hex(&archive);
470    if actual != *expected {
471        return Err(CtxError::Other(format!(
472            "checksum mismatch for '{artifact}': expected {expected}, got {actual}; \
473             aborting update (the installed binary is unchanged)"
474        )));
475    }
476
477    let binary = extract_binary(&archive, &artifact)?;
478    replace_executable(&exe, &binary)?;
479
480    Ok(SelfUpdateReport {
481        old_version,
482        new_version,
483        updated: true,
484    })
485}
486
487fn download_text(client: &reqwest::blocking::Client, url: &str) -> Result<String> {
488    let response = client.get(url).send()?;
489    let status = response.status();
490    if !status.is_success() {
491        return Err(CtxError::Other(format!(
492            "download failed: {url} returned HTTP {status}"
493        )));
494    }
495    Ok(response.text()?)
496}
497
498fn download_bytes(client: &reqwest::blocking::Client, url: &str) -> Result<Vec<u8>> {
499    let response = client.get(url).send()?;
500    let status = response.status();
501    if !status.is_success() {
502        return Err(CtxError::Other(format!(
503            "download failed: {url} returned HTTP {status}"
504        )));
505    }
506    Ok(response.bytes()?.to_vec())
507}
508
509// ============================================================================
510// Explicit version check (ctx --version --check)
511// ============================================================================
512
513/// The stderr notice printed by the passive check, also reused by
514/// `ctx --version --check`.
515pub fn update_notice(latest: &Version, current: &Version) -> String {
516    format!("ctx {latest} available (you have {current}) — run 'ctx self-update'")
517}
518
519/// Query the latest release and compare against the running binary.
520///
521/// Used by `ctx --version --check`; always allowed (no suppression, no 24h
522/// cache). Returns `(current, latest)`.
523pub fn explicit_check() -> Result<(Version, Version)> {
524    let client = http_client(EXPLICIT_TIMEOUT)?;
525    let latest = latest_release(&client)?.version()?;
526    Ok((current_version(), latest))
527}
528
529// ============================================================================
530// Passive update check
531// ============================================================================
532
533/// Cache directory for the passive-check timestamp:
534/// `$CTX_CACHE_DIR` (test hook) or `dirs::cache_dir()/ctx`.
535pub fn cache_dir() -> Option<PathBuf> {
536    if let Ok(dir) = std::env::var("CTX_CACHE_DIR") {
537        if !dir.is_empty() {
538            return Some(PathBuf::from(dir));
539        }
540    }
541    dirs::cache_dir().map(|d| d.join("ctx"))
542}
543
544/// True when the 24h interval since the last recorded check has elapsed
545/// (or no valid timestamp is recorded). `now` is unix seconds, injected so
546/// tests control the clock.
547pub fn check_is_due(stamp_file: &Path, now: u64) -> bool {
548    let last = fs::read_to_string(stamp_file)
549        .ok()
550        .and_then(|s| s.trim().parse::<u64>().ok());
551    match last {
552        Some(last) => now >= last.saturating_add(PASSIVE_CHECK_INTERVAL_SECS),
553        None => true,
554    }
555}
556
557/// Record `now` (unix seconds) as the last check attempt.
558pub fn record_check(stamp_file: &Path, now: u64) -> std::io::Result<()> {
559    if let Some(parent) = stamp_file.parent() {
560        fs::create_dir_all(parent)?;
561    }
562    fs::write(stamp_file, format!("{now}\n"))
563}
564
565/// The passive-check suppression predicate (H3). Pure so the whole gate
566/// matrix is unit-testable; `env` abstracts `std::env::var`.
567///
568/// The check is allowed only when **none** of these hold:
569/// - `--json` is active (stdout is a machine-readable document),
570/// - stderr is not a terminal (covers CI, pipes, and test harnesses),
571/// - `CTX_NO_UPDATE_CHECK` is set (non-empty),
572/// - a Claude Code hook/session environment is detected (`CLAUDECODE`,
573///   `CLAUDE_PROJECT_DIR`, or `CLAUDE_PLUGIN_ROOT`).
574///
575/// Suppression means **no network call at all**, not just a swallowed notice.
576pub fn passive_check_allowed<F>(json: bool, stderr_is_tty: bool, env: F) -> bool
577where
578    F: Fn(&str) -> Option<String>,
579{
580    if json || !stderr_is_tty {
581        return false;
582    }
583    if env("CTX_NO_UPDATE_CHECK").is_some_and(|v| !v.is_empty()) {
584        return false;
585    }
586    // Inside a Claude Code hook or session: never interfere.
587    for var in ["CLAUDECODE", "CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_ROOT"] {
588        if env(var).is_some() {
589            return false;
590        }
591    }
592    true
593}
594
595/// Passive update check, called from `main` after the command has finished
596/// (`ctx self-update` and `ctx --version` invocations skip it entirely).
597///
598/// At most one network request per 24h (timestamp cache), 1-second timeout,
599/// silent on any failure, never panics, never writes to stdout. When a newer
600/// release exists it prints exactly one line to stderr:
601///
602/// ```text
603/// ctx <new> available (you have <current>) — run 'ctx self-update'
604/// ```
605pub fn passive_check(json: bool) {
606    // CTX_UPDATE_FORCE_TTY=1 is a test-only hook: a test cannot give the
607    // child process a real stderr TTY, so it injects the TTY gate instead.
608    let stderr_is_tty = std::env::var("CTX_UPDATE_FORCE_TTY").map(|v| v == "1") == Ok(true)
609        || std::io::stderr().is_terminal();
610    if !passive_check_allowed(json, stderr_is_tty, |k| std::env::var(k).ok()) {
611        return;
612    }
613    // Failures are silent by design: an update notice is never worth
614    // breaking a working command for.
615    let _ = passive_check_inner();
616}
617
618fn passive_check_inner() -> Result<()> {
619    let dir = cache_dir().ok_or_else(|| CtxError::Other("no cache dir".to_string()))?;
620    let stamp = dir.join(STAMP_FILE);
621    let now = std::time::SystemTime::now()
622        .duration_since(std::time::UNIX_EPOCH)
623        .map(|d| d.as_secs())
624        .unwrap_or(0);
625    if !check_is_due(&stamp, now) {
626        return Ok(());
627    }
628    // Record the attempt before the network call so failures do not turn
629    // into a retry on every invocation.
630    record_check(&stamp, now)?;
631
632    let client = http_client(PASSIVE_TIMEOUT)?;
633    let latest = latest_release(&client)?.version()?;
634    let current = current_version();
635    if latest > current {
636        eprintln!("{}", update_notice(&latest, &current));
637    }
638    Ok(())
639}
640
641// ============================================================================
642// Tests
643// ============================================================================
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    // ---- platform / artifact mapping -------------------------------------
650
651    #[test]
652    fn test_artifact_names_mirror_release_workflow() {
653        // One entry per release.yml build-matrix row; tag == $GITHUB_REF_NAME.
654        let expected = [
655            (
656                "x86_64-unknown-linux-gnu",
657                "ctx-v0.3.0-x86_64-unknown-linux-gnu.tar.gz",
658            ),
659            (
660                "x86_64-apple-darwin",
661                "ctx-v0.3.0-x86_64-apple-darwin.tar.gz",
662            ),
663            (
664                "aarch64-apple-darwin",
665                "ctx-v0.3.0-aarch64-apple-darwin.tar.gz",
666            ),
667            (
668                "x86_64-pc-windows-msvc",
669                "ctx-v0.3.0-x86_64-pc-windows-msvc.zip",
670            ),
671        ];
672        for (target, name) in expected {
673            assert_eq!(artifact_name("v0.3.0", target), name);
674        }
675    }
676
677    #[test]
678    fn test_release_target_matches_compile_target() {
679        let expected = if cfg!(all(
680            target_os = "linux",
681            target_arch = "x86_64",
682            target_env = "gnu"
683        )) {
684            Some("x86_64-unknown-linux-gnu")
685        } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
686            Some("x86_64-apple-darwin")
687        } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
688            Some("aarch64-apple-darwin")
689        } else if cfg!(all(
690            target_os = "windows",
691            target_arch = "x86_64",
692            target_env = "msvc"
693        )) {
694            Some("x86_64-pc-windows-msvc")
695        } else {
696            None
697        };
698        assert_eq!(release_target(), expected);
699        // Every supported target maps to a well-formed artifact name.
700        if let Some(target) = release_target() {
701            let name = artifact_name("v1.0.0", target);
702            assert!(name.starts_with("ctx-v1.0.0-"));
703            assert!(name.ends_with(".tar.gz") || name.ends_with(".zip"));
704        }
705    }
706
707    // ---- SHA256SUMS -------------------------------------------------------
708
709    #[test]
710    fn test_parse_sha256sums_multi_line() {
711        let hex_a = "a".repeat(64);
712        let hex_b = "B".repeat(64);
713        let text = format!(
714            "{hex_a}  ctx-v0.3.0-x86_64-unknown-linux-gnu.tar.gz\n\
715             \n\
716             {hex_b} *ctx-v0.3.0-x86_64-pc-windows-msvc.zip\n\
717             not-a-sum-line\n\
718             deadbeef  too-short-digest.tar.gz\n"
719        );
720        let sums = parse_sha256sums(&text);
721        assert_eq!(sums.len(), 2);
722        assert_eq!(
723            sums["ctx-v0.3.0-x86_64-unknown-linux-gnu.tar.gz"], hex_a,
724            "plain entry parses"
725        );
726        // Binary-mode marker stripped, digest lowercased.
727        assert_eq!(
728            sums["ctx-v0.3.0-x86_64-pc-windows-msvc.zip"],
729            hex_b.to_ascii_lowercase()
730        );
731    }
732
733    #[test]
734    fn test_sha256_hex_matches_known_vector() {
735        // sha256("abc")
736        assert_eq!(
737            sha256_hex(b"abc"),
738            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
739        );
740    }
741
742    // ---- release JSON parsing ---------------------------------------------
743
744    #[test]
745    fn test_parse_release_and_version_comparison() {
746        let value = serde_json::json!({
747            "tag_name": "v0.3.0",
748            "assets": [
749                {"name": "SHA256SUMS", "browser_download_url": "https://x/SHA256SUMS"},
750                {"name": "ctx-v0.3.0-aarch64-apple-darwin.tar.gz",
751                 "browser_download_url": "https://x/ctx.tar.gz"},
752                {"malformed": true}
753            ]
754        });
755        let release = parse_release(&value).unwrap();
756        assert_eq!(release.tag, "v0.3.0");
757        assert_eq!(release.assets.len(), 2, "malformed asset entries skipped");
758        assert_eq!(release.version().unwrap(), Version::new(0, 3, 0));
759        assert!(release.asset("SHA256SUMS").is_some());
760        assert!(release.asset("nope.tar.gz").is_none());
761
762        // Version comparison semantics used by self_update / the notices.
763        assert!(Version::new(1, 0, 0) > Version::new(0, 9, 9));
764        assert!(Version::parse("0.3.0-rc.1").unwrap() < Version::new(0, 3, 0));
765    }
766
767    // ---- 24h cache --------------------------------------------------------
768
769    #[test]
770    fn test_check_is_due_with_injected_clock() {
771        let temp = tempfile::tempdir().unwrap();
772        let stamp = temp.path().join("nested").join("last-update-check");
773
774        // No stamp file yet: due.
775        assert!(check_is_due(&stamp, 1_000_000));
776
777        record_check(&stamp, 1_000_000).unwrap();
778        // Within 24h: not due (boundary excluded).
779        assert!(!check_is_due(&stamp, 1_000_000));
780        assert!(!check_is_due(
781            &stamp,
782            1_000_000 + PASSIVE_CHECK_INTERVAL_SECS - 1
783        ));
784        // At/after 24h: due again.
785        assert!(check_is_due(
786            &stamp,
787            1_000_000 + PASSIVE_CHECK_INTERVAL_SECS
788        ));
789
790        // Corrupt stamp: due.
791        fs::write(&stamp, "not-a-number").unwrap();
792        assert!(check_is_due(&stamp, 1_000_000));
793    }
794
795    // ---- suppression matrix -------------------------------------------------
796
797    #[test]
798    fn test_passive_check_suppression_matrix() {
799        let no_env = |_: &str| -> Option<String> { None };
800        let env_with = |key: &'static str| move |k: &str| (k == key).then(|| "1".to_string());
801
802        // Clean interactive invocation: allowed.
803        assert!(passive_check_allowed(false, true, no_env));
804
805        // --json active: suppressed.
806        assert!(!passive_check_allowed(true, true, no_env));
807        // stderr not a TTY: suppressed (covers CI and tests).
808        assert!(!passive_check_allowed(false, false, no_env));
809        // Opt-out env var: suppressed.
810        assert!(!passive_check_allowed(
811            false,
812            true,
813            env_with("CTX_NO_UPDATE_CHECK")
814        ));
815        // ...but an empty value does not suppress.
816        assert!(passive_check_allowed(false, true, |k: &str| (k
817            == "CTX_NO_UPDATE_CHECK")
818            .then(String::new)));
819        // Claude Code hook environment: suppressed, any of the three vars.
820        for var in ["CLAUDECODE", "CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_ROOT"] {
821            assert!(
822                !passive_check_allowed(false, true, env_with(var)),
823                "{var} must suppress the passive check"
824            );
825        }
826        // Combinations still suppress.
827        assert!(!passive_check_allowed(true, false, env_with("CLAUDECODE")));
828    }
829
830    // ---- notice text --------------------------------------------------------
831
832    #[test]
833    fn test_update_notice_format() {
834        let notice = update_notice(&Version::new(9, 9, 9), &Version::new(0, 2, 1));
835        assert_eq!(
836            notice,
837            "ctx 9.9.9 available (you have 0.2.1) — run 'ctx self-update'"
838        );
839    }
840
841    // ---- archive extraction --------------------------------------------------
842
843    #[test]
844    fn test_extract_binary_from_tar_gz() {
845        let payload = b"#!/bin/sh\necho fake ctx\n".to_vec();
846        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
847        let mut builder = tar::Builder::new(gz);
848        let mut header = tar::Header::new_gnu();
849        header.set_size(payload.len() as u64);
850        header.set_mode(0o755);
851        header.set_cksum();
852        builder
853            .append_data(
854                &mut header,
855                "ctx-v9.9.9-aarch64-apple-darwin/ctx",
856                payload.as_slice(),
857            )
858            .unwrap();
859        let archive = builder.into_inner().unwrap().finish().unwrap();
860
861        let extracted = extract_binary(&archive, "ctx-v9.9.9-aarch64-apple-darwin.tar.gz").unwrap();
862        assert_eq!(extracted, payload);
863    }
864
865    #[test]
866    fn test_extract_binary_missing_member_errors() {
867        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
868        let mut builder = tar::Builder::new(gz);
869        let mut header = tar::Header::new_gnu();
870        header.set_size(5);
871        header.set_mode(0o644);
872        header.set_cksum();
873        builder
874            .append_data(&mut header, "dir/README.md", &b"hello"[..])
875            .unwrap();
876        let archive = builder.into_inner().unwrap().finish().unwrap();
877
878        let err = extract_binary(&archive, "ctx-v9.9.9-x.tar.gz").unwrap_err();
879        assert!(err.to_string().contains("no 'ctx' binary member"));
880    }
881
882    // ---- atomic replacement ---------------------------------------------------
883
884    #[cfg(unix)]
885    #[test]
886    fn test_replace_executable_atomically_swaps_content() {
887        use std::os::unix::fs::PermissionsExt;
888
889        let temp = tempfile::tempdir().unwrap();
890        let exe = temp.path().join("ctx");
891        fs::write(&exe, b"old binary").unwrap();
892
893        replace_executable(&exe, b"new binary").unwrap();
894        assert_eq!(fs::read(&exe).unwrap(), b"new binary");
895        let mode = fs::metadata(&exe).unwrap().permissions().mode();
896        assert_eq!(mode & 0o755, 0o755, "replacement is executable");
897
898        // No staging leftovers.
899        let leftovers: Vec<_> = fs::read_dir(temp.path())
900            .unwrap()
901            .filter_map(|e| e.ok())
902            .filter(|e| e.file_name() != "ctx")
903            .collect();
904        assert!(leftovers.is_empty(), "leftovers: {leftovers:?}");
905    }
906
907    #[test]
908    fn test_ensure_writable_accepts_tempdir() {
909        let temp = tempfile::tempdir().unwrap();
910        ensure_writable(temp.path()).unwrap();
911    }
912
913    #[cfg(unix)]
914    #[test]
915    fn test_ensure_writable_rejects_readonly_dir() {
916        use std::os::unix::fs::PermissionsExt;
917
918        let temp = tempfile::tempdir().unwrap();
919        let dir = temp.path().join("ro");
920        fs::create_dir(&dir).unwrap();
921        fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
922
923        let err = ensure_writable(&dir).unwrap_err();
924        assert!(err.to_string().contains("not writable"), "{err}");
925
926        // Restore so the tempdir can be cleaned up.
927        fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
928    }
929}