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