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