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