Skip to main content

rpi_cli/
updates.rs

1//! Lightweight update discovery and update commands.
2//!
3//! Startup checks are deliberately best-effort: callers decide when to run
4//! them, each registry request uses a short timeout, and cached values are
5//! retained only as a fallback for temporary registry failures.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::future::Future;
9use std::path::{Path, PathBuf};
10#[cfg(windows)]
11use std::process::Stdio;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use futures::{stream, StreamExt};
15use serde::{Deserialize, Serialize};
16
17const CACHE_FILE: &str = "update-check.json";
18const CACHE_FALLBACK_MAX_AGE_MS: i64 = 6 * 60 * 60 * 1000;
19const REQUEST_TIMEOUT_MS: u64 = 1800;
20const UPDATE_CHECK_CONCURRENCY: usize = 4;
21const GITHUB_RELEASES_API: &str =
22    "https://api.github.com/repos/bigfish1913/pi-rust/releases?per_page=100";
23/// The single-release endpoint for the newest non-draft, non-prerelease tag.
24/// Authoritative version source for the stable CLI channel.
25const GITHUB_RELEASES_LATEST_API: &str =
26    "https://api.github.com/repos/bigfish1913/pi-rust/releases/latest";
27const STAGED_RPI_CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
28const MAX_STAGED_RPI_OUTPUT_BYTES: usize = 4 * 1024;
29const MAX_SELF_UPDATE_STATUS_BYTES: u64 = 64 * 1024;
30const MAX_SELF_UPDATE_STATUS_MESSAGE_CHARS: usize = 2 * 1024;
31/// How long a running self-update may stay in a non-terminal state before a
32/// later process treats it as debris and prunes it. A `cargo install` compiles
33/// from source, so the window is generous; a helper that outlives it is stuck,
34/// not slow.
35const STALE_SELF_UPDATE_STATUS_TTL_MS: i64 = 60 * 60 * 1000;
36/// How long a completed update keeps its status record. `rpi update` prints
37/// that path, so the file has to outlive the command that named it by long
38/// enough to actually be read; after that it is just clutter.
39const COMPLETED_SELF_UPDATE_STATUS_TTL_MS: i64 = 24 * 60 * 60 * 1000;
40static UPDATE_CACHE_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
41static SELF_UPDATE_STATUS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct UpdateNotice {
45    pub name: String,
46    pub current: String,
47    pub latest: String,
48    pub command: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct UpdateWarning {
53    pub message: String,
54    pub command: String,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq)]
58pub struct UpdateReport {
59    pub notices: Vec<UpdateNotice>,
60    pub warnings: Vec<UpdateWarning>,
61}
62
63impl UpdateReport {
64    pub fn is_empty(&self) -> bool {
65        self.notices.is_empty() && self.warnings.is_empty()
66    }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, Default)]
70#[serde(rename_all = "camelCase")]
71struct UpdateCache {
72    /// Legacy aggregate timestamp retained for backward-compatible cache reads.
73    checked_at: i64,
74    rpi_latest: Option<String>,
75    #[serde(default)]
76    native_packages: BTreeMap<String, String>,
77    #[serde(default)]
78    freshness: UpdateCacheFreshness,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, Default)]
82#[serde(rename_all = "camelCase")]
83struct UpdateCacheFreshness {
84    rpi: i64,
85    #[serde(default)]
86    native_packages: BTreeMap<String, i64>,
87}
88
89type RegistryCacheUpdate = (BTreeMap<String, String>, BTreeMap<String, i64>);
90
91#[derive(Debug, Deserialize)]
92struct CratesResponse {
93    #[serde(rename = "crate")]
94    crate_info: CrateInfo,
95}
96
97#[derive(Debug, Deserialize)]
98struct CrateInfo {
99    max_version: String,
100}
101
102#[derive(Debug, Deserialize)]
103struct GithubRelease {
104    tag_name: String,
105    #[serde(default)]
106    draft: bool,
107    #[serde(default)]
108    prerelease: bool,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112enum UpdateChannel {
113    Stable,
114    Preview,
115}
116#[derive(Debug, Deserialize)]
117#[serde(rename_all = "camelCase")]
118struct SelfUpdateStatus {
119    state: String,
120    #[serde(default)]
121    message: String,
122    /// The staging directory this update was installed into. Written by the
123    /// Windows helper; the older `preparing` records lack it, which is why an
124    /// unmapped staging directory is aged out instead of matched by name.
125    #[serde(default)]
126    staging: Option<PathBuf>,
127    #[serde(default)]
128    updated_at_ms: i64,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132enum RegistryLookup {
133    Found(String),
134    Missing,
135    TransientFailure,
136}
137
138/// Best-effort startup check for the rpi release and installed Rust extensions.
139pub async fn check_startup(cwd: &Path) -> UpdateReport {
140    let _ = cwd;
141    check_rpi_startup().await
142}
143
144/// Check the rpi release and every installed Rust cdylib extension.
145///
146/// Native-only: the npm/Git package halves of the old scope went away with the
147/// Pi compatibility layer, so this never shells out to a package manager.
148pub async fn check_rpi_startup() -> UpdateReport {
149    if update_checks_disabled() {
150        return report_without_remote_checks();
151    }
152    check_startup_with_client(startup_http_client()).await
153}
154
155fn update_checks_disabled() -> bool {
156    crate::args::offline_env_enabled() || std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_some()
157}
158
159/// Report the outcome of a self-update that ran in a previous process.
160fn report_without_remote_checks() -> UpdateReport {
161    let warnings = crate::config::agent_dir()
162        .ok()
163        .map(|agent_dir| consume_self_update_statuses(&agent_dir))
164        .unwrap_or_default();
165    UpdateReport {
166        notices: Vec::new(),
167        warnings,
168    }
169}
170
171fn startup_http_client() -> Option<reqwest::Client> {
172    reqwest::Client::builder()
173        .timeout(std::time::Duration::from_millis(REQUEST_TIMEOUT_MS))
174        .user_agent(format!("rpi/{}", crate::VERSION))
175        .build()
176        .ok()
177}
178
179async fn check_startup_with_client(client: Option<reqwest::Client>) -> UpdateReport {
180    let agent_dir = match crate::config::agent_dir() {
181        Ok(dir) => dir,
182        Err(_) => return UpdateReport::default(),
183    };
184    let warnings = consume_self_update_statuses(&agent_dir);
185    let cache_path = agent_dir.join(CACHE_FILE);
186    let previous_cache = read_cache(&cache_path);
187
188    // Installed Rust extensions come from `rpi install`'s registry, so this
189    // path stays independent of any Pi package resource discovery.
190    let native_checks = crate::install::installed_native_packages()
191        .into_iter()
192        .filter(|package| package.supports_registry_update())
193        .map(|package| {
194            let client = client.clone();
195            async move {
196                let latest = match client.as_ref() {
197                    Some(client) if package.prebuilt.is_some() => {
198                        match crate::prebuilt_extensions::latest_compatible_version(
199                            client, &package,
200                        )
201                        .await
202                        {
203                            Ok(Some(version)) => RegistryLookup::Found(version),
204                            Ok(None) => RegistryLookup::Missing,
205                            Err(_) => RegistryLookup::TransientFailure,
206                        }
207                    }
208                    Some(client) => fetch_crates_latest(client, &package.name).await,
209                    None => RegistryLookup::TransientFailure,
210                };
211                (native_cache_key(&package), latest)
212            }
213        });
214    let rpi_check = async {
215        Some(match client.as_ref() {
216            Some(client) => fetch_rpi_latest(client).await,
217            None => RegistryLookup::TransientFailure,
218        })
219    };
220    let (rpi_result, native_results) = tokio::join!(rpi_check, collect_bounded(native_checks));
221
222    let checked_at = now_ms();
223    let mut rpi_update = None;
224    if let Some(rpi_result) = rpi_result {
225        let cached_at = previous_cache
226            .as_ref()
227            .map(rpi_cache_checked_at)
228            .unwrap_or_default();
229        let fallback_allowed = timestamp_is_fresh(cached_at, checked_at);
230        let (latest, used_fallback) = lookup_with_cache_fallback(
231            rpi_result,
232            previous_cache
233                .as_ref()
234                .and_then(|cache| cache.rpi_latest.as_deref()),
235            fallback_allowed,
236        );
237        let value_checked_at =
238            resolved_checked_at(latest.as_deref(), used_fallback, cached_at, checked_at);
239        rpi_update = Some((latest, value_checked_at));
240    }
241
242    let legacy_checked_at = previous_cache
243        .as_ref()
244        .map(|cache| cache.checked_at)
245        .unwrap_or_default();
246    let native_packages = results_with_individual_cache_fallback(
247        native_results,
248        previous_cache.as_ref().map(|cache| &cache.native_packages),
249        previous_cache
250            .as_ref()
251            .map(|cache| &cache.freshness.native_packages),
252        legacy_checked_at,
253        checked_at,
254    );
255
256    let cache = merge_and_write_cache(&cache_path, checked_at, rpi_update, native_packages);
257    let mut report = report_from_cache(&cache);
258    report.warnings = warnings;
259    report
260}
261
262async fn collect_bounded<I, F, T>(checks: I) -> Vec<T>
263where
264    I: IntoIterator<Item = F>,
265    F: Future<Output = T>,
266{
267    stream::iter(checks)
268        .buffer_unordered(UPDATE_CHECK_CONCURRENCY)
269        .collect()
270        .await
271}
272
273fn rpi_cache_checked_at(cache: &UpdateCache) -> i64 {
274    if cache.freshness.rpi > 0 {
275        cache.freshness.rpi
276    } else {
277        cache.checked_at
278    }
279}
280
281fn entry_cache_checked_at(
282    freshness: Option<&BTreeMap<String, i64>>,
283    key: &str,
284    legacy_checked_at: i64,
285) -> i64 {
286    freshness
287        .and_then(|values| values.get(key).copied())
288        .filter(|checked_at| *checked_at > 0)
289        .unwrap_or(legacy_checked_at)
290}
291
292fn timestamp_is_fresh(checked_at: i64, now: i64) -> bool {
293    let age = now.saturating_sub(checked_at);
294    checked_at > 0 && (0..=CACHE_FALLBACK_MAX_AGE_MS).contains(&age)
295}
296
297fn resolved_checked_at(
298    value: Option<&str>,
299    used_fallback: bool,
300    cached_at: i64,
301    checked_at: i64,
302) -> i64 {
303    if value.is_none() {
304        0
305    } else if used_fallback {
306        cached_at
307    } else {
308        checked_at
309    }
310}
311
312fn results_with_individual_cache_fallback(
313    results: Vec<(String, RegistryLookup)>,
314    cached: Option<&BTreeMap<String, String>>,
315    freshness: Option<&BTreeMap<String, i64>>,
316    legacy_checked_at: i64,
317    checked_at: i64,
318) -> RegistryCacheUpdate {
319    let mut values = BTreeMap::new();
320    let mut checked_at_values = BTreeMap::new();
321    for (name, result) in results {
322        let cached_value = cached.and_then(|cached| cached.get(&name).map(String::as_str));
323        let cached_at = entry_cache_checked_at(freshness, &name, legacy_checked_at);
324        let (latest, used_fallback) = lookup_with_cache_fallback(
325            result,
326            cached_value,
327            timestamp_is_fresh(cached_at, checked_at),
328        );
329        if let Some(latest) = latest {
330            checked_at_values.insert(
331                name.clone(),
332                resolved_checked_at(Some(&latest), used_fallback, cached_at, checked_at),
333            );
334            values.insert(name, latest);
335        }
336    }
337    (values, checked_at_values)
338}
339
340fn merge_and_write_cache(
341    path: &Path,
342    checked_at: i64,
343    rpi_update: Option<(Option<String>, i64)>,
344    native_packages: RegistryCacheUpdate,
345) -> UpdateCache {
346    let _guard = UPDATE_CACHE_WRITE_LOCK
347        .lock()
348        .unwrap_or_else(std::sync::PoisonError::into_inner);
349    let mut cache = read_cache(path).unwrap_or_default();
350    if cache.checked_at <= 0 {
351        cache.checked_at = checked_at;
352    }
353    if let Some((latest, latest_checked_at)) = rpi_update {
354        cache.rpi_latest = latest;
355        cache.freshness.rpi = latest_checked_at;
356    }
357    cache.native_packages = native_packages.0;
358    cache.freshness.native_packages = native_packages.1;
359    let _ = write_cache(path, &cache);
360    cache
361}
362
363fn lookup_with_cache_fallback(
364    result: RegistryLookup,
365    cached: Option<&str>,
366    fallback_allowed: bool,
367) -> (Option<String>, bool) {
368    match result {
369        RegistryLookup::Found(version) => (Some(version), false),
370        RegistryLookup::Missing => (None, false),
371        RegistryLookup::TransientFailure if fallback_allowed => {
372            (cached.map(str::to_owned), cached.is_some())
373        }
374        RegistryLookup::TransientFailure => (None, false),
375    }
376}
377fn native_cache_key(package: &crate::install::InstalledNativePackage) -> String {
378    match &package.prebuilt {
379        Some(source) => format!(
380            "prebuilt:{}:{}#{}:{}",
381            if source.custom_repository {
382                "github"
383            } else {
384                "crates"
385            },
386            source.repository,
387            package.name,
388            source.target
389        ),
390        None => package.name.clone(),
391    }
392}
393
394fn report_from_cache(cache: &UpdateCache) -> UpdateReport {
395    let mut notices = Vec::new();
396    if let Some(latest) = cache.rpi_latest.as_deref() {
397        if is_newer(crate::VERSION, latest) {
398            notices.push(UpdateNotice {
399                name: "rpi".into(),
400                current: crate::VERSION.into(),
401                latest: latest.into(),
402                command: "rpi update".into(),
403            });
404        }
405    }
406    for package in crate::install::installed_native_packages() {
407        if !package.supports_registry_update() {
408            continue;
409        }
410        let Some(latest) = cache.native_packages.get(&native_cache_key(&package)) else {
411            continue;
412        };
413        if is_newer(&package.version, latest) {
414            notices.push(UpdateNotice {
415                name: package.name.clone(),
416                current: package.version,
417                latest: latest.clone(),
418                command: if let Some(source) =
419                    package.prebuilt.as_ref().filter(|p| p.custom_repository)
420                {
421                    format!(
422                        "rpi install {} --github {}",
423                        package.name, source.repository
424                    )
425                } else if package.prebuilt.is_some() {
426                    format!("rpi install {}", package.name)
427                } else {
428                    format!("rpi install {} --build", package.name)
429                },
430            });
431        }
432    }
433    UpdateReport {
434        notices,
435        warnings: Vec::new(),
436    }
437}
438
439fn normalize_windows_path(path: PathBuf) -> PathBuf {
440    if cfg!(windows) {
441        let text = path.to_string_lossy();
442        if let Some(unc) = text.strip_prefix(r"\\?\UNC\") {
443            return PathBuf::from(format!(r"\\{unc}"));
444        }
445        if let Some(local) = text.strip_prefix(r"\\?\") {
446            return PathBuf::from(local);
447        }
448    }
449    path
450}
451
452fn windows_paths_equal(left: &Path, right: &Path) -> bool {
453    let left = normalize_windows_path(left.to_path_buf());
454    let right = normalize_windows_path(right.to_path_buf());
455    if cfg!(windows) {
456        left.to_string_lossy()
457            .replace('/', "\\")
458            .eq_ignore_ascii_case(&right.to_string_lossy().replace('/', "\\"))
459    } else {
460        left == right
461    }
462}
463
464async fn fetch_rpi_latest(client: &reqwest::Client) -> RegistryLookup {
465    fetch_stable_from_github(client, GITHUB_RELEASES_LATEST_API).await
466}
467
468/// The stable version a `/releases/latest` body names, or `None` when the body
469/// is not a usable stable release.
470///
471/// `/releases/latest` already excludes drafts and prereleases, but the flags are
472/// re-checked here so a GitHub API behaviour change cannot silently hand the
473/// stable channel a prerelease. This is deliberately separate from
474/// `latest_preview_version`, which selects prereleases from the full list.
475fn stable_version_from_release(release: &GithubRelease) -> Option<String> {
476    if release.prerelease || release.draft {
477        return None;
478    }
479    let version = release.tag_name.strip_prefix('v')?;
480    let parsed = semver::Version::parse(version).ok()?;
481    parsed.pre.is_empty().then(|| version.to_string())
482}
483
484async fn fetch_stable_from_github(client: &reqwest::Client, url: &str) -> RegistryLookup {
485    let response = match client
486        .get(url)
487        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
488        .send()
489        .await
490    {
491        Ok(response) => response,
492        Err(_) => return RegistryLookup::TransientFailure,
493    };
494    // Missing, unavailable or malformed release metadata must not invalidate
495    // a fresh cached answer. A release may be temporarily unavailable while
496    // publishing; offline/cache age policy stays with the caller.
497    if !response.status().is_success() {
498        return RegistryLookup::TransientFailure;
499    }
500    let release = match response.json::<GithubRelease>().await {
501        Ok(release) => release,
502        Err(_) => return RegistryLookup::TransientFailure,
503    };
504    // A body that is not a usable stable release is an unanswered lookup too,
505    // for the same reason: keep a fresh cache able to serve the request.
506    match stable_version_from_release(&release) {
507        Some(version) => RegistryLookup::Found(version),
508        None => RegistryLookup::TransientFailure,
509    }
510}
511
512fn latest_preview_version(releases: impl IntoIterator<Item = GithubRelease>) -> RegistryLookup {
513    releases
514        .into_iter()
515        .filter(|release| release.prerelease && !release.draft)
516        .filter_map(|release| {
517            let version = release.tag_name.strip_prefix('v')?.to_string();
518            let parsed = semver::Version::parse(&version).ok()?;
519            (!parsed.pre.is_empty()).then_some((parsed, version))
520        })
521        .max_by(|left, right| left.0.cmp(&right.0))
522        .map(|(_, version)| RegistryLookup::Found(version))
523        .unwrap_or(RegistryLookup::Missing)
524}
525
526async fn fetch_rpi_preview_latest(client: &reqwest::Client) -> RegistryLookup {
527    let response = match client
528        .get(GITHUB_RELEASES_API)
529        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
530        .send()
531        .await
532    {
533        Ok(response) => response,
534        Err(_) => return RegistryLookup::TransientFailure,
535    };
536    if response.status() == reqwest::StatusCode::NOT_FOUND {
537        return RegistryLookup::Missing;
538    }
539    if !response.status().is_success() {
540        return RegistryLookup::TransientFailure;
541    }
542    let releases = match response.json::<Vec<GithubRelease>>().await {
543        Ok(releases) => releases,
544        Err(_) => return RegistryLookup::TransientFailure,
545    };
546    latest_preview_version(releases)
547}
548
549async fn fetch_crates_latest(client: &reqwest::Client, name: &str) -> RegistryLookup {
550    fetch_crates_url(client, &format!("https://crates.io/api/v1/crates/{name}")).await
551}
552
553async fn fetch_crates_url(client: &reqwest::Client, url: &str) -> RegistryLookup {
554    let response = match client.get(url).send().await {
555        Ok(response) => response,
556        Err(_) => return RegistryLookup::TransientFailure,
557    };
558    if matches!(
559        response.status(),
560        reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::GONE
561    ) {
562        return RegistryLookup::Missing;
563    }
564    if !response.status().is_success() {
565        return RegistryLookup::TransientFailure;
566    }
567    match response.json::<CratesResponse>().await {
568        Ok(response) => RegistryLookup::Found(response.crate_info.max_version),
569        Err(_) => RegistryLookup::TransientFailure,
570    }
571}
572
573/// Compare complete semantic versions, including prerelease precedence.
574pub fn is_newer(current: &str, latest: &str) -> bool {
575    let parse = |value: &str| semver::Version::parse(value.trim().trim_start_matches('v')).ok();
576    match (parse(current), parse(latest)) {
577        (Some(current), Some(latest)) => latest.cmp_precedence(&current).is_gt(),
578        _ => false,
579    }
580}
581
582fn read_cache(path: &Path) -> Option<UpdateCache> {
583    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
584}
585
586fn write_cache(path: &Path, cache: &UpdateCache) -> Result<(), String> {
587    if let Some(parent) = path.parent() {
588        std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
589    }
590    let data = serde_json::to_vec_pretty(cache).map_err(|error| error.to_string())?;
591    crate::config::atomic_write(path, &data).map_err(|error| error.to_string())
592}
593
594fn now_ms() -> i64 {
595    SystemTime::now()
596        .duration_since(UNIX_EPOCH)
597        .map(|duration| duration.as_millis() as i64)
598        .unwrap_or(0)
599}
600
601pub fn print_startup_notices(report: &UpdateReport) {
602    for warning in &report.warnings {
603        eprintln!("warning: {} Run `{}`.", warning.message, warning.command);
604    }
605    for notice in &report.notices {
606        eprintln!(
607            "Update available: {} {} -> {}. Run `{}`.",
608            notice.name, notice.current, notice.latest, notice.command
609        );
610    }
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
614enum SelfUpdateArgs {
615    Help,
616    Run {
617        force: bool,
618        build: bool,
619        channel: UpdateChannel,
620    },
621    Usage,
622}
623
624/// Parse `rpi update`'s own arguments after the global `--offline` flag has been
625/// stripped.
626fn parse_self_update_args(args: &[String]) -> SelfUpdateArgs {
627    let mut force = false;
628    let mut build = false;
629    let mut channel = UpdateChannel::Stable;
630    for arg in args {
631        match arg.as_str() {
632            "--help" | "-h" => return SelfUpdateArgs::Help,
633            "--force" | "-f" => force = true,
634            "--build" => build = true,
635            "preview" if channel == UpdateChannel::Stable => channel = UpdateChannel::Preview,
636            _ => return SelfUpdateArgs::Usage,
637        }
638    }
639    SelfUpdateArgs::Run {
640        force,
641        build,
642        channel,
643    }
644}
645
646fn print_self_update_help() {
647    println!(
648        "Usage: rpi update [preview] [--force] [--offline] [--build]\n\n\
649         Update the rpi CLI using verified GitHub prebuilt releases.\n\
650         The optional `preview` channel installs the latest GitHub prerelease;\n\
651         it is never selected by the normal update check.\n\n\
652         Options:\n  \
653         preview    use the GitHub prerelease channel instead of stable releases\n  \
654         --build    explicitly compile with Cargo instead of downloading a prebuilt binary\n  \
655         --force    reinstall even when this rpi is already the latest release\n  \
656         --offline  do not touch the network; report the last known answer only"
657    );
658}
659
660/// The latest release this process already knows about.
661///
662/// `registry_lookup: true` means a live request just answered *in this process*
663/// from the official GitHub release channel, so using it to short-circuit
664/// cannot hide a newer release. A value read from the
665/// 6-hour cache is only trusted for the offline report, where there is no
666/// alternative and the output says so.
667fn known_latest_rpi(registry_lookup: Option<&str>, cached: Option<&str>) -> Option<(String, bool)> {
668    registry_lookup
669        .map(|latest| (latest.to_string(), false))
670        .or_else(|| cached.map(|latest| (latest.to_string(), true)))
671}
672
673async fn lookup_latest_rpi() -> Option<(String, bool)> {
674    lookup_latest_rpi_from(GITHUB_RELEASES_LATEST_API).await
675}
676
677/// Resolve the latest stable version and report whether it came from a live
678/// answer.
679///
680/// The endpoint is a parameter so the whole decision — including `verified`
681/// and the cache fallback — can be exercised offline.
682async fn lookup_latest_rpi_from(github_latest_url: &str) -> Option<(String, bool)> {
683    let cache_path = crate::config::agent_dir()
684        .ok()
685        .map(|dir| dir.join(CACHE_FILE));
686    let cached = cache_path.as_deref().and_then(read_cache);
687    let result = match reqwest::Client::builder()
688        .timeout(std::time::Duration::from_secs(15))
689        .user_agent(format!("rpi/{}", crate::VERSION))
690        .build()
691    {
692        Ok(client) => fetch_stable_from_github(&client, github_latest_url).await,
693        Err(_) => RegistryLookup::TransientFailure,
694    };
695    // Only a live answer from an authoritative source counts as verified; a
696    // value that came back from the cache means every request failed and the
697    // version is up to six hours old. Only GitHub supplies live CLI versions.
698    let verified = matches!(result, RegistryLookup::Found(_));
699    let previous = cached
700        .as_ref()
701        .filter(|cache| timestamp_is_fresh(rpi_cache_checked_at(cache), now_ms()))
702        .and_then(|cache| cache.rpi_latest.as_deref());
703    let (latest, _) = lookup_with_cache_fallback(result, previous, true);
704    latest.map(|latest| (latest, verified))
705}
706
707/// `rpi update` is synchronous, so its registry lookup runs on its own thread and
708/// current-thread runtime. That also keeps it safe to call from inside a Tokio
709/// runtime, which is where the CLI enters it.
710fn lookup_latest_rpi_blocking() -> Option<(String, bool)> {
711    std::thread::Builder::new()
712        .name("rpi-update-latest-lookup".to_string())
713        .spawn(|| {
714            let runtime = tokio::runtime::Builder::new_current_thread()
715                .enable_all()
716                .build()
717                .ok()?;
718            runtime.block_on(lookup_latest_rpi())
719        })
720        .ok()?
721        .join()
722        .ok()?
723}
724
725async fn lookup_latest_preview() -> Option<(String, bool)> {
726    let result = match reqwest::Client::builder()
727        .timeout(std::time::Duration::from_secs(15))
728        .user_agent(format!("rpi/{}", crate::VERSION))
729        .build()
730    {
731        Ok(client) => fetch_rpi_preview_latest(&client).await,
732        Err(_) => RegistryLookup::TransientFailure,
733    };
734    let verified = matches!(result, RegistryLookup::Found(_));
735    match result {
736        RegistryLookup::Found(version) => Some((version, verified)),
737        _ => None,
738    }
739}
740
741fn lookup_latest_preview_blocking() -> Option<(String, bool)> {
742    std::thread::Builder::new()
743        .name("rpi-update-preview-lookup".to_string())
744        .spawn(|| {
745            let runtime = tokio::runtime::Builder::new_current_thread()
746                .enable_all()
747                .build()
748                .ok()?;
749            runtime.block_on(lookup_latest_preview())
750        })
751        .ok()?
752        .join()
753        .ok()?
754}
755
756/// The newest version already staged in a `pending-*` directory.
757///
758/// A staged update is a fully validated binary waiting for the running process
759/// to exit. Re-running `rpi update` while one is queued must not start a second
760/// compile, so the staged version joins the up-to-date comparison.
761fn staged_pending_version(update_root: &Path, channel: UpdateChannel) -> Option<semver::Version> {
762    let entries = std::fs::read_dir(update_root).ok()?;
763    let mut newest: Option<semver::Version> = None;
764    for entry in entries.flatten() {
765        let name = entry.file_name();
766        let Some(name) = name.to_str() else {
767            continue;
768        };
769        let Some(suffix) = name.strip_prefix("pending-") else {
770            continue;
771        };
772        // `tempfile` builds the name from `[A-Za-z0-9]`; anything else is not
773        // ours, so it is left alone rather than probed.
774        if suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
775            continue;
776        }
777        let staging = update_root.join(name);
778        if validated_real_directory(&staging, "self-update staging directory").is_err() {
779            continue;
780        }
781        let Ok(version) = validate_rpi_executable_version(
782            &staging.join("bin").join(staged_rpi_file_name()),
783            update_root,
784            crate::VERSION,
785            "staged rpi",
786        ) else {
787            continue;
788        };
789        if (channel == UpdateChannel::Stable && !version.pre.is_empty())
790            || (channel == UpdateChannel::Preview && version.pre.is_empty())
791        {
792            continue;
793        }
794        if newest.as_ref().map_or(true, |newest| version > *newest) {
795            newest = Some(version);
796        }
797    }
798    newest
799}
800
801fn staged_rpi_file_name() -> &'static str {
802    if cfg!(windows) {
803        "rpi.exe"
804    } else {
805        crate::APP_NAME
806    }
807}
808
809/// Reclaim debris and surface a previous run's failure, without a network
810/// round trip.
811///
812/// `rpi update` is the command that creates `self-update` records and staging
813/// directories, so it is also the right place to clean them up: leaving it to
814/// the startup check means a machine that only ever runs `rpi chat` keeps the
815/// debris from every interrupted update until the next start.
816fn reclaim_self_update_artifacts() {
817    let Ok(agent_dir) = crate::config::agent_dir() else {
818        return;
819    };
820    for warning in consume_self_update_statuses(&agent_dir) {
821        eprintln!("warning: {} Run `{}`.", warning.message, warning.command);
822    }
823}
824
825/// Download a verified CLI release by default; --build explicitly selects Cargo.
826/// Both paths use the same staging validation and transactional replacement.
827pub fn run_self_update(args: &[String]) -> i32 {
828    let offline = crate::args::normalize_offline_mode(args);
829    let args = crate::args::without_offline_flag(args);
830    let (force, build, channel) = match parse_self_update_args(&args) {
831        SelfUpdateArgs::Help => {
832            print_self_update_help();
833            return 0;
834        }
835        SelfUpdateArgs::Run {
836            force,
837            build,
838            channel,
839        } => (force, build, channel),
840        SelfUpdateArgs::Usage => {
841            eprintln!(
842                "error: `rpi update` accepts only `preview`, `--force`, `--offline` and `--build`"
843            );
844            return 2;
845        }
846    };
847
848    if offline {
849        reclaim_self_update_artifacts();
850        if channel == UpdateChannel::Preview {
851            println!(
852                "rpi preview update skipped: offline mode is enabled;\n\
853                 run `rpi update preview` without --offline to query GitHub."
854            );
855            return 0;
856        }
857        // The cache is the only source available without a network round trip,
858        // so it may be stale — say so rather than assert a version.
859        match known_latest_rpi(None, cached_latest_rpi().as_deref()) {
860            Some((latest, true)) if is_newer(crate::VERSION, &latest) => println!(
861                "rpi {} -> {} is available (offline: last known, not verified)\n\
862                 Run `rpi update` without --offline to install it.",
863                crate::VERSION,
864                latest
865            ),
866            Some(_) => println!(
867                "rpi is up to date with the last known release ({})\n\
868                 Offline mode is enabled; the registry was not contacted.",
869                crate::VERSION
870            ),
871            None => println!(
872                "rpi update skipped: offline mode is enabled and no cached release is known"
873            ),
874        }
875        return 0;
876    }
877
878    // Without this gate `rpi update` at the newest release still runs a full
879    // `cargo install` and schedules a binary replacement for the version that
880    // is already installed — which is exactly what makes a working update look
881    // broken. `--force` keeps an explicit reinstall available.
882    let release = if !build || !force {
883        match channel {
884            UpdateChannel::Stable => lookup_latest_rpi_blocking(),
885            UpdateChannel::Preview => lookup_latest_preview_blocking(),
886        }
887    } else {
888        None
889    };
890    if !force {
891        reclaim_self_update_artifacts();
892        let staged = pending_staged_version(channel);
893        let (latest, verified) = match release.clone() {
894            Some(found) => found,
895            None if staged.is_some() => {
896                println!(
897                    "could not reach the latest {} release; using the already staged update",
898                    if channel == UpdateChannel::Preview {
899                        "preview"
900                    } else {
901                        "stable"
902                    }
903                );
904                (crate::VERSION.to_string(), false)
905            }
906            None => {
907                println!(
908                    "could not reach the latest {} release\n\
909                     Retry later, or use `rpi update --build --force` for an explicit Cargo build.",
910                    if channel == UpdateChannel::Preview {
911                        "preview"
912                    } else {
913                        "stable"
914                    }
915                );
916                return 1;
917            }
918        };
919        // A staged update counts as "already going to happen": it is a fully
920        // validated binary waiting only for this process to exit, so compiling
921        // a second one buys nothing. It only counts when it is at least as new
922        // as the release the registry just reported.
923        let staged_wins = staged
924            .as_ref()
925            .filter(|staged| !is_newer(&staged.to_string(), &latest))
926            .map(std::string::ToString::to_string);
927        let target = staged_wins.clone().unwrap_or_else(|| latest.clone());
928        let needs_update = match channel {
929            UpdateChannel::Stable => is_newer(crate::VERSION, &target),
930            // Explicitly selecting preview is allowed from a stable install;
931            // a preview channel is intentionally independent of the stable channel's
932            // stable precedence ordering.
933            UpdateChannel::Preview => {
934                let current = semver::Version::parse(crate::VERSION).ok();
935                current.is_some_and(|current| current.pre.is_empty())
936                    || is_newer(crate::VERSION, &target)
937            }
938        };
939        if !needs_update {
940            if staged_wins.is_some() {
941                println!(
942                    "rpi {target} is already staged; quit this rpi and it will be applied\n\
943                     Run `rpi update --force` to stage it again."
944                );
945            } else if verified {
946                println!(
947                    "rpi is up to date ({} is the latest release)\n\
948                     Run `rpi update --force` to reinstall anyway.",
949                    crate::VERSION
950                );
951            } else {
952                println!(
953                    "rpi is up to date ({}; last known release {latest}, unverified)\n\
954                     Run `rpi update --force` to reinstall anyway.",
955                    crate::VERSION
956                );
957            }
958            return 0;
959        }
960    }
961
962    if !build && release.is_none() {
963        eprintln!(
964            "error: could not resolve the {} release version; retry later or select --build explicitly",
965            if channel == UpdateChannel::Preview {
966                "preview"
967            } else {
968                "stable"
969            }
970        );
971        return 1;
972    }
973    let version = release.as_ref().map(|(version, _)| version.as_str());
974    let code = {
975        #[cfg(windows)]
976        {
977            run_windows_self_update(build, version)
978        }
979        #[cfg(not(windows))]
980        {
981            run_direct_self_update(build, version)
982        }
983    };
984    if code == 0 {
985        clear_cached_latest_rpi();
986    }
987    code
988}
989
990fn pending_staged_version(channel: UpdateChannel) -> Option<semver::Version> {
991    let agent_dir = crate::config::agent_dir().ok()?;
992    let update_root = agent_dir.join("self-update");
993    staged_pending_version(&update_root, channel)
994}
995
996fn cached_latest_rpi() -> Option<String> {
997    let agent_dir = crate::config::agent_dir().ok()?;
998    let cache = read_cache(&agent_dir.join(CACHE_FILE))?;
999    cache.rpi_latest
1000}
1001
1002/// A self-update just changed the binary on disk, so the cached release is the
1003/// version being replaced. Dropping it stops the next startup (and the next
1004/// `rpi update`) from announcing an update that is already installed; the
1005/// following registry check refills it.
1006fn clear_cached_latest_rpi() {
1007    let Ok(agent_dir) = crate::config::agent_dir() else {
1008        return;
1009    };
1010    let path = agent_dir.join(CACHE_FILE);
1011    let _guard = UPDATE_CACHE_WRITE_LOCK
1012        .lock()
1013        .unwrap_or_else(std::sync::PoisonError::into_inner);
1014    let Some(mut cache) = read_cache(&path) else {
1015        return;
1016    };
1017    cache.rpi_latest = None;
1018    cache.freshness.rpi = 0;
1019    let _ = write_cache(&path, &cache);
1020}
1021
1022fn cargo_install_command(
1023    staging_root: Option<&Path>,
1024    isolated_cwd: &Path,
1025) -> Result<std::process::Command, String> {
1026    let isolated_cwd = validated_self_update_command_dir(isolated_cwd)?;
1027    let mut command = std::process::Command::new("cargo");
1028    command.arg("install").current_dir(isolated_cwd);
1029    for variable in [
1030        "RUSTC_WRAPPER",
1031        "RUSTC_WORKSPACE_WRAPPER",
1032        "CARGO_BUILD_RUSTC_WRAPPER",
1033        "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
1034    ] {
1035        command.env_remove(variable);
1036    }
1037    if let Some(staging_root) = staging_root {
1038        command.arg("--root").arg(staging_root);
1039    }
1040    // `--quiet` suppresses cargo's trailing advice to add the install root to
1041    // PATH. Here that advice is actively wrong: the staging root is a temp
1042    // directory that the atomic replace consumes, so a user who follows it
1043    // adds a path that will not exist a second later. Errors still print.
1044    command.args(["rpi-cli", "--locked", "--force", "--quiet"]);
1045    Ok(command)
1046}
1047
1048/// Both distribution paths feed the same validated staging/atomic replacement.
1049/// Cargo is only reached when the user explicitly selects --build.
1050fn prepare_self_update(staging: &Path, build: bool, version: Option<&str>) -> Result<(), String> {
1051    if build {
1052        let command_dir = create_self_update_command_dir()?;
1053        let status = cargo_install_command(Some(staging), command_dir.path())?
1054            .status()
1055            .map_err(|e| format!("could not run Cargo: {e}"))?;
1056        return if status.success() {
1057            Ok(())
1058        } else {
1059            Err(format!("cargo install exited with {status}"))
1060        };
1061    }
1062    let version = version
1063        .ok_or("no verified release version is available")?
1064        .to_string();
1065    let staging = staging.to_path_buf();
1066    std::thread::Builder::new()
1067        .name("rpi-prebuilt-update".into())
1068        .spawn(move || {
1069            let runtime = tokio::runtime::Builder::new_current_thread()
1070                .enable_all()
1071                .build()
1072                .map_err(|e| e.to_string())?;
1073            runtime.block_on(crate::prebuilt_release::download_to(&version, &staging))
1074        })
1075        .map_err(|e| e.to_string())?
1076        .join()
1077        .map_err(|_| "prebuilt update worker failed".to_string())?
1078}
1079
1080pub(crate) fn validate_downloaded_rpi(
1081    path: &Path,
1082    staging: &Path,
1083    expected: &str,
1084) -> Result<(), String> {
1085    let found = validate_rpi_executable_version(path, staging, crate::VERSION, "downloaded rpi")?;
1086    if found.to_string() != expected {
1087        return Err(format!(
1088            "downloaded rpi version {found} does not match manifest {expected}"
1089        ));
1090    }
1091    Ok(())
1092}
1093
1094/// Cargo discovers `.cargo/config.toml` from its working directory upward.
1095/// Put update commands directly below the user's home directory so neither an
1096/// untrusted project, a project-local `CARGO_HOME`, nor a world-writable system
1097/// temp directory can join that discovery chain. Cargo still reads its
1098/// explicitly configured user-level home through its normal environment.
1099fn create_self_update_command_dir() -> Result<tempfile::TempDir, String> {
1100    let configured_root =
1101        dirs::home_dir().ok_or_else(|| "could not resolve the user home directory".to_string())?;
1102    if !configured_root.is_absolute() {
1103        return Err("user home must be absolute for self-update".to_string());
1104    }
1105    let canonical_root = std::fs::canonicalize(&configured_root)
1106        .map(normalize_windows_path)
1107        .map_err(|error| format!("could not canonicalize user home: {error}"))?;
1108    let metadata = std::fs::symlink_metadata(&canonical_root)
1109        .map_err(|error| format!("could not inspect user home: {error}"))?;
1110    if !metadata.is_dir() || metadata.file_type().is_symlink() {
1111        return Err("user home does not resolve to a real directory".to_string());
1112    }
1113    let current = std::env::current_dir()
1114        .and_then(std::fs::canonicalize)
1115        .map(normalize_windows_path)
1116        .map_err(|error| format!("could not validate the caller directory: {error}"))?;
1117    let project_root = current
1118        .ancestors()
1119        .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
1120        .map(Path::to_path_buf);
1121    validate_configured_cargo_home(project_root.as_deref())?;
1122    if project_root.as_deref().is_some_and(|root| {
1123        !windows_paths_equal(&canonical_root, root) && path_is_within(&canonical_root, root)
1124    }) {
1125        return Err(
1126            "refusing to create a self-update command directory inside the caller project"
1127                .to_string(),
1128        );
1129    }
1130    let directory = tempfile::Builder::new()
1131        .prefix("rpi-self-update-command-")
1132        .tempdir_in(&canonical_root)
1133        .map_err(|error| format!("could not allocate Cargo command directory: {error}"))?;
1134    validated_self_update_command_dir(directory.path())?;
1135    #[cfg(unix)]
1136    {
1137        use std::os::unix::fs::{MetadataExt, PermissionsExt};
1138
1139        let directory_metadata = std::fs::symlink_metadata(directory.path())
1140            .map_err(|error| format!("could not inspect Cargo command directory: {error}"))?;
1141        if metadata.uid() != directory_metadata.uid() || metadata.permissions().mode() & 0o022 != 0
1142        {
1143            return Err("user home is not private to the current account".to_string());
1144        }
1145    }
1146    Ok(directory)
1147}
1148
1149fn path_is_within(candidate: &Path, ancestor: &Path) -> bool {
1150    candidate
1151        .ancestors()
1152        .any(|component| windows_paths_equal(component, ancestor))
1153}
1154
1155fn validate_configured_cargo_home(project_root: Option<&Path>) -> Result<(), String> {
1156    let Some(configured) = std::env::var_os("CARGO_HOME") else {
1157        return Ok(());
1158    };
1159    let configured = PathBuf::from(configured);
1160    if !configured.is_absolute() {
1161        return Err("CARGO_HOME must be absolute for self-update".to_string());
1162    }
1163    let project_local = match project_root {
1164        Some(root) => cargo_home_is_project_local(&configured, root)?,
1165        None => {
1166            canonicalize_allow_missing(&configured, "CARGO_HOME")?;
1167            false
1168        }
1169    };
1170    if project_local {
1171        return Err("refusing project-local CARGO_HOME during self-update".to_string());
1172    }
1173    Ok(())
1174}
1175
1176fn cargo_home_is_project_local(configured: &Path, project_root: &Path) -> Result<bool, String> {
1177    if !configured.is_absolute() {
1178        return Err("CARGO_HOME must be absolute for self-update".to_string());
1179    }
1180    let configured = canonicalize_allow_missing(configured, "CARGO_HOME")?;
1181    Ok(path_is_within(&configured, project_root))
1182}
1183
1184fn canonicalize_allow_missing(path: &Path, label: &str) -> Result<PathBuf, String> {
1185    if path.components().any(|component| {
1186        matches!(
1187            component,
1188            std::path::Component::CurDir | std::path::Component::ParentDir
1189        )
1190    }) {
1191        return Err(format!("{label} contains relative path components"));
1192    }
1193    let mut existing = path;
1194    let mut missing = Vec::new();
1195    loop {
1196        match std::fs::symlink_metadata(existing) {
1197            Ok(_) => break,
1198            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1199                let name = existing
1200                    .file_name()
1201                    .ok_or_else(|| format!("could not resolve {label}"))?;
1202                missing.push(name.to_os_string());
1203                existing = existing
1204                    .parent()
1205                    .ok_or_else(|| format!("could not resolve {label}"))?;
1206            }
1207            Err(error) => return Err(format!("could not inspect {label}: {error}")),
1208        }
1209    }
1210    let mut canonical = std::fs::canonicalize(existing)
1211        .map(normalize_windows_path)
1212        .map_err(|error| format!("could not canonicalize {label}: {error}"))?;
1213    for component in missing.into_iter().rev() {
1214        canonical.push(component);
1215    }
1216    Ok(canonical)
1217}
1218
1219fn canonical_display(path: &Path) -> String {
1220    std::fs::canonicalize(path)
1221        .map(normalize_windows_path)
1222        .map(|path| path.display().to_string())
1223        .unwrap_or_else(|_| "<unresolvable>".to_string())
1224}
1225
1226fn validated_self_update_command_dir(path: &Path) -> Result<PathBuf, String> {
1227    if !path.is_absolute() {
1228        return Err("self-update command directory must be absolute".to_string());
1229    }
1230    let metadata = std::fs::symlink_metadata(path)
1231        .map_err(|error| format!("could not inspect self-update command directory: {error}"))?;
1232    if !metadata.is_dir() || metadata.file_type().is_symlink() {
1233        return Err("self-update command directory is not a real directory".to_string());
1234    }
1235    let canonical = std::fs::canonicalize(path)
1236        .map(normalize_windows_path)
1237        .map_err(|error| {
1238            format!("could not canonicalize self-update command directory: {error}")
1239        })?;
1240    Ok(canonical)
1241}
1242
1243#[cfg(not(windows))]
1244#[derive(Debug, Clone)]
1245struct DirectUpdatePlan {
1246    staging_dir: PathBuf,
1247    staged_exe: PathBuf,
1248    target_exe: PathBuf,
1249    replacement_temp: PathBuf,
1250    backup_file: PathBuf,
1251}
1252
1253#[cfg(not(windows))]
1254fn run_direct_self_update(build: bool, version: Option<&str>) -> i32 {
1255    let staging = match create_self_update_staging_directory() {
1256        Ok(directory) => directory,
1257        Err(error) => {
1258            eprintln!("error: could not create self-update staging directory: {error}");
1259            return 1;
1260        }
1261    };
1262    if let Err(error) = prepare_self_update(staging.path(), build, version) {
1263        eprintln!("error: {error}");
1264        return 1;
1265    }
1266
1267    let current_exe = match std::env::current_exe() {
1268        Ok(path) => path,
1269        Err(error) => {
1270            eprintln!("error: could not resolve the current rpi executable: {error}");
1271            return 1;
1272        }
1273    };
1274    let plan = match build_direct_update_plan(staging.path(), &current_exe) {
1275        Ok(plan) => plan,
1276        Err(error) => {
1277            eprintln!("error: {error}");
1278            return 1;
1279        }
1280    };
1281    match activate_direct_update(&plan) {
1282        Ok(version) => {
1283            println!("rpi updated successfully to {version}");
1284            0
1285        }
1286        Err(error) => {
1287            eprintln!("error: {error}");
1288            1
1289        }
1290    }
1291}
1292
1293fn create_self_update_staging_directory() -> Result<tempfile::TempDir, String> {
1294    let agent = crate::config::agent_dir().map_err(|error| error.to_string())?;
1295    if !agent.is_absolute() {
1296        return Err("agent directory must be absolute".to_string());
1297    }
1298    std::fs::create_dir_all(&agent)
1299        .map_err(|error| format!("could not create {}: {error}", agent.display()))?;
1300    let agent = validated_real_directory(&agent, "agent directory")?;
1301    let update_root = agent.join("self-update");
1302    std::fs::create_dir_all(&update_root)
1303        .map_err(|error| format!("could not create {}: {error}", update_root.display()))?;
1304    let update_root = validated_real_directory(&update_root, "self-update root")?;
1305    let staging = tempfile::Builder::new()
1306        .prefix("pending-")
1307        .tempdir_in(&update_root)
1308        .map_err(|error| format!("could not allocate staging directory: {error}"))?;
1309    validated_real_directory(staging.path(), "self-update staging directory")?;
1310    Ok(staging)
1311}
1312
1313#[cfg(not(windows))]
1314fn build_direct_update_plan(
1315    staging_dir: &Path,
1316    target_exe: &Path,
1317) -> Result<DirectUpdatePlan, String> {
1318    let staging_dir = validated_real_directory(staging_dir, "self-update staging directory")?;
1319    let staged_exe = validated_regular_file(
1320        &staging_dir.join("bin").join(crate::APP_NAME),
1321        "staged rpi executable",
1322    )?;
1323    let target_exe = validated_regular_file(target_exe, "current rpi executable")?;
1324    let target_dir = validated_real_directory(
1325        target_exe
1326            .parent()
1327            .ok_or_else(|| "current executable has no parent directory".to_string())?,
1328        "current executable directory",
1329    )?;
1330    if target_exe.parent() != Some(target_dir.as_path()) {
1331        return Err("current executable is not a direct child of its validated directory".into());
1332    }
1333    let token = uuid::Uuid::new_v4().simple().to_string();
1334    let replacement_temp = target_dir.join(format!(".rpi-update-{token}.new"));
1335    let backup_file = target_dir.join(format!(".rpi-update-{token}.old"));
1336    if replacement_temp.exists() || backup_file.exists() {
1337        return Err("self-update replacement path collision".to_string());
1338    }
1339    Ok(DirectUpdatePlan {
1340        staging_dir,
1341        staged_exe,
1342        target_exe,
1343        replacement_temp,
1344        backup_file,
1345    })
1346}
1347
1348#[cfg(not(windows))]
1349fn activate_direct_update(plan: &DirectUpdatePlan) -> Result<semver::Version, String> {
1350    let staged_version = validate_rpi_executable_version(
1351        &plan.staged_exe,
1352        &plan.staging_dir,
1353        crate::VERSION,
1354        "staged rpi",
1355    )?;
1356    preflight_direct_replacement(plan)?;
1357
1358    let result = (|| {
1359        std::fs::copy(&plan.staged_exe, &plan.replacement_temp).map_err(|error| {
1360            format!("could not copy staged rpi beside the current executable: {error}")
1361        })?;
1362        validated_regular_file(&plan.replacement_temp, "replacement rpi executable")?;
1363        let copied_version = validate_rpi_executable_version(
1364            &plan.replacement_temp,
1365            plan.target_exe
1366                .parent()
1367                .ok_or_else(|| "current executable has no parent directory".to_string())?,
1368            crate::VERSION,
1369            "replacement rpi",
1370        )?;
1371        if copied_version != staged_version
1372            || !files_are_identical(&plan.staged_exe, &plan.replacement_temp)?
1373        {
1374            return Err(
1375                "replacement rpi does not match the validated staged executable".to_string(),
1376            );
1377        }
1378
1379        std::fs::hard_link(&plan.target_exe, &plan.backup_file)
1380            .map_err(|error| format!("could not preserve the current rpi executable: {error}"))?;
1381        let installed_version = validate_rpi_executable_version(
1382            &plan.backup_file,
1383            plan.target_exe
1384                .parent()
1385                .ok_or_else(|| "current executable has no parent directory".to_string())?,
1386            crate::VERSION,
1387            "current rpi",
1388        )?;
1389        if staged_version < installed_version {
1390            return Err(format!(
1391                "refusing to replace rpi {installed_version} with older version {staged_version}"
1392            ));
1393        }
1394        if !files_are_identical(&plan.target_exe, &plan.backup_file)? {
1395            return Err("current rpi executable changed while preparing the update".to_string());
1396        }
1397
1398        std::fs::rename(&plan.replacement_temp, &plan.target_exe).map_err(|error| {
1399            format!("could not atomically replace the current rpi executable: {error}")
1400        })?;
1401        Ok(())
1402    })();
1403
1404    if let Err(error) = result {
1405        let _ = remove_file_if_exists(&plan.replacement_temp);
1406        let _ = remove_file_if_exists(&plan.backup_file);
1407        return Err(error);
1408    }
1409
1410    let validation = (|| {
1411        if !files_are_identical(&plan.staged_exe, &plan.target_exe)? {
1412            return Err("updated rpi executable does not match the staged executable".to_string());
1413        }
1414        let updated_version = validate_rpi_executable_version(
1415            &plan.target_exe,
1416            plan.target_exe
1417                .parent()
1418                .ok_or_else(|| "current executable has no parent directory".to_string())?,
1419            crate::VERSION,
1420            "updated rpi",
1421        )?;
1422        if updated_version != staged_version {
1423            return Err(format!(
1424                "updated rpi reported version {updated_version}, expected {staged_version}"
1425            ));
1426        }
1427        Ok(())
1428    })();
1429
1430    if let Err(error) = validation {
1431        return Err(rollback_direct_update(plan, &error));
1432    }
1433    let _ = remove_file_if_exists(&plan.backup_file);
1434    Ok(staged_version)
1435}
1436
1437#[cfg(not(windows))]
1438fn preflight_direct_replacement(plan: &DirectUpdatePlan) -> Result<(), String> {
1439    for path in [&plan.replacement_temp, &plan.backup_file] {
1440        if path.exists() {
1441            return Err(format!(
1442                "self-update path already exists: {}",
1443                path.display()
1444            ));
1445        }
1446        let file = std::fs::OpenOptions::new()
1447            .write(true)
1448            .create_new(true)
1449            .open(path)
1450            .map_err(|error| format!("cannot write beside the current executable: {error}"))?;
1451        drop(file);
1452        std::fs::remove_file(path)
1453            .map_err(|error| format!("could not remove self-update probe: {error}"))?;
1454    }
1455    Ok(())
1456}
1457
1458#[cfg(not(windows))]
1459fn rollback_direct_update(plan: &DirectUpdatePlan, failure: &str) -> String {
1460    let _ = remove_file_if_exists(&plan.replacement_temp);
1461    match std::fs::rename(&plan.backup_file, &plan.target_exe) {
1462        Ok(()) => format!("{failure}; restored the previous rpi executable"),
1463        Err(rollback) => format!(
1464            "{failure}; automatic rollback failed ({rollback}); the previous executable remains at {}",
1465            plan.backup_file.display()
1466        ),
1467    }
1468}
1469
1470fn remove_file_if_exists(path: &Path) -> Result<(), String> {
1471    match std::fs::remove_file(path) {
1472        Ok(()) => Ok(()),
1473        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1474        Err(error) => Err(error.to_string()),
1475    }
1476}
1477
1478#[cfg(windows)]
1479#[derive(Debug, Clone)]
1480struct WindowsUpdatePlan {
1481    staging_dir: PathBuf,
1482    staged_exe: PathBuf,
1483    target_exe: PathBuf,
1484    helper_script: PathBuf,
1485    status_file: PathBuf,
1486    replacement_temp: PathBuf,
1487    backup_file: PathBuf,
1488    rollback_displaced_file: PathBuf,
1489}
1490
1491#[cfg(windows)]
1492struct WindowsUpdateStaging {
1493    directory: tempfile::TempDir,
1494    status_file: PathBuf,
1495}
1496
1497#[cfg(windows)]
1498const WINDOWS_UPDATE_HELPER: &str = r#"param(
1499    [Parameter(Mandatory = $true)][long] $ParentPid,
1500    [Parameter(Mandatory = $true)][string] $StagingPath,
1501    [Parameter(Mandatory = $true)][string] $StagedPath,
1502    [Parameter(Mandatory = $true)][string] $StagedVersion,
1503    [Parameter(Mandatory = $true)][string] $TargetPath,
1504    [Parameter(Mandatory = $true)][string] $TempPath,
1505    [Parameter(Mandatory = $true)][string] $BackupPath,
1506    [Parameter(Mandatory = $true)][string] $RollbackDisplacedPath,
1507    [Parameter(Mandatory = $true)][string] $StatusPath
1508)
1509
1510Set-StrictMode -Version Latest
1511$ErrorActionPreference = 'Stop'
1512
1513function Write-UpdateStatus {
1514    param([string] $State, [string] $Message)
1515    $record = [ordered]@{
1516        state = $State
1517        message = $Message
1518        parentPid = $ParentPid
1519        staging = $StagingPath
1520        target = $TargetPath
1521        staged = $StagedPath
1522        stagedVersion = $StagedVersion
1523        backup = $BackupPath
1524        rollbackDisplaced = $RollbackDisplacedPath
1525        updatedAt = [DateTime]::UtcNow.ToString('o')
1526    }
1527    $json = $record | ConvertTo-Json -Compress
1528    $statusTemp = $StatusPath + '.tmp'
1529    [IO.File]::WriteAllText($statusTemp, $json, [Text.UTF8Encoding]::new($false))
1530    Move-Item -LiteralPath $statusTemp -Destination $StatusPath -Force
1531}
1532
1533function Assert-RegularFile {
1534    param([string] $Path, [string] $Label)
1535    $item = Get-Item -LiteralPath $Path -Force
1536    if ($item.PSIsContainer -or $item.Length -le 0 -or
1537        (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
1538        throw "$Label is not a non-empty regular file"
1539    }
1540}
1541
1542function Assert-WindowsExecutable {
1543    param([string] $Path, [string] $Label)
1544    Assert-RegularFile -Path $Path -Label $Label
1545    $stream = [IO.File]::OpenRead($Path)
1546    try {
1547        if ($stream.ReadByte() -ne 77 -or $stream.ReadByte() -ne 90) {
1548            throw "$Label is not a Windows executable"
1549        }
1550    } finally {
1551        $stream.Dispose()
1552    }
1553}
1554
1555function Assert-RealDirectory {
1556    param([string] $Path, [string] $Label)
1557    $item = Get-Item -LiteralPath $Path -Force
1558    if (-not $item.PSIsContainer -or
1559        (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
1560        throw "$Label is not a real directory"
1561    }
1562}
1563
1564function Get-FileSha256 {
1565    param([string] $Path)
1566    $stream = [IO.File]::OpenRead($Path)
1567    try {
1568        $sha256 = [Security.Cryptography.SHA256]::Create()
1569        try {
1570            return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '')
1571        } finally {
1572            $sha256.Dispose()
1573        }
1574    } finally {
1575        $stream.Dispose()
1576    }
1577}
1578
1579function Remove-StagingPayload {
1580    try {
1581        $root = [IO.Path]::GetFullPath($StagingPath)
1582        Assert-RealDirectory -Path $root -Label 'self-update staging directory'
1583        $bin = [IO.Path]::Combine($root, 'bin')
1584        $helper = [IO.Path]::Combine($root, 'apply-update.ps1')
1585        foreach ($file in @(
1586            $StagedPath,
1587            [IO.Path]::Combine($root, '.crates.toml'),
1588            [IO.Path]::Combine($root, '.crates2.json'),
1589            $helper
1590        )) {
1591            if ([IO.File]::Exists($file)) {
1592                [IO.File]::Delete($file)
1593            }
1594        }
1595        if ([IO.Directory]::Exists($bin) -and
1596            [IO.Directory]::GetFileSystemEntries($bin).Length -eq 0) {
1597            [IO.Directory]::Delete($bin, $false)
1598        }
1599        if ([IO.Directory]::Exists($root) -and
1600            [IO.Directory]::GetFileSystemEntries($root).Length -eq 0) {
1601            [IO.Directory]::Delete($root, $false)
1602        }
1603    } catch {
1604        # Cleanup is best-effort; update status and recovery files stay intact.
1605    }
1606}
1607
1608$replaced = $false
1609try {
1610    $stagingFull = [IO.Path]::GetFullPath($StagingPath)
1611    $stagedFull = [IO.Path]::GetFullPath($StagedPath)
1612    $targetFull = [IO.Path]::GetFullPath($TargetPath)
1613    $tempFull = [IO.Path]::GetFullPath($TempPath)
1614    $backupFull = [IO.Path]::GetFullPath($BackupPath)
1615    $rollbackDisplacedFull = [IO.Path]::GetFullPath($RollbackDisplacedPath)
1616    $helperFull = [IO.Path]::Combine($stagingFull, 'apply-update.ps1')
1617    $statusFull = [IO.Path]::GetFullPath($StatusPath)
1618    $targetDirectory = [IO.Path]::GetDirectoryName($targetFull)
1619    if (-not [StringComparer]::OrdinalIgnoreCase.Equals(
1620            [IO.Path]::GetDirectoryName([IO.Path]::GetDirectoryName($stagedFull)), $stagingFull) -or
1621        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1622            [IO.Path]::GetDirectoryName($helperFull), $stagingFull) -or
1623        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1624            [IO.Path]::GetFullPath($PSScriptRoot), $stagingFull) -or
1625        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1626            [IO.Path]::GetDirectoryName($statusFull), [IO.Path]::GetDirectoryName($stagingFull)) -or
1627        [string]::IsNullOrWhiteSpace($targetDirectory) -or
1628        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1629            [IO.Path]::GetDirectoryName($tempFull), $targetDirectory) -or
1630        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1631            [IO.Path]::GetDirectoryName($backupFull), $targetDirectory) -or
1632        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1633            [IO.Path]::GetDirectoryName($rollbackDisplacedFull), $targetDirectory)) {
1634        throw 'self-update paths are outside their validated staging or target directories'
1635    }
1636
1637    Assert-RealDirectory -Path $stagingFull -Label 'self-update staging directory'
1638    Assert-RealDirectory -Path $targetDirectory -Label 'target directory'
1639    Assert-WindowsExecutable -Path $stagedFull -Label 'staged executable'
1640    Assert-WindowsExecutable -Path $targetFull -Label 'current executable'
1641    $expectedHash = Get-FileSha256 -Path $stagedFull
1642    $originalTargetHash = Get-FileSha256 -Path $targetFull
1643    if ([IO.File]::Exists($tempFull) -or [IO.File]::Exists($backupFull) -or
1644        [IO.File]::Exists($rollbackDisplacedFull)) {
1645        throw 'replacement, backup, or rollback path already exists'
1646    }
1647
1648    Write-UpdateStatus -State 'waiting' -Message 'waiting for the current rpi process to exit'
1649    if ($ParentPid -lt 1 -or $ParentPid -gt [int]::MaxValue) {
1650        throw 'parent process id is outside the supported Windows range'
1651    }
1652    $parentProcess = $null
1653    try {
1654        $candidate = [Diagnostics.Process]::GetProcessById([int]$ParentPid)
1655        try {
1656            $candidatePath = [IO.Path]::GetFullPath($candidate.MainModule.FileName)
1657            if ([StringComparer]::OrdinalIgnoreCase.Equals($candidatePath, $targetFull)) {
1658                $parentProcess = $candidate
1659            } else {
1660                $candidate.Dispose()
1661            }
1662        } catch {
1663            if ($candidate.HasExited) {
1664                $candidate.Dispose()
1665            } else {
1666                throw
1667            }
1668        }
1669    } catch [ArgumentException] {
1670        # The invoking process exited before the helper acquired its handle.
1671    }
1672    if ($null -ne $parentProcess) {
1673        try {
1674            if (-not $parentProcess.WaitForExit(600000)) {
1675                throw 'timed out waiting for the current rpi process to exit'
1676            }
1677        } finally {
1678            $parentProcess.Dispose()
1679        }
1680    }
1681
1682    Assert-RealDirectory -Path $targetDirectory -Label 'target directory'
1683    Assert-WindowsExecutable -Path $stagedFull -Label 'staged executable'
1684    Assert-WindowsExecutable -Path $targetFull -Label 'current executable'
1685    if ((Get-FileSha256 -Path $stagedFull) -ne $expectedHash) {
1686        throw 'staged executable changed while waiting for rpi to exit'
1687    }
1688    if ((Get-FileSha256 -Path $targetFull) -ne $originalTargetHash) {
1689        throw 'current executable changed while waiting for rpi to exit'
1690    }
1691    if ([IO.File]::Exists($tempFull) -or [IO.File]::Exists($backupFull) -or
1692        [IO.File]::Exists($rollbackDisplacedFull)) {
1693        throw 'replacement, backup, or rollback path appeared while waiting for rpi to exit'
1694    }
1695
1696    [IO.File]::Copy($stagedFull, $tempFull, $false)
1697    Assert-WindowsExecutable -Path $tempFull -Label 'replacement executable'
1698    if ((Get-FileSha256 -Path $tempFull) -ne $expectedHash) {
1699        throw 'replacement executable hash mismatch'
1700    }
1701
1702    [IO.File]::Replace($tempFull, $targetFull, $backupFull, $true)
1703    $replaced = $true
1704    Assert-WindowsExecutable -Path $targetFull -Label 'updated executable'
1705    if ((Get-FileSha256 -Path $targetFull) -ne $expectedHash) {
1706        throw 'updated executable hash mismatch'
1707    }
1708} catch {
1709    $failureMessage = $_.Exception.Message
1710    $recoveryMessage = ''
1711    if ($replaced -and [IO.File]::Exists($BackupPath)) {
1712        try {
1713            [IO.File]::Copy($BackupPath, $TempPath, $false)
1714            Assert-WindowsExecutable -Path $TempPath -Label 'rollback executable'
1715            if ((Get-FileSha256 -Path $TempPath) -ne $originalTargetHash) {
1716                throw 'rollback executable hash mismatch'
1717            }
1718            [IO.File]::Replace($TempPath, $TargetPath, $RollbackDisplacedPath, $true)
1719            Assert-WindowsExecutable -Path $TargetPath -Label 'restored executable'
1720            if ((Get-FileSha256 -Path $TargetPath) -ne $originalTargetHash) {
1721                throw 'restored executable hash mismatch'
1722            }
1723            $replaced = $false
1724            try { [IO.File]::Delete($BackupPath) } catch {}
1725            try { [IO.File]::Delete($RollbackDisplacedPath) } catch {}
1726        } catch {
1727            $rollbackFailure = $_.Exception.Message
1728            if ([IO.File]::Exists($BackupPath)) {
1729                $recoveryMessage = "; automatic rollback failed ($rollbackFailure); the original executable remains at $BackupPath"
1730            } else {
1731                $recoveryMessage = "; automatic rollback failed ($rollbackFailure) and no verified recovery backup remains"
1732            }
1733        }
1734    }
1735    if ([IO.File]::Exists($TempPath)) {
1736        try { Remove-Item -LiteralPath $TempPath -Force } catch {}
1737    }
1738    try { Write-UpdateStatus -State 'failed' -Message ($failureMessage + $recoveryMessage) } catch {}
1739    Remove-StagingPayload
1740    exit 1
1741}
1742
1743try { Write-UpdateStatus -State 'succeeded' -Message 'rpi executable replaced successfully' } catch {}
1744if ([IO.File]::Exists($BackupPath)) {
1745    try { Remove-Item -LiteralPath $BackupPath -Force } catch {}
1746}
1747Remove-StagingPayload
1748exit 0
1749"#;
1750
1751#[cfg(windows)]
1752fn run_windows_self_update(build: bool, version: Option<&str>) -> i32 {
1753    let staging = match create_windows_update_staging() {
1754        Ok(staging) => staging,
1755        Err(error) => {
1756            eprintln!("error: could not create self-update staging directory: {error}");
1757            return 1;
1758        }
1759    };
1760    let staging_dir = staging.directory.path().to_path_buf();
1761    let status_file = staging.status_file.clone();
1762    let _ = write_windows_update_status(
1763        &status_file,
1764        "preparing",
1765        "installing the update into staging",
1766    );
1767
1768    let current_exe = match std::env::current_exe() {
1769        Ok(path) => path,
1770        Err(error) => {
1771            let message = format!("could not resolve the current rpi executable: {error}");
1772            let _ = write_windows_update_status(&status_file, "failed", &message);
1773            eprintln!("error: {message}");
1774            return 1;
1775        }
1776    };
1777    let powershell = match system_powershell_path() {
1778        Ok(path) => path,
1779        Err(error) => {
1780            let _ = write_windows_update_status(&status_file, "failed", &error);
1781            eprintln!("error: {error}");
1782            return 1;
1783        }
1784    };
1785
1786    if let Err(error) = prepare_self_update(&staging_dir, build, version) {
1787        let _ = write_windows_update_status(&status_file, "failed", &error);
1788        eprintln!("error: {error}");
1789        return 1;
1790    }
1791
1792    let plan = match build_windows_update_plan(&staging_dir, &current_exe, &status_file) {
1793        Ok(plan) => plan,
1794        Err(error) => {
1795            let _ = write_windows_update_status(&status_file, "failed", &error);
1796            eprintln!("error: {error}");
1797            return 1;
1798        }
1799    };
1800    let staged_version = match validate_staged_rpi(&plan) {
1801        Ok(version) => version,
1802        Err(error) => {
1803            let _ = write_windows_update_status(&plan.status_file, "failed", &error);
1804            eprintln!("error: {error}");
1805            return 1;
1806        }
1807    };
1808    if let Err(error) = preflight_windows_replacement(&plan) {
1809        let _ = write_windows_update_status(&plan.status_file, "failed", &error);
1810        eprintln!("error: {error}");
1811        return 1;
1812    }
1813    if let Err(error) = std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER.as_bytes()) {
1814        let message = format!("could not write the self-update helper: {error}");
1815        let _ = write_windows_update_status(&plan.status_file, "failed", &message);
1816        eprintln!("error: {message}");
1817        return 1;
1818    }
1819    if validated_regular_file(&plan.helper_script, "self-update helper").is_err() {
1820        let message = "self-update helper did not pass regular-file validation";
1821        let _ = write_windows_update_status(&plan.status_file, "failed", message);
1822        eprintln!("error: {message}");
1823        return 1;
1824    }
1825    let _ = write_windows_update_status(
1826        &plan.status_file,
1827        "scheduled",
1828        &format!("validated rpi {staged_version}; waiting to replace rpi after this process exits"),
1829    );
1830    let mut command = windows_update_helper_command(
1831        &powershell,
1832        &plan,
1833        std::process::id(),
1834        &staged_version.to_string(),
1835    );
1836    use std::os::windows::process::CommandExt;
1837    command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
1838    match command.spawn() {
1839        Ok(_) => {
1840            let persisted_staging = staging.directory.keep();
1841            println!(
1842                "rpi {staged_version} staged; quit this rpi and it will be applied.\n\
1843                 Status: {}",
1844                plan.status_file.display()
1845            );
1846            debug_assert!(windows_paths_equal(&persisted_staging, &plan.staging_dir));
1847            0
1848        }
1849        Err(error) => {
1850            let message = format!("could not start the self-update helper: {error}");
1851            let _ = write_windows_update_status(&plan.status_file, "failed", &message);
1852            eprintln!("error: {message}");
1853            1
1854        }
1855    }
1856}
1857
1858#[cfg(windows)]
1859fn create_windows_update_staging() -> Result<WindowsUpdateStaging, String> {
1860    let staging = create_self_update_staging_directory()?;
1861    let update_root = staging
1862        .path()
1863        .parent()
1864        .ok_or_else(|| "self-update staging directory has no parent".to_string())?;
1865    let status_file = update_root.join(format!("status-{}.json", uuid::Uuid::new_v4().simple()));
1866    if status_file.exists() {
1867        return Err("self-update status path collision".to_string());
1868    }
1869    Ok(WindowsUpdateStaging {
1870        directory: staging,
1871        status_file,
1872    })
1873}
1874
1875#[cfg(windows)]
1876fn build_windows_update_plan(
1877    staging_dir: &Path,
1878    target_exe: &Path,
1879    status_file: &Path,
1880) -> Result<WindowsUpdatePlan, String> {
1881    let staging_dir = validated_real_directory(staging_dir, "self-update staging directory")?;
1882    if !status_file.is_absolute() {
1883        return Err("self-update status path must be absolute".to_string());
1884    }
1885    let status_parent = validated_real_directory(
1886        status_file
1887            .parent()
1888            .ok_or_else(|| "self-update status path has no parent".to_string())?,
1889        "self-update status directory",
1890    )?;
1891    if staging_dir.parent() != Some(status_parent.as_path()) {
1892        return Err("self-update status file is outside the staging parent".to_string());
1893    }
1894    let status_name = status_file
1895        .file_name()
1896        .ok_or_else(|| "self-update status file has no name".to_string())?;
1897    if !is_self_update_status_name(status_name) {
1898        return Err("self-update status file has an invalid name".to_string());
1899    }
1900    if status_file.exists() {
1901        validated_regular_file(status_file, "self-update status file")?;
1902    }
1903    let staged_exe = validated_windows_executable(
1904        &staging_dir.join("bin").join("rpi.exe"),
1905        "staged rpi executable",
1906    )?;
1907    let target_exe = validated_windows_executable(target_exe, "current rpi executable")?;
1908    let target_dir = validated_real_directory(
1909        target_exe
1910            .parent()
1911            .ok_or_else(|| "current executable has no parent directory".to_string())?,
1912        "current executable directory",
1913    )?;
1914    if target_exe.parent() != Some(target_dir.as_path()) {
1915        return Err("current executable is not a direct child of its validated directory".into());
1916    }
1917    let token = uuid::Uuid::new_v4().simple().to_string();
1918    let replacement_temp = target_dir.join(format!(".rpi-update-{token}.new.exe"));
1919    let backup_file = target_dir.join(format!(".rpi-update-{token}.old.exe"));
1920    let rollback_displaced_file = target_dir.join(format!(".rpi-update-{token}.rollback-new.exe"));
1921    if replacement_temp.exists() || backup_file.exists() || rollback_displaced_file.exists() {
1922        return Err("self-update replacement path collision".to_string());
1923    }
1924
1925    Ok(WindowsUpdatePlan {
1926        staging_dir: staging_dir.clone(),
1927        staged_exe,
1928        target_exe,
1929        helper_script: staging_dir.join("apply-update.ps1"),
1930        status_file: status_file.to_path_buf(),
1931        replacement_temp,
1932        backup_file,
1933        rollback_displaced_file,
1934    })
1935}
1936
1937#[cfg(windows)]
1938fn preflight_windows_replacement(plan: &WindowsUpdatePlan) -> Result<(), String> {
1939    for path in [
1940        &plan.replacement_temp,
1941        &plan.backup_file,
1942        &plan.rollback_displaced_file,
1943    ] {
1944        if path.exists() {
1945            return Err(format!(
1946                "self-update path already exists: {}",
1947                path.display()
1948            ));
1949        }
1950        let file = std::fs::OpenOptions::new()
1951            .write(true)
1952            .create_new(true)
1953            .open(path)
1954            .map_err(|error| format!("cannot write beside the current executable: {error}"))?;
1955        drop(file);
1956        std::fs::remove_file(path)
1957            .map_err(|error| format!("could not remove self-update probe: {error}"))?;
1958    }
1959    Ok(())
1960}
1961
1962#[cfg(windows)]
1963fn validate_staged_rpi(plan: &WindowsUpdatePlan) -> Result<semver::Version, String> {
1964    let cwd = plan
1965        .helper_script
1966        .parent()
1967        .ok_or_else(|| "self-update staging directory is missing".to_string())?;
1968    validate_rpi_executable_version(&plan.staged_exe, cwd, crate::VERSION, "staged rpi")
1969}
1970
1971/// Captured output of a bounded self-update probe.
1972struct BoundedProcessOutput {
1973    status: std::process::ExitStatus,
1974    stdout: Vec<u8>,
1975    stderr: Vec<u8>,
1976}
1977
1978/// Run a short-lived probe under both a wall-clock and an output cap.
1979///
1980/// The child runs on its own thread and current-thread runtime so this stays
1981/// safe when `rpi update` is entered from inside a Tokio runtime. The old
1982/// implementation reused the npm bridge's process runner, which went away with
1983/// the Pi compatibility layer.
1984fn run_bounded_process_blocking(
1985    program: &str,
1986    args: &[String],
1987    cwd: &Path,
1988    timeout: std::time::Duration,
1989    max_output_bytes: usize,
1990) -> Result<BoundedProcessOutput, String> {
1991    let program = program.to_string();
1992    let args = args.to_vec();
1993    let cwd = cwd.to_path_buf();
1994    std::thread::Builder::new()
1995        .name("rpi-bounded-version-probe".to_string())
1996        .spawn(move || {
1997            let runtime = tokio::runtime::Builder::new_current_thread()
1998                .enable_all()
1999                .build()
2000                .map_err(|error| format!("probe runtime failed: {error}"))?;
2001            runtime.block_on(async move {
2002                let child = tokio::process::Command::new(&program)
2003                    .args(&args)
2004                    .current_dir(&cwd)
2005                    .stdin(std::process::Stdio::null())
2006                    .stdout(std::process::Stdio::piped())
2007                    .stderr(std::process::Stdio::piped())
2008                    .kill_on_drop(true)
2009                    .spawn()
2010                    .map_err(|error| format!("spawn failed: {error}"))?;
2011                let output = match tokio::time::timeout(timeout, child.wait_with_output()).await {
2012                    Ok(result) => result.map_err(|error| format!("wait failed: {error}"))?,
2013                    // Dropping the future drops the child, which `kill_on_drop`
2014                    // turns into a kill.
2015                    Err(_) => return Err(format!("timed out after {timeout:?}")),
2016                };
2017                Ok(BoundedProcessOutput {
2018                    status: output.status,
2019                    stdout: cap_bytes(output.stdout, max_output_bytes),
2020                    stderr: cap_bytes(output.stderr, max_output_bytes),
2021                })
2022            })
2023        })
2024        .map_err(|error| format!("probe thread failed: {error}"))?
2025        .join()
2026        .map_err(|_| "probe worker panicked".to_string())?
2027}
2028
2029fn cap_bytes(bytes: Vec<u8>, limit: usize) -> Vec<u8> {
2030    if bytes.len() <= limit {
2031        bytes
2032    } else {
2033        bytes[..limit].to_vec()
2034    }
2035}
2036
2037/// Byte-for-byte comparison, used to prove a copied or replaced rpi binary is
2038/// the one that was validated.
2039#[cfg(not(windows))]
2040fn files_are_identical(left: &Path, right: &Path) -> Result<bool, String> {
2041    use std::io::{BufReader, Read};
2042
2043    let left_file = std::fs::File::open(left)
2044        .map_err(|error| format!("could not open {}: {error}", left.display()))?;
2045    let right_file = std::fs::File::open(right)
2046        .map_err(|error| format!("could not open {}: {error}", right.display()))?;
2047    if left_file
2048        .metadata()
2049        .map_err(|error| error.to_string())?
2050        .len()
2051        != right_file
2052            .metadata()
2053            .map_err(|error| error.to_string())?
2054            .len()
2055    {
2056        return Ok(false);
2057    }
2058    let mut left = BufReader::new(left_file);
2059    let mut right = BufReader::new(right_file);
2060    let mut left_buffer = [0u8; 64 * 1024];
2061    let mut right_buffer = [0u8; 64 * 1024];
2062    loop {
2063        let left_read = left
2064            .read(&mut left_buffer)
2065            .map_err(|error| error.to_string())?;
2066        let right_read = right
2067            .read(&mut right_buffer)
2068            .map_err(|error| error.to_string())?;
2069        if left_read != right_read || left_buffer[..left_read] != right_buffer[..right_read] {
2070            return Ok(false);
2071        }
2072        if left_read == 0 {
2073            return Ok(true);
2074        }
2075    }
2076}
2077
2078fn validate_rpi_executable_version(
2079    executable: &Path,
2080    cwd: &Path,
2081    current: &str,
2082    label: &str,
2083) -> Result<semver::Version, String> {
2084    let program = executable
2085        .to_str()
2086        .ok_or_else(|| format!("{label} path cannot be represented as Unicode"))?;
2087    let args = vec!["--version".to_string()];
2088    let output = run_bounded_process_blocking(
2089        program,
2090        &args,
2091        cwd,
2092        STAGED_RPI_CHECK_TIMEOUT,
2093        MAX_STAGED_RPI_OUTPUT_BYTES,
2094    )
2095    .map_err(|error| format!("{label} version check failed: {error}"))?;
2096    if !output.status.success() {
2097        return Err(format!("{label} --version exited with {}", output.status));
2098    }
2099    validate_staged_rpi_version_output(&output.stdout, &output.stderr, current)
2100}
2101
2102fn validate_staged_rpi_version_output(
2103    stdout: &[u8],
2104    stderr: &[u8],
2105    current: &str,
2106) -> Result<semver::Version, String> {
2107    if !stderr.is_empty() {
2108        return Err("staged rpi --version wrote unexpected stderr output".to_string());
2109    }
2110    let staged = parse_rpi_version_output(stdout)
2111        .ok_or_else(|| "staged rpi returned an invalid --version response".to_string())?;
2112    let current = semver::Version::parse(current)
2113        .map_err(|_| "the running rpi version is not valid semantic versioning".to_string())?;
2114    if staged < current {
2115        return Err(format!(
2116            "refusing to replace rpi {current} with older version {staged}"
2117        ));
2118    }
2119    Ok(staged)
2120}
2121
2122fn parse_rpi_version_output(output: &[u8]) -> Option<semver::Version> {
2123    let output = std::str::from_utf8(output).ok()?;
2124    let line = output
2125        .strip_suffix("\r\n")
2126        .or_else(|| output.strip_suffix('\n'))
2127        .unwrap_or(output);
2128    if line.contains(['\r', '\n']) {
2129        return None;
2130    }
2131    let version = line.strip_prefix(crate::APP_NAME)?.strip_prefix(' ')?;
2132    if version.is_empty() || version.chars().any(char::is_whitespace) {
2133        return None;
2134    }
2135    semver::Version::parse(version).ok()
2136}
2137
2138/// A real directory *by its own entry*: the leaf is not a link, so the update
2139/// is free to write into it. Links further up are resolved rather than
2140/// refused — a launch path such as `C:\tools\current\rpi.exe` where `current`
2141/// is a junction names the real install directory, which is exactly what an
2142/// update should rewrite.
2143fn validated_real_directory(path: &Path, label: &str) -> Result<PathBuf, String> {
2144    if !path.is_absolute() {
2145        return Err(format!("{label} must be absolute"));
2146    }
2147    let metadata = std::fs::symlink_metadata(path)
2148        .map_err(|error| format!("could not inspect {label}: {error}"))?;
2149    if metadata.file_type().is_symlink() {
2150        return Err(format!(
2151            "{label} is a link or junction, not a real directory ({} -> {})",
2152            path.display(),
2153            canonical_display(path),
2154        ));
2155    }
2156    if !metadata.is_dir() {
2157        return Err(format!("{label} is not a real directory"));
2158    }
2159    std::fs::canonicalize(path)
2160        .map(normalize_windows_path)
2161        .map_err(|error| format!("could not canonicalize {label}: {error}"))
2162}
2163
2164/// A non-empty regular file *by its own entry*: a link to a file is refused
2165/// with both paths named, because replacing the link would leave the real
2166/// target stale. Ancestor links are resolved, as in
2167/// [`validated_real_directory`].
2168fn validated_regular_file(path: &Path, label: &str) -> Result<PathBuf, String> {
2169    if !path.is_absolute() {
2170        return Err(format!("{label} must be absolute"));
2171    }
2172    let metadata = std::fs::symlink_metadata(path)
2173        .map_err(|error| format!("could not inspect {label}: {error}"))?;
2174    if metadata.file_type().is_symlink() {
2175        return Err(format!(
2176            "{label} is a link or junction, not a non-empty regular file ({} -> {})",
2177            path.display(),
2178            canonical_display(path),
2179        ));
2180    }
2181    if !metadata.is_file() || metadata.len() == 0 {
2182        return Err(format!("{label} is not a non-empty regular file"));
2183    }
2184    std::fs::canonicalize(path)
2185        .map(normalize_windows_path)
2186        .map_err(|error| format!("could not canonicalize {label}: {error}"))
2187}
2188
2189fn is_self_update_status_name(name: &std::ffi::OsStr) -> bool {
2190    let Some(name) = name.to_str() else {
2191        return false;
2192    };
2193    let Some(token) = name
2194        .strip_prefix("status-")
2195        .and_then(|name| name.strip_suffix(".json"))
2196    else {
2197        return false;
2198    };
2199    token.len() == 32 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
2200}
2201
2202/// `tempfile::Builder::new().prefix("pending-")` mints the rest of the name
2203/// from `[A-Za-z0-9]`. Requiring that shape keeps the pruning from ever
2204/// pointing `remove_dir_all` at a directory this tool did not create.
2205fn is_self_update_staging_name(name: &str) -> bool {
2206    let Some(suffix) = name.strip_prefix("pending-") else {
2207        return false;
2208    };
2209    !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_alphanumeric())
2210}
2211
2212fn validated_self_update_status_file(update_root: &Path, path: &Path) -> Result<PathBuf, String> {
2213    if !path.is_absolute() || path.parent() != Some(update_root) {
2214        return Err("self-update status file is outside the update root".to_string());
2215    }
2216    if !path.file_name().is_some_and(is_self_update_status_name) {
2217        return Err("self-update status file has an invalid name".to_string());
2218    }
2219    let path = validated_regular_file(path, "self-update status file")?;
2220    let length = std::fs::metadata(&path)
2221        .map_err(|error| format!("could not inspect self-update status file: {error}"))?
2222        .len();
2223    if length > MAX_SELF_UPDATE_STATUS_BYTES {
2224        return Err("self-update status file is too large".to_string());
2225    }
2226    Ok(path)
2227}
2228
2229fn read_self_update_status(path: &Path) -> Option<SelfUpdateStatus> {
2230    use std::io::Read;
2231
2232    let file = std::fs::File::open(path).ok()?;
2233    let mut data = Vec::with_capacity(
2234        usize::try_from(MAX_SELF_UPDATE_STATUS_BYTES)
2235            .unwrap_or_default()
2236            .min(8 * 1024),
2237    );
2238    file.take(MAX_SELF_UPDATE_STATUS_BYTES.saturating_add(1))
2239        .read_to_end(&mut data)
2240        .ok()?;
2241    if data.len() as u64 > MAX_SELF_UPDATE_STATUS_BYTES {
2242        return None;
2243    }
2244    serde_json::from_slice(&data).ok()
2245}
2246
2247fn sanitized_self_update_status_message(message: &str) -> String {
2248    let mut sanitized = String::new();
2249    let mut pending_space = false;
2250    let mut truncated = false;
2251    for character in message.trim().chars() {
2252        if character.is_control() || character.is_whitespace() {
2253            pending_space = !sanitized.is_empty();
2254            continue;
2255        }
2256        if sanitized.chars().count() >= MAX_SELF_UPDATE_STATUS_MESSAGE_CHARS {
2257            truncated = true;
2258            break;
2259        }
2260        if pending_space {
2261            sanitized.push(' ');
2262            pending_space = false;
2263        }
2264        sanitized.push(character);
2265    }
2266    if sanitized.is_empty() {
2267        return "no diagnostic message was recorded".to_string();
2268    }
2269    if truncated {
2270        sanitized.push_str("...");
2271    }
2272    sanitized
2273}
2274
2275fn consume_self_update_statuses(agent_dir: &Path) -> Vec<UpdateWarning> {
2276    let _guard = SELF_UPDATE_STATUS_LOCK
2277        .lock()
2278        .unwrap_or_else(std::sync::PoisonError::into_inner);
2279    let agent_dir = match validated_real_directory(agent_dir, "agent directory") {
2280        Ok(path) => path,
2281        Err(_) => return Vec::new(),
2282    };
2283    let update_root = match validated_real_directory(
2284        &agent_dir.join("self-update"),
2285        "self-update status directory",
2286    ) {
2287        Ok(path) if path.parent() == Some(agent_dir.as_path()) => path,
2288        _ => return Vec::new(),
2289    };
2290    let entries = match std::fs::read_dir(&update_root) {
2291        Ok(entries) => entries,
2292        Err(_) => return Vec::new(),
2293    };
2294
2295    let now = now_ms();
2296    let mut warnings = Vec::new();
2297    // Every staging directory a live status file still claims.
2298    let mut claimed = BTreeSet::new();
2299    // Staging directories a status file proved to be debris: the helper that
2300    // would have installed from them is gone, so their on-disk age — which can
2301    // look recent when a run died early — does not have to vouch for them.
2302    let mut obsolete = BTreeSet::new();
2303    // Non-terminal records young enough that the writer is probably still
2304    // alive. Reconciled after the scan, once a terminal record could have
2305    // claimed the same directory.
2306    let mut undecided = Vec::new();
2307    for entry in entries.flatten() {
2308        if !is_self_update_status_name(&entry.file_name()) {
2309            continue;
2310        }
2311        let path = match validated_self_update_status_file(&update_root, &entry.path()) {
2312            Ok(path) => path,
2313            Err(_) => continue,
2314        };
2315        let Some(status) = read_self_update_status(&path) else {
2316            continue;
2317        };
2318        if !matches!(status.state.as_str(), "failed" | "succeeded") {
2319            // A record with no usable timestamp has an unknown age. Leaving it
2320            // alone is the safe reading: the alternative deletes a status file
2321            // that a live helper may still be writing.
2322            let stale = status.updated_at_ms > 0
2323                && now.saturating_sub(status.updated_at_ms) > STALE_SELF_UPDATE_STATUS_TTL_MS;
2324            if stale {
2325                if let Some(staging) = status.staging.as_deref() {
2326                    obsolete.insert(staging.to_path_buf());
2327                }
2328                let _ = remove_file_if_exists(&path);
2329            } else {
2330                undecided.push((path, status));
2331            }
2332            continue;
2333        }
2334        if let Some(staging) = status.staging.as_deref() {
2335            claimed.insert(staging.to_path_buf());
2336        }
2337
2338        if status.state == "succeeded" {
2339            // Kept as the durable record of what was installed. The path is
2340            // printed by `rpi update`, so deleting the file on the next start
2341            // made a finished update look like it had never run. It ages out
2342            // with the rest of the record set instead.
2343            let old = status.updated_at_ms > 0
2344                && now.saturating_sub(status.updated_at_ms) > COMPLETED_SELF_UPDATE_STATUS_TTL_MS;
2345            if old {
2346                if let Some(staging) = status.staging.as_deref() {
2347                    claimed.remove(staging);
2348                    obsolete.insert(staging.to_path_buf());
2349                }
2350                let _ = remove_file_if_exists(&path);
2351            }
2352            continue;
2353        }
2354
2355        match std::fs::remove_file(&path) {
2356            Ok(()) => {}
2357            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2358            // A failure is still actionable even when cleanup is denied. Keep
2359            // the file so a later startup can retry consumption.
2360            Err(_) => {}
2361        }
2362        warnings.push(UpdateWarning {
2363            message: format!(
2364                "The previously scheduled rpi update failed: {}",
2365                sanitized_self_update_status_message(&status.message)
2366            ),
2367            command: "rpi update".to_string(),
2368        });
2369    }
2370
2371    // A non-terminal record whose staging directory a terminal record already
2372    // owns is a leftover of a completed run, not a live one.
2373    for (path, status) in undecided {
2374        let staging = status.staging.clone();
2375        if staging
2376            .as_deref()
2377            .is_some_and(|staging| claimed.contains(staging))
2378        {
2379            let _ = remove_file_if_exists(&path);
2380            continue;
2381        }
2382        if let Some(staging) = staging {
2383            claimed.insert(staging);
2384        }
2385    }
2386
2387    prune_self_update_staging(&update_root, &claimed, &obsolete, now);
2388    warnings
2389}
2390
2391/// Delete staging directories no live status file references.
2392///
2393/// A Windows update persists its staging directory (`TempDir::keep`) so the
2394/// helper can install from it after the parent exits. When the helper dies
2395/// first — a reboot at the wrong moment, a machine-wide `taskkill` — the
2396/// directory stays behind forever: nothing else in the update path removes it,
2397/// and because its status file was still `preparing` the next start never
2398/// looked at it either. Those are the directories that pile up in
2399/// `~/.rpi/agent/self-update`.
2400///
2401/// A directory a status file already proved dead (`obsolete`) goes as soon as
2402/// its name validates. Otherwise the evidence has to come from the directory
2403/// itself: empty debris goes unconditionally, because no update can be
2404/// mid-install in a directory with nothing in it, while a populated one waits
2405/// for the TTL. That keeps a staging directory whose record was just consumed —
2406/// and which a helper may still be reading — from being deleted underneath it.
2407fn prune_self_update_staging(
2408    update_root: &Path,
2409    claimed: &BTreeSet<PathBuf>,
2410    obsolete: &BTreeSet<PathBuf>,
2411    now: i64,
2412) {
2413    let Ok(entries) = std::fs::read_dir(update_root) else {
2414        return;
2415    };
2416    for entry in entries.flatten() {
2417        let name = entry.file_name();
2418        let Some(name) = name.to_str() else {
2419            continue;
2420        };
2421        if !is_self_update_staging_name(name) {
2422            continue;
2423        }
2424        let path = update_root.join(name);
2425        if claimed.contains(&path) {
2426            continue;
2427        }
2428        if validated_real_directory(&path, "self-update staging directory").is_err() {
2429            continue;
2430        }
2431        let aged_out = directory_age_ms(
2432            entry.metadata().ok().and_then(|meta| meta.modified().ok()),
2433            now,
2434        )
2435        .is_some_and(|age| age >= STALE_SELF_UPDATE_STATUS_TTL_MS);
2436        if obsolete.contains(&path) || !staging_holds_payload(&path) || aged_out {
2437            let _ = std::fs::remove_dir_all(&path);
2438        }
2439    }
2440}
2441
2442/// Whether a staging directory still holds something an install could use.
2443///
2444/// `cargo install --root <dir>` leaves zero-byte `.crates.toml` / `.crates2.json`
2445/// bookkeeping files behind even when it produced no binary, and the helper's
2446/// own cleanup deletes the payload first. So the presence of *a* file proves
2447/// nothing — a directory whose only contents are empty files is debris that can
2448/// never be installed from, and waiting out the TTL for it just keeps garbage
2449/// around. Only a non-empty file does, and the payload lives one level down in
2450/// `bin/`.
2451fn staging_holds_payload(path: &Path) -> bool {
2452    let Ok(entries) = std::fs::read_dir(path) else {
2453        return false;
2454    };
2455    for entry in entries.flatten() {
2456        let Ok(metadata) = entry.metadata() else {
2457            continue;
2458        };
2459        if metadata.is_file() {
2460            if metadata.len() > 0 {
2461                return true;
2462            }
2463            continue;
2464        }
2465        if metadata.is_dir()
2466            && std::fs::read_dir(entry.path()).is_ok_and(|nested| {
2467                nested.flatten().any(|nested| {
2468                    nested
2469                        .metadata()
2470                        .is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
2471                })
2472            })
2473        {
2474            return true;
2475        }
2476    }
2477    false
2478}
2479
2480fn directory_age_ms(modified: Option<SystemTime>, now: i64) -> Option<i64> {
2481    let modified = modified?;
2482    let millis = modified.duration_since(UNIX_EPOCH).ok()?.as_millis();
2483    i64::try_from(millis).ok().map(|at| now.saturating_sub(at))
2484}
2485
2486#[cfg(windows)]
2487fn validated_windows_executable(path: &Path, label: &str) -> Result<PathBuf, String> {
2488    use std::io::Read;
2489
2490    let path = validated_regular_file(path, label)?;
2491    let mut file =
2492        std::fs::File::open(&path).map_err(|error| format!("could not open {label}: {error}"))?;
2493    let mut magic = [0u8; 2];
2494    file.read_exact(&mut magic)
2495        .map_err(|error| format!("could not read {label}: {error}"))?;
2496    if magic != *b"MZ" {
2497        return Err(format!("{label} is not a Windows executable"));
2498    }
2499    Ok(path)
2500}
2501
2502#[cfg(windows)]
2503fn system_powershell_path() -> Result<PathBuf, String> {
2504    use std::ffi::OsString;
2505    use std::os::windows::ffi::OsStringExt;
2506    use windows_sys::Win32::System::SystemInformation::GetWindowsDirectoryW;
2507
2508    let mut buffer = vec![0u16; 260];
2509    let system_root = loop {
2510        let length = unsafe {
2511            GetWindowsDirectoryW(
2512                buffer.as_mut_ptr(),
2513                u32::try_from(buffer.len()).unwrap_or(u32::MAX),
2514            )
2515        };
2516        if length == 0 {
2517            return Err(format!(
2518                "could not locate the Windows system directory: {}",
2519                std::io::Error::last_os_error()
2520            ));
2521        }
2522        let length = usize::try_from(length)
2523            .map_err(|error| format!("invalid Windows system directory length: {error}"))?;
2524        if length < buffer.len() {
2525            buffer.truncate(length);
2526            break PathBuf::from(OsString::from_wide(&buffer));
2527        }
2528        buffer.resize(length.saturating_add(1), 0);
2529    };
2530    let system_root = validated_real_directory(&system_root, "Windows system root")?;
2531    let powershell = validated_windows_executable(
2532        &system_root.join("System32/WindowsPowerShell/v1.0/powershell.exe"),
2533        "Windows PowerShell",
2534    )?;
2535    powershell
2536        .strip_prefix(&system_root)
2537        .map_err(|_| "Windows PowerShell is outside SystemRoot".to_string())?;
2538    Ok(powershell)
2539}
2540
2541#[cfg(windows)]
2542fn windows_update_helper_command(
2543    powershell: &Path,
2544    plan: &WindowsUpdatePlan,
2545    parent_pid: u32,
2546    staged_version: &str,
2547) -> std::process::Command {
2548    let mut command = std::process::Command::new(powershell);
2549    let powershell_modules = powershell
2550        .parent()
2551        .expect("validated PowerShell path has a parent")
2552        .join("Modules");
2553    command
2554        .args([
2555            "-NoLogo",
2556            "-NoProfile",
2557            "-NonInteractive",
2558            "-ExecutionPolicy",
2559            "Bypass",
2560            "-File",
2561        ])
2562        .arg(&plan.helper_script)
2563        .arg("-ParentPid")
2564        .arg(parent_pid.to_string())
2565        .arg("-StagingPath")
2566        .arg(&plan.staging_dir)
2567        .arg("-StagedPath")
2568        .arg(&plan.staged_exe)
2569        .arg("-StagedVersion")
2570        .arg(staged_version)
2571        .arg("-TargetPath")
2572        .arg(&plan.target_exe)
2573        .arg("-TempPath")
2574        .arg(&plan.replacement_temp)
2575        .arg("-BackupPath")
2576        .arg(&plan.backup_file)
2577        .arg("-RollbackDisplacedPath")
2578        .arg(&plan.rollback_displaced_file)
2579        .arg("-StatusPath")
2580        .arg(&plan.status_file)
2581        .current_dir(
2582            plan.status_file
2583                .parent()
2584                .expect("validated status path has an update-root parent"),
2585        )
2586        .env("PSModulePath", powershell_modules)
2587        .stdin(Stdio::null())
2588        .stdout(Stdio::null())
2589        .stderr(Stdio::null());
2590    command
2591}
2592
2593#[cfg(windows)]
2594fn write_windows_update_status(path: &Path, state: &str, message: &str) -> Result<(), String> {
2595    let data = serde_json::to_vec_pretty(&serde_json::json!({
2596        "state": state,
2597        "message": message,
2598        "parentPid": std::process::id(),
2599        "currentVersion": crate::VERSION,
2600        "updatedAtMs": now_ms(),
2601    }))
2602    .map_err(|error| error.to_string())?;
2603    std::fs::write(path, data)
2604        .map_err(|error| format!("could not write update status {}: {error}", path.display()))
2605}
2606
2607#[cfg(test)]
2608mod tests {
2609    #[test]
2610    fn prebuilt_update_notices_preserve_repository_and_separate_source_caches() {
2611        let _guard = crate::config::test_support::env_lock()
2612            .lock()
2613            .unwrap_or_else(|p| p.into_inner());
2614        let agent = tempfile::tempdir().unwrap();
2615        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2616        std::env::set_var(crate::config::CONFIG_DIR_ENV, agent.path());
2617        let record: crate::install::InstalledNativePackage = serde_json::from_value(serde_json::json!({
2618            "name":"demo", "version":"1.0.0", "source":"prebuilt:https://github.com/owner/repo", "artifacts":["custom.dll"],
2619            "prebuilt":{"repository":"https://github.com/owner/repo","target":"x86_64-pc-windows-msvc",
2620                "manifest_sha256":"a".repeat(64),"source_commit":"b".repeat(40),"sdk_version":"0.3.23","plugin_abi":4,
2621                "archive_sha256":"c".repeat(64),"library_sha256":"d".repeat(64),"custom_repository":true}
2622        })).unwrap();
2623        std::fs::write(
2624            agent.path().join("native-packages.json"),
2625            serde_json::to_vec(&[record.clone()]).unwrap(),
2626        )
2627        .unwrap();
2628        let mut cache = super::UpdateCache::default();
2629        cache.native_packages.insert("demo".into(), "99.0.0".into()); // old Cargo source cannot supply a prebuilt update
2630        assert!(super::report_from_cache(&cache).notices.is_empty());
2631        cache
2632            .native_packages
2633            .insert(super::native_cache_key(&record), "2.0.0".into());
2634        let report = super::report_from_cache(&cache);
2635        assert_eq!(report.notices.len(), 1);
2636        assert_eq!(report.notices[0].latest, "2.0.0");
2637        assert_eq!(
2638            report.notices[0].command,
2639            "rpi install demo --github https://github.com/owner/repo"
2640        );
2641        let mut other = record.clone();
2642        other.prebuilt.as_mut().unwrap().repository = "https://github.com/another/repo".into();
2643        assert_ne!(
2644            super::native_cache_key(&record),
2645            super::native_cache_key(&other)
2646        );
2647
2648        // A successful install updates the registry. A cached remote version
2649        // must no longer advertise an update after that version is installed.
2650        let mut updated = record.clone();
2651        updated.version = "2.0.0".into();
2652        std::fs::write(
2653            agent.path().join("native-packages.json"),
2654            serde_json::to_vec(&[updated]).unwrap(),
2655        )
2656        .unwrap();
2657        assert!(super::report_from_cache(&cache).notices.is_empty());
2658        other = record.clone();
2659        other.prebuilt.as_mut().unwrap().custom_repository = false;
2660        assert_ne!(
2661            super::native_cache_key(&record),
2662            super::native_cache_key(&other)
2663        );
2664        let mut cargo = record.clone();
2665        cargo.source = None;
2666        cargo.prebuilt = None;
2667        for (package, command) in [
2668            (other, "rpi install demo"),
2669            (cargo, "rpi install demo --build"),
2670        ] {
2671            cache
2672                .native_packages
2673                .insert(super::native_cache_key(&package), "2.0.0".into());
2674            std::fs::write(
2675                agent.path().join("native-packages.json"),
2676                serde_json::to_vec(&[package]).unwrap(),
2677            )
2678            .unwrap();
2679            let report = super::report_from_cache(&cache);
2680            assert_eq!(report.notices.len(), 1);
2681            assert_eq!(report.notices[0].command, command);
2682        }
2683    }
2684
2685    /// Fixtures may live under an ambient TEMP that is itself a junction
2686    /// (e.g. `D:\Temp` on some Windows hosts). Ancestor links are resolved by
2687    /// the path validation rather than refused, so a plain `tempdir()` is a
2688    /// valid fixture — and using one keeps these tests honest about that.
2689    fn real_tempdir() -> tempfile::TempDir {
2690        tempfile::Builder::new()
2691            .prefix("rpi-update-test-")
2692            .tempdir()
2693            .unwrap()
2694    }
2695
2696    /// The path a fixture resolves to once production validation has seen it.
2697    ///
2698    /// Every validated call site canonicalizes and normalizes a path before it
2699    /// stores or compares it, so a test asserting against a production-derived
2700    /// path has to resolve its own fixture the same way. `real_tempdir()` stays
2701    /// unresolved on purpose — that is what keeps these tests honest about an
2702    /// ambient temp root that is itself a link — but on macOS `/var` is a link
2703    /// to `/private/var`, so the raw fixture path and the resolved production
2704    /// path are different strings for the same directory. Comparing them
2705    /// directly fails for a reason that has nothing to do with the code under
2706    /// test, and does so only on hosts whose temp root is reached through a
2707    /// link, which is why a Linux-only CI never saw it.
2708    fn as_production_resolves(path: &Path) -> std::path::PathBuf {
2709        super::normalize_windows_path(std::fs::canonicalize(path).unwrap())
2710    }
2711
2712    use std::collections::BTreeMap;
2713    use std::ffi::OsString;
2714    use std::path::Path;
2715    use std::sync::atomic::{AtomicUsize, Ordering};
2716    use std::sync::Arc;
2717
2718    #[cfg(unix)]
2719    use super::{activate_direct_update, build_direct_update_plan};
2720    #[cfg(windows)]
2721    use super::{
2722        build_windows_update_plan, preflight_windows_replacement, system_powershell_path,
2723        windows_update_helper_command, write_windows_update_status, WINDOWS_UPDATE_HELPER,
2724    };
2725    use super::{
2726        cargo_home_is_project_local, cargo_install_command, collect_bounded,
2727        create_self_update_command_dir, fetch_crates_url, is_newer, parse_rpi_version_output,
2728        path_is_within, results_with_individual_cache_fallback, validate_staged_rpi_version_output,
2729        RegistryLookup, CACHE_FALLBACK_MAX_AGE_MS, UPDATE_CHECK_CONCURRENCY,
2730    };
2731
2732    struct RestoreConfigDir(Option<OsString>);
2733
2734    impl Drop for RestoreConfigDir {
2735        fn drop(&mut self) {
2736            match self.0.take() {
2737                Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2738                None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2739            }
2740        }
2741    }
2742
2743    struct RestoreEnv {
2744        name: &'static str,
2745        value: Option<OsString>,
2746    }
2747
2748    impl RestoreEnv {
2749        fn capture(name: &'static str) -> Self {
2750            Self {
2751                name,
2752                value: std::env::var_os(name),
2753            }
2754        }
2755    }
2756
2757    impl Drop for RestoreEnv {
2758        fn drop(&mut self) {
2759            match self.value.take() {
2760                Some(value) => std::env::set_var(self.name, value),
2761                None => std::env::remove_var(self.name),
2762            }
2763        }
2764    }
2765
2766    #[cfg(windows)]
2767    fn fake_windows_executable(path: &Path, marker: &[u8]) {
2768        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2769        let mut bytes = b"MZ".to_vec();
2770        bytes.extend_from_slice(marker);
2771        std::fs::write(path, bytes).unwrap();
2772    }
2773
2774    #[cfg(unix)]
2775    fn fake_unix_rpi(path: &Path, version: &str, fail_after_rename_to: Option<&str>) {
2776        use std::os::unix::fs::PermissionsExt;
2777
2778        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2779        let failure = fail_after_rename_to.map_or_else(String::new, |name| {
2780            format!(
2781                "case \"$0\" in\n  */{name}) echo 'post-replace failure' >&2; exit 1 ;;\nesac\n"
2782            )
2783        });
2784        std::fs::write(
2785            path,
2786            format!("#!/bin/sh\n{failure}printf 'rpi {version}\\n'\n"),
2787        )
2788        .unwrap();
2789        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2790    }
2791
2792    fn write_test_update_status_at(path: &Path, state: &str, message: &str, updated_at_ms: i64) {
2793        let mut status = serde_json::json!({
2794            "state": state,
2795            "message": message,
2796        });
2797        if updated_at_ms > 0 {
2798            status["updatedAtMs"] = serde_json::json!(updated_at_ms);
2799        }
2800        std::fs::write(path, serde_json::to_vec(&status).unwrap()).unwrap();
2801    }
2802
2803    fn write_test_update_status(path: &Path, state: &str, message: &str) {
2804        std::fs::write(
2805            path,
2806            serde_json::to_vec(&serde_json::json!({
2807                "state": state,
2808                "message": message,
2809            }))
2810            .unwrap(),
2811        )
2812        .unwrap();
2813    }
2814
2815    #[test]
2816    fn path_validation_resolves_ancestor_links_but_refuses_a_link_leaf() {
2817        let temp = real_tempdir();
2818        // Real targets, plus a link directory that points at one of them.
2819        let real_dir = temp.path().join("real-dir");
2820        let real_file = temp.path().join("real-file.exe");
2821        std::fs::create_dir_all(&real_dir).unwrap();
2822        std::fs::write(&real_file, b"payload").unwrap();
2823
2824        let link_dir = temp.path().join("link-dir");
2825        let link_file = temp.path().join("link-file.exe");
2826        #[cfg(unix)]
2827        let linked = {
2828            std::os::unix::fs::symlink(&real_dir, &link_dir).unwrap();
2829            std::os::unix::fs::symlink(&real_file, &link_file).unwrap();
2830            true
2831        };
2832        #[cfg(windows)]
2833        let linked = {
2834            // Junctions need no elevation; file symlinks do, so skip the file
2835            // half when this process cannot create one.
2836            let dir_ok = std::process::Command::new("cmd")
2837                .args(["/c", "mklink", "/J"])
2838                .arg(&link_dir)
2839                .arg(&real_dir)
2840                .output()
2841                .map(|out| out.status.success())
2842                .unwrap_or(false);
2843            if dir_ok {
2844                let file_ok = std::os::windows::fs::symlink_file(&real_file, &link_file).is_ok();
2845                if !file_ok {
2846                    eprintln!("skipping the file-link half: no symlink privilege");
2847                }
2848            }
2849            dir_ok
2850        };
2851        if !linked {
2852            eprintln!("skipping: this platform could not create the link fixture");
2853            return;
2854        }
2855
2856        // A file *below* a linked directory resolves: the link is an ancestor,
2857        // not the thing being written.
2858        let through_link = link_dir.join("nested").join("leaf.exe");
2859        std::fs::create_dir_all(through_link.parent().unwrap()).unwrap();
2860        std::fs::write(&through_link, b"payload").unwrap();
2861        super::validated_regular_file(&through_link, "file below a linked directory").unwrap();
2862        assert_eq!(
2863            std::fs::canonicalize(&link_dir).unwrap(),
2864            std::fs::canonicalize(&real_dir).unwrap(),
2865        );
2866
2867        // A link *leaf* is still refused, and the error names both paths.
2868        let error = super::validated_real_directory(&link_dir, "linked directory").unwrap_err();
2869        assert!(error.contains("link or junction"), "{error}");
2870        assert!(error.contains(&link_dir.display().to_string()), "{error}");
2871
2872        if link_file.exists() {
2873            let error = super::validated_regular_file(&link_file, "linked file").unwrap_err();
2874            assert!(error.contains("link or junction"), "{error}");
2875            assert!(error.contains(&link_file.display().to_string()), "{error}");
2876            assert!(error.contains(&real_file.display().to_string()), "{error}");
2877        }
2878
2879        // The link leaf check must not be a blanket refusal: the real targets
2880        // still validate.
2881        super::validated_real_directory(&real_dir, "real directory").unwrap();
2882        super::validated_regular_file(&real_file, "real file").unwrap();
2883    }
2884
2885    #[test]
2886    fn cargo_self_update_staging_is_passed_as_a_structured_argument() {
2887        let temp = real_tempdir();
2888        let staging = temp.path().join("staging");
2889        let command_dir = temp.path().join("command");
2890        std::fs::create_dir_all(&staging).unwrap();
2891        std::fs::create_dir_all(&command_dir).unwrap();
2892        let command = cargo_install_command(Some(&staging), &command_dir).unwrap();
2893        let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2894
2895        assert_eq!(command.get_program(), "cargo");
2896        assert_eq!(args.len(), 7);
2897        assert_eq!(args[0], "install");
2898        assert_eq!(args[1], "--root");
2899        assert_eq!(args[2], staging.as_os_str());
2900        assert_eq!(args[3], "rpi-cli");
2901        assert_eq!(args[4], "--locked");
2902        assert_eq!(args[5], "--force");
2903        // Suppresses cargo's "add this to your PATH" advice, which names the
2904        // staging root that the atomic replace is about to consume.
2905        assert_eq!(args[6], "--quiet");
2906        // The command runs in the resolved directory, not the unresolved
2907        // fixture path: `cargo_install_command` validates it first.
2908        assert_eq!(
2909            command.get_current_dir(),
2910            Some(as_production_resolves(&command_dir).as_path())
2911        );
2912    }
2913
2914    #[cfg(unix)]
2915    #[test]
2916    fn direct_update_replaces_the_selected_target_and_rejects_downgrades() {
2917        let temp = real_tempdir();
2918        let staging = temp.path().join("staging");
2919        let staged = staging.join("bin/rpi");
2920        let target = temp.path().join("target/rpi-current");
2921        fake_unix_rpi(&staged, "9.9.9", None);
2922        fake_unix_rpi(&target, crate::VERSION, None);
2923        let expected = std::fs::read(&staged).unwrap();
2924
2925        let plan = build_direct_update_plan(&staging, &target).unwrap();
2926        assert_eq!(activate_direct_update(&plan).unwrap().to_string(), "9.9.9");
2927        assert_eq!(std::fs::read(&target).unwrap(), expected);
2928        assert!(!plan.replacement_temp.exists());
2929        assert!(!plan.backup_file.exists());
2930
2931        fake_unix_rpi(&staged, "0.0.1", None);
2932        let plan = build_direct_update_plan(&staging, &target).unwrap();
2933        let error = activate_direct_update(&plan).unwrap_err();
2934        assert!(error.contains("older version"), "{error}");
2935        assert_eq!(std::fs::read(&target).unwrap(), expected);
2936    }
2937
2938    #[cfg(unix)]
2939    #[test]
2940    fn direct_update_rolls_back_when_post_replace_validation_fails() {
2941        let temp = real_tempdir();
2942        let staging = temp.path().join("staging");
2943        let staged = staging.join("bin/rpi");
2944        let target = temp.path().join("target/rpi-current");
2945        fake_unix_rpi(&staged, "9.9.9", Some("rpi-current"));
2946        fake_unix_rpi(&target, crate::VERSION, None);
2947        let original = std::fs::read(&target).unwrap();
2948
2949        let plan = build_direct_update_plan(&staging, &target).unwrap();
2950        let error = activate_direct_update(&plan).unwrap_err();
2951
2952        assert!(error.contains("restored the previous"), "{error}");
2953        assert_eq!(std::fs::read(&target).unwrap(), original);
2954        assert!(!plan.replacement_temp.exists());
2955        assert!(!plan.backup_file.exists());
2956    }
2957
2958    #[test]
2959    fn self_update_arguments_are_parsed_explicitly() {
2960        let args = |list: &[&str]| list.iter().map(|arg| arg.to_string()).collect::<Vec<_>>();
2961        assert_eq!(
2962            super::parse_self_update_args(&args(&["--build", "--force"])),
2963            super::SelfUpdateArgs::Run {
2964                force: true,
2965                build: true,
2966                channel: super::UpdateChannel::Stable,
2967            }
2968        );
2969        assert_eq!(
2970            super::parse_self_update_args(&args(&[])),
2971            super::SelfUpdateArgs::Run {
2972                force: false,
2973                build: false,
2974                channel: super::UpdateChannel::Stable,
2975            }
2976        );
2977        for flag in ["--force", "-f"] {
2978            assert_eq!(
2979                super::parse_self_update_args(&args(&[flag])),
2980                super::SelfUpdateArgs::Run {
2981                    force: true,
2982                    build: false,
2983                    channel: super::UpdateChannel::Stable,
2984                }
2985            );
2986        }
2987        assert_eq!(
2988            super::parse_self_update_args(&args(&["preview"])),
2989            super::SelfUpdateArgs::Run {
2990                force: false,
2991                build: false,
2992                channel: super::UpdateChannel::Preview,
2993            }
2994        );
2995        for flag in ["--help", "-h"] {
2996            assert_eq!(
2997                super::parse_self_update_args(&args(&[flag])),
2998                super::SelfUpdateArgs::Help
2999            );
3000        }
3001        // `--help` wins wherever it appears, and unknown flags are a usage
3002        // error rather than being silently ignored.
3003        assert_eq!(
3004            super::parse_self_update_args(&args(&["--force", "--help"])),
3005            super::SelfUpdateArgs::Help
3006        );
3007        assert_eq!(
3008            super::parse_self_update_args(&args(&["--nope"])),
3009            super::SelfUpdateArgs::Usage
3010        );
3011    }
3012
3013    #[test]
3014    fn default_self_update_requires_a_release_and_never_falls_back_to_cargo() {
3015        let staging = tempfile::tempdir().unwrap();
3016        let error = super::prepare_self_update(staging.path(), false, None).unwrap_err();
3017        assert!(error.contains("no verified release version"));
3018        assert_eq!(std::fs::read_dir(staging.path()).unwrap().count(), 0);
3019    }
3020
3021    #[test]
3022    fn self_update_honors_env_and_early_dispatched_offline_flag() {
3023        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3024        let _restore = RestoreEnv::capture(crate::args::RPI_OFFLINE_ENV);
3025
3026        std::env::set_var(crate::args::RPI_OFFLINE_ENV, "tRuE");
3027        assert_eq!(super::run_self_update(&[]), 0);
3028
3029        std::env::set_var(crate::args::RPI_OFFLINE_ENV, "0");
3030        assert_eq!(super::run_self_update(&["--offline".into()]), 0);
3031        assert_eq!(
3032            std::env::var(crate::args::RPI_OFFLINE_ENV).as_deref(),
3033            Ok("1")
3034        );
3035    }
3036
3037    #[tokio::test]
3038    async fn offline_skips_rpi_startup_checks_before_cache_io() {
3039        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3040        let _restore_config = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3041        let _restore_offline = RestoreEnv::capture(crate::args::RPI_OFFLINE_ENV);
3042        let _restore_disabled = RestoreEnv::capture("RPI_DISABLE_UPDATE_CHECK");
3043        let temp = real_tempdir();
3044        let agent = temp.path().join("offline-agent");
3045        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3046        std::env::set_var(crate::args::RPI_OFFLINE_ENV, "YES");
3047        std::env::remove_var("RPI_DISABLE_UPDATE_CHECK");
3048
3049        let report = super::check_rpi_startup().await;
3050
3051        assert_eq!(report, super::UpdateReport::default());
3052        assert!(
3053            !agent.exists(),
3054            "offline checks must not create the update cache directory"
3055        );
3056    }
3057
3058    #[test]
3059    fn completed_self_update_statuses_are_consumed_once() {
3060        let temp = real_tempdir();
3061        let agent = temp.path().join("agent");
3062        let update_root = agent.join("self-update");
3063        std::fs::create_dir_all(&update_root).unwrap();
3064        let failed = update_root.join("status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json");
3065        let succeeded = update_root.join("status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.json");
3066        let waiting = update_root.join("status-cccccccccccccccccccccccccccccccc.json");
3067        let temporary = update_root.join("status-dddddddddddddddddddddddddddddddd.json.tmp");
3068        let malformed = update_root.join("status-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.json");
3069        let invalid_name = update_root.join("status-not-a-uuid.json");
3070        write_test_update_status(&failed, "failed", "permission\u{1b}[31m\r\n denied\u{7}");
3071        write_test_update_status(&succeeded, "succeeded", "done");
3072        write_test_update_status(&waiting, "waiting", "still running");
3073        write_test_update_status(&temporary, "failed", "not published");
3074        std::fs::write(&malformed, b"{not-json").unwrap();
3075        write_test_update_status(&invalid_name, "failed", "invalid name");
3076
3077        let warnings = super::consume_self_update_statuses(&agent);
3078
3079        assert_eq!(warnings.len(), 1);
3080        assert!(warnings[0]
3081            .message
3082            .contains("rpi update failed: permission [31m denied"));
3083        assert!(!warnings[0].message.chars().any(char::is_control));
3084        assert_eq!(warnings[0].command, "rpi update");
3085        assert!(!failed.exists());
3086        // A `succeeded` record is the receipt for the version now installed —
3087        // `rpi update` prints its path — so it survives consumption instead of
3088        // being deleted the moment the next start looks at it.
3089        assert!(succeeded.exists());
3090        assert!(waiting.exists());
3091        assert!(temporary.exists());
3092        assert!(malformed.exists());
3093        assert!(invalid_name.exists());
3094        // The failure is reported once: the record is gone, the kept one is not
3095        // a failure, and the young `waiting` record says nothing.
3096        assert!(super::consume_self_update_statuses(&agent).is_empty());
3097        assert!(succeeded.exists());
3098    }
3099
3100    #[test]
3101    fn stale_and_orphaned_self_update_records_are_pruned() {
3102        let temp = real_tempdir();
3103        let agent = temp.path().join("agent");
3104        std::fs::create_dir_all(&agent).unwrap();
3105        // `consume_self_update_statuses` resolves the agent directory before it
3106        // derives any staging path from it, so the fixture starts from the same
3107        // resolved path. Otherwise a staging directory this test records in a
3108        // status file would never compare equal to the one pruning computes,
3109        // and the pruning under test would silently not run.
3110        let agent = as_production_resolves(&agent);
3111        let update_root = agent.join("self-update");
3112        std::fs::create_dir_all(&update_root).unwrap();
3113        let name =
3114            |token: char| update_root.join(format!("status-{}.json", token.to_string().repeat(32)));
3115        let now = super::now_ms();
3116        let stale_ms = now - super::STALE_SELF_UPDATE_STATUS_TTL_MS - 60_000;
3117
3118        // Live run: recent `waiting` record that owns its staging directory.
3119        let live_status = name('1');
3120        let live_staging = update_root.join("pending-Live001");
3121        std::fs::create_dir_all(live_staging.join("bin")).unwrap();
3122        std::fs::write(live_staging.join("bin").join("rpi.exe"), b"payload").unwrap();
3123        std::fs::write(
3124            &live_status,
3125            serde_json::to_vec(&serde_json::json!({
3126                "state": "waiting",
3127                "staging": live_staging,
3128                "updatedAtMs": now - 1000,
3129            }))
3130            .unwrap(),
3131        )
3132        .unwrap();
3133
3134        // Abandoned run: the helper died, so the record aged out and the
3135        // payload it claims is debris.
3136        let dead_status = name('2');
3137        let dead_staging = update_root.join("pending-Dead002");
3138        std::fs::create_dir_all(dead_staging.join("bin")).unwrap();
3139        std::fs::write(dead_staging.join("bin").join("rpi.exe"), b"payload").unwrap();
3140        write_test_update_status_at(&dead_status, "preparing", "installing", stale_ms);
3141        // Name the staging directory in the stale record, the way the Windows
3142        // helper does: that is the evidence its payload is debris even though
3143        // the directory's own timestamp looks recent.
3144        let mut dead =
3145            serde_json::from_slice::<serde_json::Value>(&std::fs::read(&dead_status).unwrap())
3146                .unwrap();
3147        dead["staging"] = serde_json::json!(dead_staging);
3148        std::fs::write(&dead_status, serde_json::to_vec(&dead).unwrap()).unwrap();
3149
3150        // Orphan: no status file ever named it, and it was created long ago.
3151        let orphan = update_root.join("pending-Orphan3");
3152        std::fs::create_dir_all(orphan.join("bin")).unwrap();
3153        std::fs::write(orphan.join("bin").join("rpi.exe"), b"payload").unwrap();
3154
3155        // Debris that can never be serving an update: an empty staging
3156        // directory, even while it is brand new.
3157        let empty = update_root.join("pending-Empty04");
3158        std::fs::create_dir(&empty).unwrap();
3159
3160        // Not ours to touch: the name is not a tempfile label.
3161        let foreign = update_root.join("pending-not-ours");
3162        std::fs::create_dir_all(&foreign).unwrap();
3163        std::fs::write(foreign.join("keep"), b"keep").unwrap();
3164
3165        let warnings = super::consume_self_update_statuses(&agent);
3166
3167        assert!(warnings.is_empty());
3168        assert!(live_status.exists(), "a live run's record must survive");
3169        assert!(live_staging.exists(), "a live run's staging must survive");
3170        assert!(!dead_status.exists());
3171        assert!(!dead_staging.exists());
3172        // Pruning only deletes a populated directory it can prove is old; an
3173        // untimestamped orphan is left for a later pass rather than guessed at.
3174        assert!(orphan.exists());
3175        // An empty directory cannot be serving an update, whatever its age.
3176        assert!(!empty.exists());
3177        assert!(foreign.exists());
3178    }
3179
3180    #[test]
3181    fn completed_records_survive_and_supersede_leftovers() {
3182        let temp = real_tempdir();
3183        let agent = temp.path().join("agent");
3184        let update_root = agent.join("self-update");
3185        std::fs::create_dir_all(&update_root).unwrap();
3186        let now = super::now_ms();
3187        let staging = update_root.join("pending-Done001");
3188        std::fs::create_dir_all(staging.join("bin")).unwrap();
3189        std::fs::write(staging.join("bin").join("rpi.exe"), b"payload").unwrap();
3190
3191        // A `waiting` leftover naming the same directory as a completed run.
3192        let leftover = update_root.join(format!("status-{}.json", "3".repeat(32)));
3193        std::fs::write(
3194            &leftover,
3195            serde_json::to_vec(&serde_json::json!({
3196                "state": "waiting",
3197                "staging": staging,
3198                "updatedAtMs": now,
3199            }))
3200            .unwrap(),
3201        )
3202        .unwrap();
3203
3204        let recent = update_root.join(format!("status-{}.json", "4".repeat(32)));
3205        std::fs::write(
3206            &recent,
3207            serde_json::to_vec(&serde_json::json!({
3208                "state": "succeeded",
3209                "message": "rpi executable replaced successfully",
3210                "staging": staging,
3211                "updatedAtMs": now,
3212            }))
3213            .unwrap(),
3214        )
3215        .unwrap();
3216
3217        let aged = update_root.join(format!("status-{}.json", "5".repeat(32)));
3218        std::fs::write(
3219            &aged,
3220            serde_json::to_vec(&serde_json::json!({
3221                "state": "succeeded",
3222                "message": "rpi executable replaced successfully",
3223                "updatedAtMs": now - super::COMPLETED_SELF_UPDATE_STATUS_TTL_MS - 60_000,
3224            }))
3225            .unwrap(),
3226        )
3227        .unwrap();
3228
3229        assert!(super::consume_self_update_statuses(&agent).is_empty());
3230
3231        // The receipt for the installed version is kept until it ages out.
3232        assert!(recent.exists());
3233        assert!(!aged.exists());
3234        // The completed record owns the staging directory, so the leftover
3235        // `waiting` record that names it is debris; the directory itself is
3236        // still referenced, so it stays.
3237        assert!(!leftover.exists());
3238        assert!(staging.exists());
3239    }
3240
3241    #[test]
3242    fn self_update_status_validation_rejects_escape_and_links() {
3243        let temp = real_tempdir();
3244        let agent = temp.path().join("agent");
3245        let update_root = agent.join("self-update");
3246        let outside_root = temp.path().join("outside");
3247        std::fs::create_dir_all(&update_root).unwrap();
3248        std::fs::create_dir_all(&outside_root).unwrap();
3249        let name = "status-ffffffffffffffffffffffffffffffff.json";
3250        let outside = outside_root.join(name);
3251        write_test_update_status(&outside, "failed", "outside");
3252
3253        assert!(super::validated_self_update_status_file(&update_root, &outside).is_err());
3254
3255        let linked = update_root.join(name);
3256        #[cfg(unix)]
3257        std::os::unix::fs::symlink(&outside, &linked).unwrap();
3258        #[cfg(windows)]
3259        let link_created = std::os::windows::fs::symlink_file(&outside, &linked).is_ok();
3260        #[cfg(unix)]
3261        let link_created = true;
3262        if link_created {
3263            assert!(super::validated_self_update_status_file(&update_root, &linked).is_err());
3264            assert!(super::consume_self_update_statuses(&agent).is_empty());
3265            assert!(outside.exists());
3266        }
3267    }
3268
3269    #[test]
3270    fn cargo_self_update_without_staging_never_inherits_the_callers_cwd() {
3271        let temp = real_tempdir();
3272        let command_dir = temp.path().join("isolated");
3273        std::fs::create_dir_all(&command_dir).unwrap();
3274
3275        let command = cargo_install_command(None, &command_dir).unwrap();
3276        let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
3277
3278        assert_eq!(
3279            args,
3280            ["install", "rpi-cli", "--locked", "--force", "--quiet"]
3281                .into_iter()
3282                .map(OsString::from)
3283                .collect::<Vec<_>>()
3284        );
3285        assert_eq!(
3286            command.get_current_dir(),
3287            Some(as_production_resolves(&command_dir).as_path())
3288        );
3289        assert_ne!(
3290            command.get_current_dir(),
3291            std::env::current_dir().ok().as_deref()
3292        );
3293        for variable in [
3294            "RUSTC_WRAPPER",
3295            "RUSTC_WORKSPACE_WRAPPER",
3296            "CARGO_BUILD_RUSTC_WRAPPER",
3297            "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
3298        ] {
3299            assert!(command
3300                .get_envs()
3301                .any(|(name, value)| name == variable && value.is_none()));
3302        }
3303        assert!(cargo_install_command(None, Path::new("relative-command-dir")).is_err());
3304
3305        let generated = create_self_update_command_dir().unwrap();
3306        let caller = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap();
3307        assert!(generated.path().is_absolute());
3308        assert!(!path_is_within(generated.path(), &caller));
3309    }
3310
3311    #[test]
3312    fn project_local_cargo_home_is_rejected_even_when_its_leaf_is_missing() {
3313        let temp = real_tempdir();
3314        let project = temp.path().join("project");
3315        let global = temp.path().join("global-cargo");
3316        std::fs::create_dir_all(&project).unwrap();
3317        std::fs::create_dir_all(&global).unwrap();
3318        let project = std::fs::canonicalize(project).unwrap();
3319
3320        assert!(cargo_home_is_project_local(&project.join(".cargo-home"), &project).unwrap());
3321        assert!(cargo_home_is_project_local(&project, &project).unwrap());
3322        assert!(!cargo_home_is_project_local(&global, &project).unwrap());
3323        assert!(cargo_home_is_project_local(Path::new("relative"), &project).is_err());
3324    }
3325
3326    #[cfg(windows)]
3327    #[test]
3328    fn windows_update_helper_atomically_replaces_a_valid_target() {
3329        let temp = real_tempdir();
3330        let staging = temp.path().join("staging 'semi;& with spaces");
3331        let staged = staging.join("bin/rpi.exe");
3332        let target = temp.path().join("target 'semi;& with spaces/rpi-copy.exe");
3333        fake_windows_executable(&staged, b"new-version");
3334        fake_windows_executable(&target, b"old-version");
3335        let expected = std::fs::read(&staged).unwrap();
3336        let plan = build_windows_update_plan(
3337            &staging,
3338            &target,
3339            &temp
3340                .path()
3341                .join("status-00000000000000000000000000000000.json"),
3342        )
3343        .unwrap();
3344        preflight_windows_replacement(&plan).unwrap();
3345        std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER).unwrap();
3346        write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
3347
3348        let powershell = system_powershell_path().unwrap();
3349        let mut command =
3350            windows_update_helper_command(&powershell, &plan, i32::MAX as u32, "9.9.9");
3351        let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
3352        for (flag, expected) in [
3353            ("-StagingPath", &plan.staging_dir),
3354            ("-StagedPath", &plan.staged_exe),
3355            ("-TargetPath", &plan.target_exe),
3356            ("-TempPath", &plan.replacement_temp),
3357            ("-BackupPath", &plan.backup_file),
3358            ("-RollbackDisplacedPath", &plan.rollback_displaced_file),
3359            ("-StatusPath", &plan.status_file),
3360        ] {
3361            let index = args.iter().position(|arg| arg == flag).unwrap();
3362            assert_eq!(args[index + 1], expected.as_os_str());
3363        }
3364        let version_index = args.iter().position(|arg| arg == "-StagedVersion").unwrap();
3365        assert_eq!(args[version_index + 1], "9.9.9");
3366        assert_eq!(command.get_current_dir(), plan.status_file.parent());
3367        assert!(command.get_envs().any(|(name, value)| {
3368            name == "PSModulePath"
3369                && value == Some(powershell.parent().unwrap().join("Modules").as_os_str())
3370        }));
3371        let status = command.status().unwrap();
3372
3373        let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
3374        assert!(
3375            status.success(),
3376            "helper exited with {status}: {status_text}"
3377        );
3378        assert_eq!(std::fs::read(&target).unwrap(), expected);
3379        assert!(!staging.exists());
3380        assert!(!plan.replacement_temp.exists());
3381        assert!(!plan.backup_file.exists());
3382        assert!(!plan.rollback_displaced_file.exists());
3383        let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
3384        assert_eq!(status["state"], "succeeded");
3385        assert_eq!(status["stagedVersion"], "9.9.9");
3386    }
3387
3388    #[cfg(windows)]
3389    #[test]
3390    fn windows_update_helper_failure_preserves_the_old_executable() {
3391        let temp = real_tempdir();
3392        let staging = temp.path().join("staging with spaces");
3393        let staged = staging.join("bin/rpi.exe");
3394        let target = temp.path().join("target with spaces/rpi-copy.exe");
3395        fake_windows_executable(&staged, b"new-version");
3396        fake_windows_executable(&target, b"old-version");
3397        let expected = std::fs::read(&target).unwrap();
3398        let plan = build_windows_update_plan(
3399            &staging,
3400            &target,
3401            &temp
3402                .path()
3403                .join("status-00000000000000000000000000000000.json"),
3404        )
3405        .unwrap();
3406        preflight_windows_replacement(&plan).unwrap();
3407        std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER).unwrap();
3408        write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
3409        std::fs::remove_file(&plan.staged_exe).unwrap();
3410
3411        let status = windows_update_helper_command(
3412            &system_powershell_path().unwrap(),
3413            &plan,
3414            i32::MAX as u32,
3415            "9.9.9",
3416        )
3417        .status()
3418        .unwrap();
3419
3420        let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
3421        assert!(!status.success());
3422        assert_eq!(
3423            std::fs::read(&target).unwrap(),
3424            expected,
3425            "status={status_text}; backup_exists={}",
3426            plan.backup_file.exists()
3427        );
3428        assert!(!staging.exists());
3429        assert!(!plan.replacement_temp.exists());
3430        assert!(!plan.backup_file.exists());
3431        assert!(!plan.rollback_displaced_file.exists());
3432        let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
3433        assert_eq!(status["state"], "failed");
3434    }
3435
3436    #[cfg(windows)]
3437    #[test]
3438    fn windows_update_helper_rolls_back_after_post_replace_validation_failure() {
3439        let temp = real_tempdir();
3440        let staging = temp.path().join("staging");
3441        let staged = staging.join("bin/rpi.exe");
3442        let target = temp.path().join("target/rpi-copy.exe");
3443        fake_windows_executable(&staged, b"new-version");
3444        fake_windows_executable(&target, b"old-version");
3445        let expected = std::fs::read(&target).unwrap();
3446        let plan = build_windows_update_plan(
3447            &staging,
3448            &target,
3449            &temp
3450                .path()
3451                .join("status-00000000000000000000000000000000.json"),
3452        )
3453        .unwrap();
3454        preflight_windows_replacement(&plan).unwrap();
3455        let forced_failure = WINDOWS_UPDATE_HELPER.replace(
3456            "(Get-FileSha256 -Path $targetFull) -ne $expectedHash",
3457            "$true",
3458        );
3459        assert_ne!(forced_failure, WINDOWS_UPDATE_HELPER);
3460        std::fs::write(&plan.helper_script, forced_failure).unwrap();
3461        write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
3462
3463        let status = windows_update_helper_command(
3464            &system_powershell_path().unwrap(),
3465            &plan,
3466            i32::MAX as u32,
3467            "9.9.9",
3468        )
3469        .status()
3470        .unwrap();
3471
3472        let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
3473        assert!(!status.success());
3474        assert_eq!(
3475            std::fs::read(&target).unwrap(),
3476            expected,
3477            "status={status_text}; backup_exists={}",
3478            plan.backup_file.exists()
3479        );
3480        assert!(!staging.exists());
3481        assert!(!plan.replacement_temp.exists());
3482        assert!(!plan.backup_file.exists());
3483        assert!(!plan.rollback_displaced_file.exists());
3484        let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
3485        assert_eq!(status["state"], "failed");
3486        assert!(status["message"]
3487            .as_str()
3488            .unwrap()
3489            .contains("updated executable hash mismatch"));
3490    }
3491
3492    #[cfg(windows)]
3493    #[test]
3494    fn windows_update_plan_rejects_a_non_executable_staged_file() {
3495        let temp = real_tempdir();
3496        let staging = temp.path().join("staging");
3497        let staged = staging.join("bin/rpi.exe");
3498        let target = temp.path().join("target/rpi.exe");
3499        std::fs::create_dir_all(staged.parent().unwrap()).unwrap();
3500        std::fs::write(&staged, b"not-a-pe").unwrap();
3501        fake_windows_executable(&target, b"old-version");
3502        let expected = std::fs::read(&target).unwrap();
3503
3504        let error = build_windows_update_plan(
3505            &staging,
3506            &target,
3507            &temp
3508                .path()
3509                .join("status-00000000000000000000000000000000.json"),
3510        )
3511        .unwrap_err();
3512
3513        assert!(error.contains("not a Windows executable"));
3514        assert_eq!(std::fs::read(&target).unwrap(), expected);
3515    }
3516
3517    #[test]
3518    fn staged_rpi_version_output_is_exact_and_cannot_downgrade() {
3519        assert_eq!(
3520            parse_rpi_version_output(b"rpi 1.2.3\r\n").unwrap(),
3521            semver::Version::new(1, 2, 3)
3522        );
3523        assert_eq!(
3524            validate_staged_rpi_version_output(b"rpi 1.2.3\n", b"", "1.2.3").unwrap(),
3525            semver::Version::new(1, 2, 3)
3526        );
3527        assert!(
3528            validate_staged_rpi_version_output(b"rpi 1.2.2\n", b"", "1.2.3")
3529                .unwrap_err()
3530                .contains("older version")
3531        );
3532
3533        for output in [
3534            &b"pi 1.2.3\n"[..],
3535            &b"rpi v1.2.3\n"[..],
3536            &b"rpi 1.2.3 extra\n"[..],
3537            &b"rpi 1.2.3\nsecond line\n"[..],
3538            &b" rpi 1.2.3\n"[..],
3539        ] {
3540            assert!(
3541                parse_rpi_version_output(output).is_none(),
3542                "output={output:?}"
3543            );
3544        }
3545        assert!(
3546            validate_staged_rpi_version_output(b"rpi 1.2.3\n", b"warning", "1.2.3")
3547                .unwrap_err()
3548                .contains("stderr")
3549        );
3550    }
3551
3552    #[test]
3553    fn preview_release_selection_ignores_stable_and_draft_releases() {
3554        let releases = vec![
3555            super::GithubRelease {
3556                tag_name: "v0.9.4-preview.1".into(),
3557                draft: false,
3558                prerelease: true,
3559            },
3560            super::GithubRelease {
3561                tag_name: "v0.9.4-preview.3".into(),
3562                draft: false,
3563                prerelease: true,
3564            },
3565            super::GithubRelease {
3566                tag_name: "v0.9.4-preview.99".into(),
3567                draft: true,
3568                prerelease: true,
3569            },
3570            super::GithubRelease {
3571                tag_name: "v0.9.4".into(),
3572                draft: false,
3573                prerelease: false,
3574            },
3575        ];
3576        assert_eq!(
3577            super::latest_preview_version(releases),
3578            super::RegistryLookup::Found("0.9.4-preview.3".into())
3579        );
3580    }
3581
3582    #[test]
3583    fn compares_release_versions_conservatively() {
3584        assert!(is_newer("0.1.9", "0.1.10"));
3585        assert!(is_newer("v1.2.3", "1.3.0"));
3586        assert!(is_newer("1.2.3-beta.1", "1.2.3-beta.2"));
3587        assert!(is_newer("1.2.3-beta.2", "1.2.3"));
3588        assert!(!is_newer("1.2.3", "1.2.3"));
3589        assert!(!is_newer("1.2.3", "1.2.3+build.1"));
3590        assert!(!is_newer("1.2.3+build.1", "1.2.3+build.2"));
3591        assert!(!is_newer("nightly", "1.0.0"));
3592    }
3593
3594    #[test]
3595    fn failed_item_checks_keep_only_corresponding_cached_values() {
3596        let cached = BTreeMap::from([
3597            ("fresh".to_string(), "1.0.0".to_string()),
3598            ("failed".to_string(), "2.0.0".to_string()),
3599            ("removed".to_string(), "3.0.0".to_string()),
3600        ]);
3601        let results = vec![
3602            (
3603                "fresh".to_string(),
3604                RegistryLookup::Found("1.1.0".to_string()),
3605            ),
3606            ("failed".to_string(), RegistryLookup::TransientFailure),
3607            ("uncached".to_string(), RegistryLookup::TransientFailure),
3608            ("missing".to_string(), RegistryLookup::Missing),
3609        ];
3610
3611        let (merged, checked_at_values) =
3612            results_with_individual_cache_fallback(results, Some(&cached), None, 100, 100);
3613        assert_eq!(merged.get("fresh").map(String::as_str), Some("1.1.0"));
3614        assert_eq!(merged.get("failed").map(String::as_str), Some("2.0.0"));
3615        assert!(!merged.contains_key("removed"));
3616        assert!(!merged.contains_key("uncached"));
3617        assert!(!merged.contains_key("missing"));
3618        // A live lookup is stamped with the current check; a fallback keeps the
3619        // timestamp it was cached at, so the fallback window cannot be extended
3620        // indefinitely by repeated offline starts.
3621        assert_eq!(checked_at_values.get("fresh").copied(), Some(100));
3622        assert_eq!(checked_at_values.get("failed").copied(), Some(100));
3623        assert!(!checked_at_values.contains_key("uncached"));
3624        assert!(!checked_at_values.contains_key("missing"));
3625    }
3626
3627    #[tokio::test]
3628    async fn package_update_checks_have_a_shared_concurrency_limit() {
3629        let in_flight = Arc::new(AtomicUsize::new(0));
3630        let maximum = Arc::new(AtomicUsize::new(0));
3631        let checks = (0..(UPDATE_CHECK_CONCURRENCY * 3)).map(|index| {
3632            let in_flight = Arc::clone(&in_flight);
3633            let maximum = Arc::clone(&maximum);
3634            async move {
3635                let active = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
3636                maximum.fetch_max(active, Ordering::SeqCst);
3637                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3638                in_flight.fetch_sub(1, Ordering::SeqCst);
3639                index
3640            }
3641        });
3642
3643        let mut results = collect_bounded(checks).await;
3644        results.sort_unstable();
3645
3646        assert_eq!(
3647            results,
3648            (0..(UPDATE_CHECK_CONCURRENCY * 3)).collect::<Vec<_>>()
3649        );
3650        assert_eq!(maximum.load(Ordering::SeqCst), UPDATE_CHECK_CONCURRENCY);
3651        assert_eq!(in_flight.load(Ordering::SeqCst), 0);
3652    }
3653
3654    #[tokio::test]
3655    async fn bounded_checks_do_not_head_of_line_block_new_work() {
3656        let release_first = Arc::new(tokio::sync::Notify::new());
3657        let checks = (0..(UPDATE_CHECK_CONCURRENCY + 1)).map(|index| {
3658            let release_first = Arc::clone(&release_first);
3659            async move {
3660                if index == 0 {
3661                    release_first.notified().await;
3662                } else if index == UPDATE_CHECK_CONCURRENCY {
3663                    release_first.notify_one();
3664                }
3665                index
3666            }
3667        });
3668
3669        let mut results =
3670            tokio::time::timeout(std::time::Duration::from_secs(1), collect_bounded(checks))
3671                .await
3672                .expect("a completed later check must free a concurrency slot");
3673        results.sort_unstable();
3674        assert_eq!(results, (0..=UPDATE_CHECK_CONCURRENCY).collect::<Vec<_>>());
3675    }
3676
3677    #[test]
3678    fn fresh_results_keep_individual_timestamps_when_a_peer_uses_fallback() {
3679        let now = 10 * CACHE_FALLBACK_MAX_AGE_MS;
3680        let nearly_stale = now - CACHE_FALLBACK_MAX_AGE_MS + 60_000;
3681        let cached = BTreeMap::from([
3682            ("fresh".to_string(), "1.0.0".to_string()),
3683            ("failed".to_string(), "2.0.0".to_string()),
3684        ]);
3685        let freshness = BTreeMap::from([
3686            ("fresh".to_string(), nearly_stale),
3687            ("failed".to_string(), nearly_stale),
3688        ]);
3689        let (values, timestamps) = results_with_individual_cache_fallback(
3690            vec![
3691                (
3692                    "fresh".to_string(),
3693                    RegistryLookup::Found("1.1.0".to_string()),
3694                ),
3695                ("failed".to_string(), RegistryLookup::TransientFailure),
3696            ],
3697            Some(&cached),
3698            Some(&freshness),
3699            nearly_stale,
3700            now,
3701        );
3702
3703        assert_eq!(timestamps.get("fresh"), Some(&now));
3704        assert_eq!(timestamps.get("failed"), Some(&nearly_stale));
3705        let after_old_entry_expires = now + 120_000;
3706        let (fallback, _) = results_with_individual_cache_fallback(
3707            vec![
3708                ("fresh".to_string(), RegistryLookup::TransientFailure),
3709                ("failed".to_string(), RegistryLookup::TransientFailure),
3710            ],
3711            Some(&values),
3712            Some(&timestamps),
3713            nearly_stale,
3714            after_old_entry_expires,
3715        );
3716        assert_eq!(fallback.get("fresh").map(String::as_str), Some("1.1.0"));
3717        assert!(!fallback.contains_key("failed"));
3718    }
3719
3720    #[tokio::test]
3721    async fn crates_rate_limits_and_request_timeouts_are_transient() {
3722        async fn lookup(status: &str, body: &str) -> RegistryLookup {
3723            use std::io::{BufRead, BufReader, Write};
3724
3725            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3726            let address = listener.local_addr().unwrap();
3727            let status = status.to_string();
3728            let body = body.to_string();
3729            let server = std::thread::spawn(move || {
3730                let (mut stream, _) = listener.accept().unwrap();
3731                {
3732                    let mut reader = BufReader::new(&mut stream);
3733                    let mut line = String::new();
3734                    loop {
3735                        line.clear();
3736                        if reader.read_line(&mut line).unwrap() == 0 || line == "\r\n" {
3737                            break;
3738                        }
3739                    }
3740                }
3741                write!(
3742                    stream,
3743                    "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
3744                    body.len()
3745                )
3746                .unwrap();
3747            });
3748            let client = reqwest::Client::builder().build().unwrap();
3749            let result = fetch_crates_url(&client, &format!("http://{address}/crate")).await;
3750            server.join().unwrap();
3751            result
3752        }
3753
3754        assert_eq!(
3755            lookup("408 Request Timeout", "").await,
3756            RegistryLookup::TransientFailure
3757        );
3758        assert_eq!(
3759            lookup("429 Too Many Requests", "").await,
3760            RegistryLookup::TransientFailure
3761        );
3762        let malformed = lookup("200 OK", "{malformed-json").await;
3763        assert_eq!(malformed, RegistryLookup::TransientFailure);
3764        assert_eq!(
3765            super::lookup_with_cache_fallback(malformed, Some("9.9.9"), true),
3766            (Some("9.9.9".to_string()), true)
3767        );
3768        assert_eq!(lookup("404 Not Found", "").await, RegistryLookup::Missing);
3769    }
3770
3771    /// One-shot HTTP server: answers the first request with `status`/`body`.
3772    ///
3773    /// Returns the URL, a stop flag, and the thread handle. The no-crates.io
3774    /// regression leaves one server unused, so accept must be nonblocking.
3775    fn serve_once(
3776        status: String,
3777        body: String,
3778    ) -> (
3779        String,
3780        std::sync::Arc<std::sync::atomic::AtomicBool>,
3781        std::thread::JoinHandle<usize>,
3782    ) {
3783        use std::io::{BufRead, BufReader, Write};
3784        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
3785        use std::sync::Arc;
3786
3787        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3788        let address = listener.local_addr().unwrap();
3789        // The no-crates.io regression leaves one of two servers unused, so the
3790        // accept must be able to time out on its own: a blocking accept that no
3791        // client ever reaches would hang the test, and a self-connect cannot
3792        // unblock `accept` on this platform.
3793        listener.set_nonblocking(true).unwrap();
3794        let hits = Arc::new(AtomicUsize::new(0));
3795        let thread_hits = hits.clone();
3796        let stop = Arc::new(AtomicBool::new(false));
3797        let thread_stop = stop.clone();
3798        let handle = std::thread::spawn(move || {
3799            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
3800            let stream = loop {
3801                match listener.accept() {
3802                    Ok((stream, _)) => break Some(stream),
3803                    Err(ref error) if error.kind() == std::io::ErrorKind::WouldBlock => {
3804                        if thread_stop.load(Ordering::SeqCst)
3805                            || std::time::Instant::now() > deadline
3806                        {
3807                            break None;
3808                        }
3809                        std::thread::sleep(std::time::Duration::from_millis(2));
3810                    }
3811                    Err(_) => break None,
3812                }
3813            };
3814            let Some(mut stream) = stream else {
3815                return thread_hits.load(Ordering::SeqCst);
3816            };
3817            // `accept` inherited non-blocking from the listener; the request read
3818            // below must block, so put the stream back into blocking mode.
3819            stream.set_nonblocking(false).unwrap();
3820            thread_hits.fetch_add(1, Ordering::SeqCst);
3821            {
3822                let mut reader = BufReader::new(&mut stream);
3823                let mut line = String::new();
3824                loop {
3825                    line.clear();
3826                    if reader.read_line(&mut line).unwrap() == 0 || line == "\r\n" {
3827                        break;
3828                    }
3829                }
3830            }
3831            write!(
3832                stream,
3833                "HTTP/1.1 {status}
3834Content-Length: {}
3835Connection: close
3836
3837{body}",
3838                body.len()
3839            )
3840            .unwrap();
3841            thread_hits.load(Ordering::SeqCst)
3842        });
3843        (format!("http://{address}/release"), stop, handle)
3844    }
3845
3846    fn stop_and_join(
3847        stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
3848        handle: std::thread::JoinHandle<usize>,
3849        url: &str,
3850    ) -> usize {
3851        use std::sync::atomic::Ordering;
3852        stop.store(true, Ordering::SeqCst);
3853        let _ = url;
3854        handle.join().unwrap()
3855    }
3856
3857    fn stable_release(tag: &str) -> String {
3858        serde_json::json!({"tag_name": tag, "draft": false, "prerelease": false}).to_string()
3859    }
3860
3861    #[tokio::test]
3862    async fn cli_stable_queries_github_without_contacting_crates_io() {
3863        let (crates_url, crates_stop, crates_server) = serve_once(
3864            "200 OK".into(),
3865            r#"{"crate":{"max_version":"99.0.0"}}"#.into(),
3866        );
3867        let (github_url, github_stop, github_server) =
3868            serve_once("200 OK".into(), stable_release("v0.11.0"));
3869        let client = reqwest::Client::new();
3870        let result = super::fetch_stable_from_github(&client, &github_url).await;
3871        assert_eq!(stop_and_join(crates_stop, crates_server, &crates_url), 0);
3872        assert_eq!(stop_and_join(github_stop, github_server, &github_url), 1);
3873        assert_eq!(result, RegistryLookup::Found("0.11.0".into()));
3874    }
3875
3876    #[tokio::test]
3877    async fn github_stable_rejects_prereleases_drafts_and_invalid_versions() {
3878        for body in [
3879            serde_json::json!({"tag_name":"v1.0.0-preview.1","draft":false,"prerelease":true}),
3880            serde_json::json!({"tag_name":"v1.0.0","draft":true,"prerelease":false}),
3881            serde_json::json!({"tag_name":"v1.0.0-rc.1","draft":false,"prerelease":false}),
3882            serde_json::json!({"tag_name":"1.0.0","draft":false,"prerelease":false}),
3883            serde_json::json!({"tag_name":"vnot-semver","draft":false,"prerelease":false}),
3884        ] {
3885            let (url, stop, server) = serve_once("200 OK".into(), body.to_string());
3886            let result = super::fetch_stable_from_github(&reqwest::Client::new(), &url).await;
3887            stop_and_join(stop, server, &url);
3888            assert_eq!(result, RegistryLookup::TransientFailure, "{body}");
3889        }
3890    }
3891
3892    #[tokio::test]
3893    async fn github_errors_preserve_fresh_cache_and_reject_expired_cache() {
3894        let _guard = crate::config::test_support::env_lock()
3895            .lock()
3896            .unwrap_or_else(|p| p.into_inner());
3897        let agent = tempfile::tempdir().unwrap();
3898        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3899        std::env::set_var(crate::config::CONFIG_DIR_ENV, agent.path());
3900        for (status, body) in [
3901            ("404 Not Found", ""),
3902            ("403 Forbidden", "rate limited"),
3903            ("500 Internal Server Error", ""),
3904            ("200 OK", "{not-json"),
3905        ] {
3906            for fresh in [true, false] {
3907                let stamp = super::now_ms() - if fresh { 1000 } else { 48 * 60 * 60 * 1000 };
3908                let cached = super::UpdateCache {
3909                    checked_at: stamp,
3910                    rpi_latest: Some("0.10.0".into()),
3911                    freshness: super::UpdateCacheFreshness {
3912                        rpi: stamp,
3913                        native_packages: Default::default(),
3914                    },
3915                    ..Default::default()
3916                };
3917                std::fs::write(
3918                    agent.path().join(super::CACHE_FILE),
3919                    serde_json::to_vec(&cached).unwrap(),
3920                )
3921                .unwrap();
3922                let (url, stop, server) = serve_once(status.into(), body.into());
3923                let result = super::lookup_latest_rpi_from(&url).await;
3924                stop_and_join(stop, server, &url);
3925                assert_eq!(
3926                    result,
3927                    fresh.then(|| ("0.10.0".into(), false)),
3928                    "{status}, fresh={fresh}"
3929                );
3930            }
3931        }
3932    }
3933
3934    #[tokio::test]
3935    async fn github_live_answer_is_verified_even_with_an_expired_cache() {
3936        let _guard = crate::config::test_support::env_lock()
3937            .lock()
3938            .unwrap_or_else(|p| p.into_inner());
3939        let agent = tempfile::tempdir().unwrap();
3940        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3941        std::env::set_var(crate::config::CONFIG_DIR_ENV, agent.path());
3942        let stamp = super::now_ms() - 48 * 60 * 60 * 1000;
3943        let cached = super::UpdateCache {
3944            checked_at: stamp,
3945            rpi_latest: Some("0.10.0".into()),
3946            freshness: super::UpdateCacheFreshness {
3947                rpi: stamp,
3948                native_packages: Default::default(),
3949            },
3950            ..Default::default()
3951        };
3952        std::fs::write(
3953            agent.path().join(super::CACHE_FILE),
3954            serde_json::to_vec(&cached).unwrap(),
3955        )
3956        .unwrap();
3957        let (url, stop, server) = serve_once("200 OK".into(), stable_release("v0.11.0"));
3958        let result = super::lookup_latest_rpi_from(&url).await;
3959        assert_eq!(stop_and_join(stop, server, &url), 1);
3960        assert_eq!(result, Some(("0.11.0".into(), true)));
3961    }
3962}