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