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