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;
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;
26static UPDATE_CACHE_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
27static SELF_UPDATE_STATUS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct UpdateNotice {
31    pub name: String,
32    pub current: String,
33    pub latest: String,
34    pub command: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct UpdateWarning {
39    pub message: String,
40    pub command: String,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct UpdateReport {
45    pub notices: Vec<UpdateNotice>,
46    pub warnings: Vec<UpdateWarning>,
47}
48
49impl UpdateReport {
50    pub fn is_empty(&self) -> bool {
51        self.notices.is_empty() && self.warnings.is_empty()
52    }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56#[serde(rename_all = "camelCase")]
57struct UpdateCache {
58    /// Legacy aggregate timestamp retained for backward-compatible cache reads.
59    checked_at: i64,
60    rpi_latest: Option<String>,
61    #[serde(default)]
62    native_packages: BTreeMap<String, String>,
63    #[serde(default)]
64    freshness: UpdateCacheFreshness,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68#[serde(rename_all = "camelCase")]
69struct UpdateCacheFreshness {
70    rpi: i64,
71    #[serde(default)]
72    native_packages: BTreeMap<String, i64>,
73}
74
75type RegistryCacheUpdate = (BTreeMap<String, String>, BTreeMap<String, i64>);
76
77#[derive(Debug, Deserialize)]
78struct CratesResponse {
79    #[serde(rename = "crate")]
80    crate_info: CrateInfo,
81}
82
83#[derive(Debug, Deserialize)]
84struct CrateInfo {
85    max_version: String,
86}
87
88#[derive(Debug, Deserialize)]
89struct SelfUpdateStatus {
90    state: String,
91    #[serde(default)]
92    message: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96enum RegistryLookup {
97    Found(String),
98    Missing,
99    TransientFailure,
100}
101
102/// Best-effort startup check for the rpi release and installed Rust extensions.
103pub async fn check_startup(cwd: &Path) -> UpdateReport {
104    let _ = cwd;
105    check_rpi_startup().await
106}
107
108/// Check the rpi release and every installed Rust cdylib extension.
109///
110/// Native-only: the npm/Git package halves of the old scope went away with the
111/// Pi compatibility layer, so this never shells out to a package manager.
112pub async fn check_rpi_startup() -> UpdateReport {
113    if update_checks_disabled() {
114        return report_without_remote_checks();
115    }
116    check_startup_with_client(startup_http_client()).await
117}
118
119fn update_checks_disabled() -> bool {
120    crate::args::offline_env_enabled() || std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_some()
121}
122
123/// Report the outcome of a self-update that ran in a previous process.
124fn report_without_remote_checks() -> UpdateReport {
125    let warnings = crate::config::agent_dir()
126        .ok()
127        .map(|agent_dir| consume_self_update_statuses(&agent_dir))
128        .unwrap_or_default();
129    UpdateReport {
130        notices: Vec::new(),
131        warnings,
132    }
133}
134
135fn startup_http_client() -> Option<reqwest::Client> {
136    reqwest::Client::builder()
137        .timeout(std::time::Duration::from_millis(REQUEST_TIMEOUT_MS))
138        .user_agent(format!("rpi/{}", crate::VERSION))
139        .build()
140        .ok()
141}
142
143async fn check_startup_with_client(client: Option<reqwest::Client>) -> UpdateReport {
144    let agent_dir = match crate::config::agent_dir() {
145        Ok(dir) => dir,
146        Err(_) => return UpdateReport::default(),
147    };
148    let warnings = consume_self_update_statuses(&agent_dir);
149    let cache_path = agent_dir.join(CACHE_FILE);
150    let previous_cache = read_cache(&cache_path);
151
152    // Installed Rust extensions come from `rpi install`'s registry, so this
153    // path stays independent of any Pi package resource discovery.
154    let native_checks = crate::install::installed_native_packages()
155        .into_iter()
156        .filter(|package| package.source.is_none())
157        .map(|package| {
158            let client = client.clone();
159            async move {
160                let latest = match client.as_ref() {
161                    Some(client) => fetch_crates_latest(client, &package.name).await,
162                    None => RegistryLookup::TransientFailure,
163                };
164                (package.name, latest)
165            }
166        });
167    let rpi_check = async {
168        Some(match client.as_ref() {
169            Some(client) => fetch_rpi_latest(client).await,
170            None => RegistryLookup::TransientFailure,
171        })
172    };
173    let (rpi_result, native_results) = tokio::join!(rpi_check, collect_bounded(native_checks));
174
175    let checked_at = now_ms();
176    let mut rpi_update = None;
177    if let Some(rpi_result) = rpi_result {
178        let cached_at = previous_cache
179            .as_ref()
180            .map(rpi_cache_checked_at)
181            .unwrap_or_default();
182        let fallback_allowed = timestamp_is_fresh(cached_at, checked_at);
183        let (latest, used_fallback) = lookup_with_cache_fallback(
184            rpi_result,
185            previous_cache
186                .as_ref()
187                .and_then(|cache| cache.rpi_latest.as_deref()),
188            fallback_allowed,
189        );
190        let value_checked_at =
191            resolved_checked_at(latest.as_deref(), used_fallback, cached_at, checked_at);
192        rpi_update = Some((latest, value_checked_at));
193    }
194
195    let legacy_checked_at = previous_cache
196        .as_ref()
197        .map(|cache| cache.checked_at)
198        .unwrap_or_default();
199    let native_packages = results_with_individual_cache_fallback(
200        native_results,
201        previous_cache.as_ref().map(|cache| &cache.native_packages),
202        previous_cache
203            .as_ref()
204            .map(|cache| &cache.freshness.native_packages),
205        legacy_checked_at,
206        checked_at,
207    );
208
209    let cache = merge_and_write_cache(&cache_path, checked_at, rpi_update, native_packages);
210    let mut report = report_from_cache(&cache);
211    report.warnings = warnings;
212    report
213}
214
215async fn collect_bounded<I, F, T>(checks: I) -> Vec<T>
216where
217    I: IntoIterator<Item = F>,
218    F: Future<Output = T>,
219{
220    stream::iter(checks)
221        .buffer_unordered(UPDATE_CHECK_CONCURRENCY)
222        .collect()
223        .await
224}
225
226fn rpi_cache_checked_at(cache: &UpdateCache) -> i64 {
227    if cache.freshness.rpi > 0 {
228        cache.freshness.rpi
229    } else {
230        cache.checked_at
231    }
232}
233
234fn entry_cache_checked_at(
235    freshness: Option<&BTreeMap<String, i64>>,
236    key: &str,
237    legacy_checked_at: i64,
238) -> i64 {
239    freshness
240        .and_then(|values| values.get(key).copied())
241        .filter(|checked_at| *checked_at > 0)
242        .unwrap_or(legacy_checked_at)
243}
244
245fn timestamp_is_fresh(checked_at: i64, now: i64) -> bool {
246    let age = now.saturating_sub(checked_at);
247    checked_at > 0 && age >= 0 && age <= CACHE_FALLBACK_MAX_AGE_MS
248}
249
250fn resolved_checked_at(
251    value: Option<&str>,
252    used_fallback: bool,
253    cached_at: i64,
254    checked_at: i64,
255) -> i64 {
256    if value.is_none() {
257        0
258    } else if used_fallback {
259        cached_at
260    } else {
261        checked_at
262    }
263}
264
265fn results_with_individual_cache_fallback(
266    results: Vec<(String, RegistryLookup)>,
267    cached: Option<&BTreeMap<String, String>>,
268    freshness: Option<&BTreeMap<String, i64>>,
269    legacy_checked_at: i64,
270    checked_at: i64,
271) -> RegistryCacheUpdate {
272    let mut values = BTreeMap::new();
273    let mut checked_at_values = BTreeMap::new();
274    for (name, result) in results {
275        let cached_value = cached.and_then(|cached| cached.get(&name).map(String::as_str));
276        let cached_at = entry_cache_checked_at(freshness, &name, legacy_checked_at);
277        let (latest, used_fallback) = lookup_with_cache_fallback(
278            result,
279            cached_value,
280            timestamp_is_fresh(cached_at, checked_at),
281        );
282        if let Some(latest) = latest {
283            checked_at_values.insert(
284                name.clone(),
285                resolved_checked_at(Some(&latest), used_fallback, cached_at, checked_at),
286            );
287            values.insert(name, latest);
288        }
289    }
290    (values, checked_at_values)
291}
292
293fn merge_and_write_cache(
294    path: &Path,
295    checked_at: i64,
296    rpi_update: Option<(Option<String>, i64)>,
297    native_packages: RegistryCacheUpdate,
298) -> UpdateCache {
299    let _guard = UPDATE_CACHE_WRITE_LOCK
300        .lock()
301        .unwrap_or_else(std::sync::PoisonError::into_inner);
302    let mut cache = read_cache(path).unwrap_or_default();
303    if cache.checked_at <= 0 {
304        cache.checked_at = checked_at;
305    }
306    if let Some((latest, latest_checked_at)) = rpi_update {
307        cache.rpi_latest = latest;
308        cache.freshness.rpi = latest_checked_at;
309    }
310    cache.native_packages = native_packages.0;
311    cache.freshness.native_packages = native_packages.1;
312    let _ = write_cache(path, &cache);
313    cache
314}
315
316fn lookup_with_cache_fallback(
317    result: RegistryLookup,
318    cached: Option<&str>,
319    fallback_allowed: bool,
320) -> (Option<String>, bool) {
321    match result {
322        RegistryLookup::Found(version) => (Some(version), false),
323        RegistryLookup::Missing => (None, false),
324        RegistryLookup::TransientFailure if fallback_allowed => {
325            (cached.map(str::to_owned), cached.is_some())
326        }
327        RegistryLookup::TransientFailure => (None, false),
328    }
329}
330fn report_from_cache(cache: &UpdateCache) -> UpdateReport {
331    let mut notices = Vec::new();
332    if let Some(latest) = cache.rpi_latest.as_deref() {
333        if is_newer(crate::VERSION, latest) {
334            notices.push(UpdateNotice {
335                name: "rpi".into(),
336                current: crate::VERSION.into(),
337                latest: latest.into(),
338                command: "rpi update".into(),
339            });
340        }
341    }
342    for package in crate::install::installed_native_packages() {
343        if package.source.is_some() {
344            continue;
345        }
346        let Some(latest) = cache.native_packages.get(&package.name) else {
347            continue;
348        };
349        if is_newer(&package.version, latest) {
350            notices.push(UpdateNotice {
351                name: package.name.clone(),
352                current: package.version,
353                latest: latest.clone(),
354                command: format!("rpi install {} --force", package.name),
355            });
356        }
357    }
358    UpdateReport {
359        notices,
360        warnings: Vec::new(),
361    }
362}
363
364fn normalize_windows_path(path: PathBuf) -> PathBuf {
365    if cfg!(windows) {
366        let text = path.to_string_lossy();
367        if let Some(unc) = text.strip_prefix(r"\\?\UNC\") {
368            return PathBuf::from(format!(r"\\{unc}"));
369        }
370        if let Some(local) = text.strip_prefix(r"\\?\") {
371            return PathBuf::from(local);
372        }
373    }
374    path
375}
376
377fn windows_paths_equal(left: &Path, right: &Path) -> bool {
378    let left = normalize_windows_path(left.to_path_buf());
379    let right = normalize_windows_path(right.to_path_buf());
380    if cfg!(windows) {
381        left.to_string_lossy()
382            .replace('/', "\\")
383            .eq_ignore_ascii_case(&right.to_string_lossy().replace('/', "\\"))
384    } else {
385        left == right
386    }
387}
388
389async fn fetch_rpi_latest(client: &reqwest::Client) -> RegistryLookup {
390    fetch_crates_url(client, CRATES_IO_API).await
391}
392
393async fn fetch_crates_latest(client: &reqwest::Client, name: &str) -> RegistryLookup {
394    fetch_crates_url(client, &format!("https://crates.io/api/v1/crates/{name}")).await
395}
396
397async fn fetch_crates_url(client: &reqwest::Client, url: &str) -> RegistryLookup {
398    let response = match client.get(url).send().await {
399        Ok(response) => response,
400        Err(_) => return RegistryLookup::TransientFailure,
401    };
402    if matches!(
403        response.status(),
404        reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::GONE
405    ) {
406        return RegistryLookup::Missing;
407    }
408    if !response.status().is_success() {
409        return RegistryLookup::TransientFailure;
410    }
411    match response.json::<CratesResponse>().await {
412        Ok(response) => RegistryLookup::Found(response.crate_info.max_version),
413        Err(_) => RegistryLookup::TransientFailure,
414    }
415}
416
417/// Compare complete semantic versions, including prerelease precedence.
418pub fn is_newer(current: &str, latest: &str) -> bool {
419    let parse = |value: &str| semver::Version::parse(value.trim().trim_start_matches('v')).ok();
420    match (parse(current), parse(latest)) {
421        (Some(current), Some(latest)) => latest > current,
422        _ => false,
423    }
424}
425
426fn read_cache(path: &Path) -> Option<UpdateCache> {
427    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
428}
429
430fn write_cache(path: &Path, cache: &UpdateCache) -> Result<(), String> {
431    if let Some(parent) = path.parent() {
432        std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
433    }
434    let data = serde_json::to_vec_pretty(cache).map_err(|error| error.to_string())?;
435    crate::config::atomic_write(path, &data).map_err(|error| error.to_string())
436}
437
438fn now_ms() -> i64 {
439    SystemTime::now()
440        .duration_since(UNIX_EPOCH)
441        .map(|duration| duration.as_millis() as i64)
442        .unwrap_or(0)
443}
444
445pub fn print_startup_notices(report: &UpdateReport) {
446    for warning in &report.warnings {
447        eprintln!("warning: {} Run `{}`.", warning.message, warning.command);
448    }
449    for notice in &report.notices {
450        eprintln!(
451            "Update available: {} {} -> {}. Run `{}`.",
452            notice.name, notice.current, notice.latest, notice.command
453        );
454    }
455}
456
457/// Update the installed rpi CLI through its documented crates.io install path.
458/// Windows stages first because a running executable cannot replace itself;
459/// other platforms retain Cargo's direct replacement behavior.
460pub fn run_self_update(args: &[String]) -> i32 {
461    let offline = crate::args::normalize_offline_mode(args);
462    let args = crate::args::without_offline_flag(args);
463    if args
464        .iter()
465        .any(|arg| matches!(arg.as_str(), "--help" | "-h"))
466    {
467        println!("Usage: rpi update [--offline]\n\nUpdate the rpi CLI from crates.io.");
468        return 0;
469    }
470    if !args.is_empty() {
471        eprintln!("error: `rpi update` does not accept arguments");
472        return 2;
473    }
474    if offline {
475        println!("rpi update skipped: offline mode is enabled");
476        return 0;
477    }
478
479    #[cfg(windows)]
480    {
481        run_windows_self_update()
482    }
483    #[cfg(not(windows))]
484    {
485        run_direct_self_update()
486    }
487}
488
489fn cargo_install_command(
490    staging_root: Option<&Path>,
491    isolated_cwd: &Path,
492) -> Result<std::process::Command, String> {
493    let isolated_cwd = validated_self_update_command_dir(isolated_cwd)?;
494    let mut command = std::process::Command::new("cargo");
495    command.arg("install").current_dir(isolated_cwd);
496    for variable in [
497        "RUSTC_WRAPPER",
498        "RUSTC_WORKSPACE_WRAPPER",
499        "CARGO_BUILD_RUSTC_WRAPPER",
500        "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
501    ] {
502        command.env_remove(variable);
503    }
504    if let Some(staging_root) = staging_root {
505        command.arg("--root").arg(staging_root);
506    }
507    // `--quiet` suppresses cargo's trailing advice to add the install root to
508    // PATH. Here that advice is actively wrong: the staging root is a temp
509    // directory that the atomic replace consumes, so a user who follows it
510    // adds a path that will not exist a second later. Errors still print.
511    command.args(["rpi-cli", "--locked", "--force", "--quiet"]);
512    Ok(command)
513}
514
515/// Cargo discovers `.cargo/config.toml` from its working directory upward.
516/// Put update commands directly below the user's home directory so neither an
517/// untrusted project, a project-local `CARGO_HOME`, nor a world-writable system
518/// temp directory can join that discovery chain. Cargo still reads its
519/// explicitly configured user-level home through its normal environment.
520fn create_self_update_command_dir() -> Result<tempfile::TempDir, String> {
521    let configured_root =
522        dirs::home_dir().ok_or_else(|| "could not resolve the user home directory".to_string())?;
523    if !configured_root.is_absolute() {
524        return Err("user home must be absolute for self-update".to_string());
525    }
526    let canonical_root = std::fs::canonicalize(&configured_root)
527        .map(normalize_windows_path)
528        .map_err(|error| format!("could not canonicalize user home: {error}"))?;
529    let metadata = std::fs::symlink_metadata(&canonical_root)
530        .map_err(|error| format!("could not inspect user home: {error}"))?;
531    if !metadata.is_dir() || metadata.file_type().is_symlink() {
532        return Err("user home does not resolve to a real directory".to_string());
533    }
534    let current = std::env::current_dir()
535        .and_then(std::fs::canonicalize)
536        .map(normalize_windows_path)
537        .map_err(|error| format!("could not validate the caller directory: {error}"))?;
538    let project_root = current
539        .ancestors()
540        .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
541        .map(Path::to_path_buf);
542    validate_configured_cargo_home(project_root.as_deref())?;
543    if project_root.as_deref().is_some_and(|root| {
544        !windows_paths_equal(&canonical_root, root) && path_is_within(&canonical_root, root)
545    }) {
546        return Err(
547            "refusing to create a self-update command directory inside the caller project"
548                .to_string(),
549        );
550    }
551    let directory = tempfile::Builder::new()
552        .prefix("rpi-self-update-command-")
553        .tempdir_in(&canonical_root)
554        .map_err(|error| format!("could not allocate Cargo command directory: {error}"))?;
555    validated_self_update_command_dir(directory.path())?;
556    #[cfg(unix)]
557    {
558        use std::os::unix::fs::{MetadataExt, PermissionsExt};
559
560        let directory_metadata = std::fs::symlink_metadata(directory.path())
561            .map_err(|error| format!("could not inspect Cargo command directory: {error}"))?;
562        if metadata.uid() != directory_metadata.uid() || metadata.permissions().mode() & 0o022 != 0
563        {
564            return Err("user home is not private to the current account".to_string());
565        }
566    }
567    Ok(directory)
568}
569
570fn path_is_within(candidate: &Path, ancestor: &Path) -> bool {
571    candidate
572        .ancestors()
573        .any(|component| windows_paths_equal(component, ancestor))
574}
575
576fn validate_configured_cargo_home(project_root: Option<&Path>) -> Result<(), String> {
577    let Some(configured) = std::env::var_os("CARGO_HOME") else {
578        return Ok(());
579    };
580    let configured = PathBuf::from(configured);
581    if !configured.is_absolute() {
582        return Err("CARGO_HOME must be absolute for self-update".to_string());
583    }
584    let project_local = match project_root {
585        Some(root) => cargo_home_is_project_local(&configured, root)?,
586        None => {
587            canonicalize_allow_missing(&configured, "CARGO_HOME")?;
588            false
589        }
590    };
591    if project_local {
592        return Err("refusing project-local CARGO_HOME during self-update".to_string());
593    }
594    Ok(())
595}
596
597fn cargo_home_is_project_local(configured: &Path, project_root: &Path) -> Result<bool, String> {
598    if !configured.is_absolute() {
599        return Err("CARGO_HOME must be absolute for self-update".to_string());
600    }
601    let configured = canonicalize_allow_missing(configured, "CARGO_HOME")?;
602    Ok(path_is_within(&configured, project_root))
603}
604
605fn canonicalize_allow_missing(path: &Path, label: &str) -> Result<PathBuf, String> {
606    if path.components().any(|component| {
607        matches!(
608            component,
609            std::path::Component::CurDir | std::path::Component::ParentDir
610        )
611    }) {
612        return Err(format!("{label} contains relative path components"));
613    }
614    let mut existing = path;
615    let mut missing = Vec::new();
616    loop {
617        match std::fs::symlink_metadata(existing) {
618            Ok(_) => break,
619            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
620                let name = existing
621                    .file_name()
622                    .ok_or_else(|| format!("could not resolve {label}"))?;
623                missing.push(name.to_os_string());
624                existing = existing
625                    .parent()
626                    .ok_or_else(|| format!("could not resolve {label}"))?;
627            }
628            Err(error) => return Err(format!("could not inspect {label}: {error}")),
629        }
630    }
631    let mut canonical = std::fs::canonicalize(existing)
632        .map(normalize_windows_path)
633        .map_err(|error| format!("could not canonicalize {label}: {error}"))?;
634    for component in missing.into_iter().rev() {
635        canonical.push(component);
636    }
637    Ok(canonical)
638}
639
640fn canonical_display(path: &Path) -> String {
641    std::fs::canonicalize(path)
642        .map(normalize_windows_path)
643        .map(|path| path.display().to_string())
644        .unwrap_or_else(|_| "<unresolvable>".to_string())
645}
646
647fn validated_self_update_command_dir(path: &Path) -> Result<PathBuf, String> {
648    if !path.is_absolute() {
649        return Err("self-update command directory must be absolute".to_string());
650    }
651    let metadata = std::fs::symlink_metadata(path)
652        .map_err(|error| format!("could not inspect self-update command directory: {error}"))?;
653    if !metadata.is_dir() || metadata.file_type().is_symlink() {
654        return Err("self-update command directory is not a real directory".to_string());
655    }
656    let canonical = std::fs::canonicalize(path)
657        .map(normalize_windows_path)
658        .map_err(|error| {
659            format!("could not canonicalize self-update command directory: {error}")
660        })?;
661    Ok(canonical)
662}
663
664#[cfg(not(windows))]
665#[derive(Debug, Clone)]
666struct DirectUpdatePlan {
667    staging_dir: PathBuf,
668    staged_exe: PathBuf,
669    target_exe: PathBuf,
670    replacement_temp: PathBuf,
671    backup_file: PathBuf,
672}
673
674#[cfg(not(windows))]
675fn run_direct_self_update() -> i32 {
676    let staging = match create_self_update_staging_directory() {
677        Ok(directory) => directory,
678        Err(error) => {
679            eprintln!("error: could not create self-update staging directory: {error}");
680            return 1;
681        }
682    };
683    let command_dir = match create_self_update_command_dir() {
684        Ok(directory) => directory,
685        Err(error) => {
686            eprintln!("error: could not create an isolated self-update directory: {error}");
687            return 1;
688        }
689    };
690    let mut command = match cargo_install_command(Some(staging.path()), command_dir.path()) {
691        Ok(command) => command,
692        Err(error) => {
693            eprintln!("error: invalid isolated self-update directory: {error}");
694            return 1;
695        }
696    };
697    let status = command.status();
698    match status {
699        Ok(status) if status.success() => {}
700        Ok(status) => {
701            eprintln!("error: cargo install exited with {status}");
702            return 1;
703        }
704        Err(error) => {
705            eprintln!("error: could not run cargo (install Rust/Cargo first): {error}");
706            return 1;
707        }
708    }
709
710    let current_exe = match std::env::current_exe() {
711        Ok(path) => path,
712        Err(error) => {
713            eprintln!("error: could not resolve the current rpi executable: {error}");
714            return 1;
715        }
716    };
717    let plan = match build_direct_update_plan(staging.path(), &current_exe) {
718        Ok(plan) => plan,
719        Err(error) => {
720            eprintln!("error: {error}");
721            return 1;
722        }
723    };
724    match activate_direct_update(&plan) {
725        Ok(version) => {
726            println!("rpi updated successfully to {version}");
727            0
728        }
729        Err(error) => {
730            eprintln!("error: {error}");
731            1
732        }
733    }
734}
735
736fn create_self_update_staging_directory() -> Result<tempfile::TempDir, String> {
737    let agent = crate::config::agent_dir().map_err(|error| error.to_string())?;
738    if !agent.is_absolute() {
739        return Err("agent directory must be absolute".to_string());
740    }
741    std::fs::create_dir_all(&agent)
742        .map_err(|error| format!("could not create {}: {error}", agent.display()))?;
743    let agent = validated_real_directory(&agent, "agent directory")?;
744    let update_root = agent.join("self-update");
745    std::fs::create_dir_all(&update_root)
746        .map_err(|error| format!("could not create {}: {error}", update_root.display()))?;
747    let update_root = validated_real_directory(&update_root, "self-update root")?;
748    let staging = tempfile::Builder::new()
749        .prefix("pending-")
750        .tempdir_in(&update_root)
751        .map_err(|error| format!("could not allocate staging directory: {error}"))?;
752    validated_real_directory(staging.path(), "self-update staging directory")?;
753    Ok(staging)
754}
755
756#[cfg(not(windows))]
757fn build_direct_update_plan(
758    staging_dir: &Path,
759    target_exe: &Path,
760) -> Result<DirectUpdatePlan, String> {
761    let staging_dir = validated_real_directory(staging_dir, "self-update staging directory")?;
762    let staged_exe = validated_regular_file(
763        &staging_dir.join("bin").join(crate::APP_NAME),
764        "staged rpi executable",
765    )?;
766    let target_exe = validated_regular_file(target_exe, "current rpi executable")?;
767    let target_dir = validated_real_directory(
768        target_exe
769            .parent()
770            .ok_or_else(|| "current executable has no parent directory".to_string())?,
771        "current executable directory",
772    )?;
773    if target_exe.parent() != Some(target_dir.as_path()) {
774        return Err("current executable is not a direct child of its validated directory".into());
775    }
776    let token = uuid::Uuid::new_v4().simple().to_string();
777    let replacement_temp = target_dir.join(format!(".rpi-update-{token}.new"));
778    let backup_file = target_dir.join(format!(".rpi-update-{token}.old"));
779    if replacement_temp.exists() || backup_file.exists() {
780        return Err("self-update replacement path collision".to_string());
781    }
782    Ok(DirectUpdatePlan {
783        staging_dir,
784        staged_exe,
785        target_exe,
786        replacement_temp,
787        backup_file,
788    })
789}
790
791#[cfg(not(windows))]
792fn activate_direct_update(plan: &DirectUpdatePlan) -> Result<semver::Version, String> {
793    let staged_version = validate_rpi_executable_version(
794        &plan.staged_exe,
795        &plan.staging_dir,
796        crate::VERSION,
797        "staged rpi",
798    )?;
799    preflight_direct_replacement(plan)?;
800
801    let result = (|| {
802        std::fs::copy(&plan.staged_exe, &plan.replacement_temp).map_err(|error| {
803            format!("could not copy staged rpi beside the current executable: {error}")
804        })?;
805        validated_regular_file(&plan.replacement_temp, "replacement rpi executable")?;
806        let copied_version = validate_rpi_executable_version(
807            &plan.replacement_temp,
808            plan.target_exe
809                .parent()
810                .ok_or_else(|| "current executable has no parent directory".to_string())?,
811            crate::VERSION,
812            "replacement rpi",
813        )?;
814        if copied_version != staged_version
815            || !files_are_identical(&plan.staged_exe, &plan.replacement_temp)?
816        {
817            return Err(
818                "replacement rpi does not match the validated staged executable".to_string(),
819            );
820        }
821
822        std::fs::hard_link(&plan.target_exe, &plan.backup_file)
823            .map_err(|error| format!("could not preserve the current rpi executable: {error}"))?;
824        let installed_version = validate_rpi_executable_version(
825            &plan.backup_file,
826            plan.target_exe
827                .parent()
828                .ok_or_else(|| "current executable has no parent directory".to_string())?,
829            crate::VERSION,
830            "current rpi",
831        )?;
832        if staged_version < installed_version {
833            return Err(format!(
834                "refusing to replace rpi {installed_version} with older version {staged_version}"
835            ));
836        }
837        if !files_are_identical(&plan.target_exe, &plan.backup_file)? {
838            return Err("current rpi executable changed while preparing the update".to_string());
839        }
840
841        std::fs::rename(&plan.replacement_temp, &plan.target_exe).map_err(|error| {
842            format!("could not atomically replace the current rpi executable: {error}")
843        })?;
844        Ok(())
845    })();
846
847    if let Err(error) = result {
848        let _ = remove_file_if_exists(&plan.replacement_temp);
849        let _ = remove_file_if_exists(&plan.backup_file);
850        return Err(error);
851    }
852
853    let validation = (|| {
854        if !files_are_identical(&plan.staged_exe, &plan.target_exe)? {
855            return Err("updated rpi executable does not match the staged executable".to_string());
856        }
857        let updated_version = validate_rpi_executable_version(
858            &plan.target_exe,
859            plan.target_exe
860                .parent()
861                .ok_or_else(|| "current executable has no parent directory".to_string())?,
862            crate::VERSION,
863            "updated rpi",
864        )?;
865        if updated_version != staged_version {
866            return Err(format!(
867                "updated rpi reported version {updated_version}, expected {staged_version}"
868            ));
869        }
870        Ok(())
871    })();
872
873    if let Err(error) = validation {
874        return Err(rollback_direct_update(plan, &error));
875    }
876    let _ = remove_file_if_exists(&plan.backup_file);
877    Ok(staged_version)
878}
879
880#[cfg(not(windows))]
881fn preflight_direct_replacement(plan: &DirectUpdatePlan) -> Result<(), String> {
882    for path in [&plan.replacement_temp, &plan.backup_file] {
883        if path.exists() {
884            return Err(format!(
885                "self-update path already exists: {}",
886                path.display()
887            ));
888        }
889        let file = std::fs::OpenOptions::new()
890            .write(true)
891            .create_new(true)
892            .open(path)
893            .map_err(|error| format!("cannot write beside the current executable: {error}"))?;
894        drop(file);
895        std::fs::remove_file(path)
896            .map_err(|error| format!("could not remove self-update probe: {error}"))?;
897    }
898    Ok(())
899}
900
901#[cfg(not(windows))]
902fn rollback_direct_update(plan: &DirectUpdatePlan, failure: &str) -> String {
903    let _ = remove_file_if_exists(&plan.replacement_temp);
904    match std::fs::rename(&plan.backup_file, &plan.target_exe) {
905        Ok(()) => format!("{failure}; restored the previous rpi executable"),
906        Err(rollback) => format!(
907            "{failure}; automatic rollback failed ({rollback}); the previous executable remains at {}",
908            plan.backup_file.display()
909        ),
910    }
911}
912
913#[cfg(not(windows))]
914fn remove_file_if_exists(path: &Path) -> Result<(), String> {
915    match std::fs::remove_file(path) {
916        Ok(()) => Ok(()),
917        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
918        Err(error) => Err(error.to_string()),
919    }
920}
921
922#[cfg(not(windows))]
923fn files_are_identical(left: &Path, right: &Path) -> Result<bool, String> {
924    use std::io::{BufReader, Read};
925
926    let left_file = std::fs::File::open(left)
927        .map_err(|error| format!("could not open {}: {error}", left.display()))?;
928    let right_file = std::fs::File::open(right)
929        .map_err(|error| format!("could not open {}: {error}", right.display()))?;
930    if left_file
931        .metadata()
932        .map_err(|error| error.to_string())?
933        .len()
934        != right_file
935            .metadata()
936            .map_err(|error| error.to_string())?
937            .len()
938    {
939        return Ok(false);
940    }
941    let mut left = BufReader::new(left_file);
942    let mut right = BufReader::new(right_file);
943    let mut left_buffer = [0u8; 64 * 1024];
944    let mut right_buffer = [0u8; 64 * 1024];
945    loop {
946        let left_read = left
947            .read(&mut left_buffer)
948            .map_err(|error| error.to_string())?;
949        let right_read = right
950            .read(&mut right_buffer)
951            .map_err(|error| error.to_string())?;
952        if left_read != right_read || left_buffer[..left_read] != right_buffer[..right_read] {
953            return Ok(false);
954        }
955        if left_read == 0 {
956            return Ok(true);
957        }
958    }
959}
960
961#[cfg(windows)]
962#[derive(Debug, Clone)]
963struct WindowsUpdatePlan {
964    staging_dir: PathBuf,
965    staged_exe: PathBuf,
966    target_exe: PathBuf,
967    helper_script: PathBuf,
968    status_file: PathBuf,
969    replacement_temp: PathBuf,
970    backup_file: PathBuf,
971    rollback_displaced_file: PathBuf,
972}
973
974#[cfg(windows)]
975struct WindowsUpdateStaging {
976    directory: tempfile::TempDir,
977    status_file: PathBuf,
978}
979
980#[cfg(windows)]
981const WINDOWS_UPDATE_HELPER: &str = r#"param(
982    [Parameter(Mandatory = $true)][long] $ParentPid,
983    [Parameter(Mandatory = $true)][string] $StagingPath,
984    [Parameter(Mandatory = $true)][string] $StagedPath,
985    [Parameter(Mandatory = $true)][string] $StagedVersion,
986    [Parameter(Mandatory = $true)][string] $TargetPath,
987    [Parameter(Mandatory = $true)][string] $TempPath,
988    [Parameter(Mandatory = $true)][string] $BackupPath,
989    [Parameter(Mandatory = $true)][string] $RollbackDisplacedPath,
990    [Parameter(Mandatory = $true)][string] $StatusPath
991)
992
993Set-StrictMode -Version Latest
994$ErrorActionPreference = 'Stop'
995
996function Write-UpdateStatus {
997    param([string] $State, [string] $Message)
998    $record = [ordered]@{
999        state = $State
1000        message = $Message
1001        parentPid = $ParentPid
1002        staging = $StagingPath
1003        target = $TargetPath
1004        staged = $StagedPath
1005        stagedVersion = $StagedVersion
1006        backup = $BackupPath
1007        rollbackDisplaced = $RollbackDisplacedPath
1008        updatedAt = [DateTime]::UtcNow.ToString('o')
1009    }
1010    $json = $record | ConvertTo-Json -Compress
1011    $statusTemp = $StatusPath + '.tmp'
1012    [IO.File]::WriteAllText($statusTemp, $json, [Text.UTF8Encoding]::new($false))
1013    Move-Item -LiteralPath $statusTemp -Destination $StatusPath -Force
1014}
1015
1016function Assert-RegularFile {
1017    param([string] $Path, [string] $Label)
1018    $item = Get-Item -LiteralPath $Path -Force
1019    if ($item.PSIsContainer -or $item.Length -le 0 -or
1020        (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
1021        throw "$Label is not a non-empty regular file"
1022    }
1023}
1024
1025function Assert-WindowsExecutable {
1026    param([string] $Path, [string] $Label)
1027    Assert-RegularFile -Path $Path -Label $Label
1028    $stream = [IO.File]::OpenRead($Path)
1029    try {
1030        if ($stream.ReadByte() -ne 77 -or $stream.ReadByte() -ne 90) {
1031            throw "$Label is not a Windows executable"
1032        }
1033    } finally {
1034        $stream.Dispose()
1035    }
1036}
1037
1038function Assert-RealDirectory {
1039    param([string] $Path, [string] $Label)
1040    $item = Get-Item -LiteralPath $Path -Force
1041    if (-not $item.PSIsContainer -or
1042        (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
1043        throw "$Label is not a real directory"
1044    }
1045}
1046
1047function Get-FileSha256 {
1048    param([string] $Path)
1049    $stream = [IO.File]::OpenRead($Path)
1050    try {
1051        $sha256 = [Security.Cryptography.SHA256]::Create()
1052        try {
1053            return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '')
1054        } finally {
1055            $sha256.Dispose()
1056        }
1057    } finally {
1058        $stream.Dispose()
1059    }
1060}
1061
1062function Remove-StagingPayload {
1063    try {
1064        $root = [IO.Path]::GetFullPath($StagingPath)
1065        Assert-RealDirectory -Path $root -Label 'self-update staging directory'
1066        $bin = [IO.Path]::Combine($root, 'bin')
1067        $helper = [IO.Path]::Combine($root, 'apply-update.ps1')
1068        foreach ($file in @(
1069            $StagedPath,
1070            [IO.Path]::Combine($root, '.crates.toml'),
1071            [IO.Path]::Combine($root, '.crates2.json'),
1072            $helper
1073        )) {
1074            if ([IO.File]::Exists($file)) {
1075                [IO.File]::Delete($file)
1076            }
1077        }
1078        if ([IO.Directory]::Exists($bin) -and
1079            [IO.Directory]::GetFileSystemEntries($bin).Length -eq 0) {
1080            [IO.Directory]::Delete($bin, $false)
1081        }
1082        if ([IO.Directory]::Exists($root) -and
1083            [IO.Directory]::GetFileSystemEntries($root).Length -eq 0) {
1084            [IO.Directory]::Delete($root, $false)
1085        }
1086    } catch {
1087        # Cleanup is best-effort; update status and recovery files stay intact.
1088    }
1089}
1090
1091$replaced = $false
1092try {
1093    $stagingFull = [IO.Path]::GetFullPath($StagingPath)
1094    $stagedFull = [IO.Path]::GetFullPath($StagedPath)
1095    $targetFull = [IO.Path]::GetFullPath($TargetPath)
1096    $tempFull = [IO.Path]::GetFullPath($TempPath)
1097    $backupFull = [IO.Path]::GetFullPath($BackupPath)
1098    $rollbackDisplacedFull = [IO.Path]::GetFullPath($RollbackDisplacedPath)
1099    $helperFull = [IO.Path]::Combine($stagingFull, 'apply-update.ps1')
1100    $statusFull = [IO.Path]::GetFullPath($StatusPath)
1101    $targetDirectory = [IO.Path]::GetDirectoryName($targetFull)
1102    if (-not [StringComparer]::OrdinalIgnoreCase.Equals(
1103            [IO.Path]::GetDirectoryName([IO.Path]::GetDirectoryName($stagedFull)), $stagingFull) -or
1104        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1105            [IO.Path]::GetDirectoryName($helperFull), $stagingFull) -or
1106        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1107            [IO.Path]::GetFullPath($PSScriptRoot), $stagingFull) -or
1108        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1109            [IO.Path]::GetDirectoryName($statusFull), [IO.Path]::GetDirectoryName($stagingFull)) -or
1110        [string]::IsNullOrWhiteSpace($targetDirectory) -or
1111        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1112            [IO.Path]::GetDirectoryName($tempFull), $targetDirectory) -or
1113        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1114            [IO.Path]::GetDirectoryName($backupFull), $targetDirectory) -or
1115        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1116            [IO.Path]::GetDirectoryName($rollbackDisplacedFull), $targetDirectory)) {
1117        throw 'self-update paths are outside their validated staging or target directories'
1118    }
1119
1120    Assert-RealDirectory -Path $stagingFull -Label 'self-update staging directory'
1121    Assert-RealDirectory -Path $targetDirectory -Label 'target directory'
1122    Assert-WindowsExecutable -Path $stagedFull -Label 'staged executable'
1123    Assert-WindowsExecutable -Path $targetFull -Label 'current executable'
1124    $expectedHash = Get-FileSha256 -Path $stagedFull
1125    $originalTargetHash = Get-FileSha256 -Path $targetFull
1126    if ([IO.File]::Exists($tempFull) -or [IO.File]::Exists($backupFull) -or
1127        [IO.File]::Exists($rollbackDisplacedFull)) {
1128        throw 'replacement, backup, or rollback path already exists'
1129    }
1130
1131    Write-UpdateStatus -State 'waiting' -Message 'waiting for the current rpi process to exit'
1132    if ($ParentPid -lt 1 -or $ParentPid -gt [int]::MaxValue) {
1133        throw 'parent process id is outside the supported Windows range'
1134    }
1135    $parentProcess = $null
1136    try {
1137        $candidate = [Diagnostics.Process]::GetProcessById([int]$ParentPid)
1138        try {
1139            $candidatePath = [IO.Path]::GetFullPath($candidate.MainModule.FileName)
1140            if ([StringComparer]::OrdinalIgnoreCase.Equals($candidatePath, $targetFull)) {
1141                $parentProcess = $candidate
1142            } else {
1143                $candidate.Dispose()
1144            }
1145        } catch {
1146            if ($candidate.HasExited) {
1147                $candidate.Dispose()
1148            } else {
1149                throw
1150            }
1151        }
1152    } catch [ArgumentException] {
1153        # The invoking process exited before the helper acquired its handle.
1154    }
1155    if ($null -ne $parentProcess) {
1156        try {
1157            if (-not $parentProcess.WaitForExit(600000)) {
1158                throw 'timed out waiting for the current rpi process to exit'
1159            }
1160        } finally {
1161            $parentProcess.Dispose()
1162        }
1163    }
1164
1165    Assert-RealDirectory -Path $targetDirectory -Label 'target directory'
1166    Assert-WindowsExecutable -Path $stagedFull -Label 'staged executable'
1167    Assert-WindowsExecutable -Path $targetFull -Label 'current executable'
1168    if ((Get-FileSha256 -Path $stagedFull) -ne $expectedHash) {
1169        throw 'staged executable changed while waiting for rpi to exit'
1170    }
1171    if ((Get-FileSha256 -Path $targetFull) -ne $originalTargetHash) {
1172        throw 'current executable changed while waiting for rpi to exit'
1173    }
1174    if ([IO.File]::Exists($tempFull) -or [IO.File]::Exists($backupFull) -or
1175        [IO.File]::Exists($rollbackDisplacedFull)) {
1176        throw 'replacement, backup, or rollback path appeared while waiting for rpi to exit'
1177    }
1178
1179    [IO.File]::Copy($stagedFull, $tempFull, $false)
1180    Assert-WindowsExecutable -Path $tempFull -Label 'replacement executable'
1181    if ((Get-FileSha256 -Path $tempFull) -ne $expectedHash) {
1182        throw 'replacement executable hash mismatch'
1183    }
1184
1185    [IO.File]::Replace($tempFull, $targetFull, $backupFull, $true)
1186    $replaced = $true
1187    Assert-WindowsExecutable -Path $targetFull -Label 'updated executable'
1188    if ((Get-FileSha256 -Path $targetFull) -ne $expectedHash) {
1189        throw 'updated executable hash mismatch'
1190    }
1191} catch {
1192    $failureMessage = $_.Exception.Message
1193    $recoveryMessage = ''
1194    if ($replaced -and [IO.File]::Exists($BackupPath)) {
1195        try {
1196            [IO.File]::Copy($BackupPath, $TempPath, $false)
1197            Assert-WindowsExecutable -Path $TempPath -Label 'rollback executable'
1198            if ((Get-FileSha256 -Path $TempPath) -ne $originalTargetHash) {
1199                throw 'rollback executable hash mismatch'
1200            }
1201            [IO.File]::Replace($TempPath, $TargetPath, $RollbackDisplacedPath, $true)
1202            Assert-WindowsExecutable -Path $TargetPath -Label 'restored executable'
1203            if ((Get-FileSha256 -Path $TargetPath) -ne $originalTargetHash) {
1204                throw 'restored executable hash mismatch'
1205            }
1206            $replaced = $false
1207            try { [IO.File]::Delete($BackupPath) } catch {}
1208            try { [IO.File]::Delete($RollbackDisplacedPath) } catch {}
1209        } catch {
1210            $rollbackFailure = $_.Exception.Message
1211            if ([IO.File]::Exists($BackupPath)) {
1212                $recoveryMessage = "; automatic rollback failed ($rollbackFailure); the original executable remains at $BackupPath"
1213            } else {
1214                $recoveryMessage = "; automatic rollback failed ($rollbackFailure) and no verified recovery backup remains"
1215            }
1216        }
1217    }
1218    if ([IO.File]::Exists($TempPath)) {
1219        try { Remove-Item -LiteralPath $TempPath -Force } catch {}
1220    }
1221    try { Write-UpdateStatus -State 'failed' -Message ($failureMessage + $recoveryMessage) } catch {}
1222    Remove-StagingPayload
1223    exit 1
1224}
1225
1226try { Write-UpdateStatus -State 'succeeded' -Message 'rpi executable replaced successfully' } catch {}
1227if ([IO.File]::Exists($BackupPath)) {
1228    try { Remove-Item -LiteralPath $BackupPath -Force } catch {}
1229}
1230Remove-StagingPayload
1231exit 0
1232"#;
1233
1234#[cfg(windows)]
1235fn run_windows_self_update() -> i32 {
1236    let staging = match create_windows_update_staging() {
1237        Ok(staging) => staging,
1238        Err(error) => {
1239            eprintln!("error: could not create self-update staging directory: {error}");
1240            return 1;
1241        }
1242    };
1243    let staging_dir = staging.directory.path().to_path_buf();
1244    let status_file = staging.status_file.clone();
1245    let _ = write_windows_update_status(
1246        &status_file,
1247        "preparing",
1248        "installing the update into staging",
1249    );
1250
1251    let current_exe = match std::env::current_exe() {
1252        Ok(path) => path,
1253        Err(error) => {
1254            let message = format!("could not resolve the current rpi executable: {error}");
1255            let _ = write_windows_update_status(&status_file, "failed", &message);
1256            eprintln!("error: {message}");
1257            return 1;
1258        }
1259    };
1260    let powershell = match system_powershell_path() {
1261        Ok(path) => path,
1262        Err(error) => {
1263            let _ = write_windows_update_status(&status_file, "failed", &error);
1264            eprintln!("error: {error}");
1265            return 1;
1266        }
1267    };
1268
1269    let command_dir = match create_self_update_command_dir() {
1270        Ok(directory) => directory,
1271        Err(error) => {
1272            let message = format!("could not create an isolated Cargo directory: {error}");
1273            let _ = write_windows_update_status(&status_file, "failed", &message);
1274            eprintln!("error: {message}");
1275            return 1;
1276        }
1277    };
1278    let mut cargo = match cargo_install_command(Some(&staging_dir), command_dir.path()) {
1279        Ok(command) => command,
1280        Err(error) => {
1281            let message = format!("invalid isolated Cargo directory: {error}");
1282            let _ = write_windows_update_status(&status_file, "failed", &message);
1283            eprintln!("error: {message}");
1284            return 1;
1285        }
1286    };
1287    let status = cargo.status();
1288    match status {
1289        Ok(status) if status.success() => {}
1290        Ok(status) => {
1291            let message = format!("cargo install exited with {status}");
1292            let _ = write_windows_update_status(&status_file, "failed", &message);
1293            eprintln!("error: {message}");
1294            return 1;
1295        }
1296        Err(error) => {
1297            let message = format!("could not run cargo (install Rust/Cargo first): {error}");
1298            let _ = write_windows_update_status(&status_file, "failed", &message);
1299            eprintln!("error: {message}");
1300            return 1;
1301        }
1302    }
1303
1304    let plan = match build_windows_update_plan(&staging_dir, &current_exe, &status_file) {
1305        Ok(plan) => plan,
1306        Err(error) => {
1307            let _ = write_windows_update_status(&status_file, "failed", &error);
1308            eprintln!("error: {error}");
1309            return 1;
1310        }
1311    };
1312    let staged_version = match validate_staged_rpi(&plan) {
1313        Ok(version) => version,
1314        Err(error) => {
1315            let _ = write_windows_update_status(&plan.status_file, "failed", &error);
1316            eprintln!("error: {error}");
1317            return 1;
1318        }
1319    };
1320    if let Err(error) = preflight_windows_replacement(&plan) {
1321        let _ = write_windows_update_status(&plan.status_file, "failed", &error);
1322        eprintln!("error: {error}");
1323        return 1;
1324    }
1325    if let Err(error) = std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER.as_bytes()) {
1326        let message = format!("could not write the self-update helper: {error}");
1327        let _ = write_windows_update_status(&plan.status_file, "failed", &message);
1328        eprintln!("error: {message}");
1329        return 1;
1330    }
1331    if validated_regular_file(&plan.helper_script, "self-update helper").is_err() {
1332        let message = "self-update helper did not pass regular-file validation";
1333        let _ = write_windows_update_status(&plan.status_file, "failed", message);
1334        eprintln!("error: {message}");
1335        return 1;
1336    }
1337    let _ = write_windows_update_status(
1338        &plan.status_file,
1339        "scheduled",
1340        &format!("validated rpi {staged_version}; waiting to replace rpi after this process exits"),
1341    );
1342    let mut command = windows_update_helper_command(
1343        &powershell,
1344        &plan,
1345        std::process::id(),
1346        &staged_version.to_string(),
1347    );
1348    use std::os::windows::process::CommandExt;
1349    command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
1350    match command.spawn() {
1351        Ok(_) => {
1352            let persisted_staging = staging.directory.keep();
1353            println!(
1354                "rpi update staged; it will be applied after this process exits\nStatus: {}",
1355                plan.status_file.display()
1356            );
1357            debug_assert!(windows_paths_equal(&persisted_staging, &plan.staging_dir));
1358            0
1359        }
1360        Err(error) => {
1361            let message = format!("could not start the self-update helper: {error}");
1362            let _ = write_windows_update_status(&plan.status_file, "failed", &message);
1363            eprintln!("error: {message}");
1364            1
1365        }
1366    }
1367}
1368
1369#[cfg(windows)]
1370fn create_windows_update_staging() -> Result<WindowsUpdateStaging, String> {
1371    let staging = create_self_update_staging_directory()?;
1372    let update_root = staging
1373        .path()
1374        .parent()
1375        .ok_or_else(|| "self-update staging directory has no parent".to_string())?;
1376    let status_file = update_root.join(format!("status-{}.json", uuid::Uuid::new_v4().simple()));
1377    if status_file.exists() {
1378        return Err("self-update status path collision".to_string());
1379    }
1380    Ok(WindowsUpdateStaging {
1381        directory: staging,
1382        status_file,
1383    })
1384}
1385
1386#[cfg(windows)]
1387fn build_windows_update_plan(
1388    staging_dir: &Path,
1389    target_exe: &Path,
1390    status_file: &Path,
1391) -> Result<WindowsUpdatePlan, String> {
1392    let staging_dir = validated_real_directory(staging_dir, "self-update staging directory")?;
1393    if !status_file.is_absolute() {
1394        return Err("self-update status path must be absolute".to_string());
1395    }
1396    let status_parent = validated_real_directory(
1397        status_file
1398            .parent()
1399            .ok_or_else(|| "self-update status path has no parent".to_string())?,
1400        "self-update status directory",
1401    )?;
1402    if staging_dir.parent() != Some(status_parent.as_path()) {
1403        return Err("self-update status file is outside the staging parent".to_string());
1404    }
1405    let status_name = status_file
1406        .file_name()
1407        .ok_or_else(|| "self-update status file has no name".to_string())?;
1408    if !is_self_update_status_name(status_name) {
1409        return Err("self-update status file has an invalid name".to_string());
1410    }
1411    if status_file.exists() {
1412        validated_regular_file(status_file, "self-update status file")?;
1413    }
1414    let staged_exe = validated_windows_executable(
1415        &staging_dir.join("bin").join("rpi.exe"),
1416        "staged rpi executable",
1417    )?;
1418    let target_exe = validated_windows_executable(target_exe, "current rpi executable")?;
1419    let target_dir = validated_real_directory(
1420        target_exe
1421            .parent()
1422            .ok_or_else(|| "current executable has no parent directory".to_string())?,
1423        "current executable directory",
1424    )?;
1425    if target_exe.parent() != Some(target_dir.as_path()) {
1426        return Err("current executable is not a direct child of its validated directory".into());
1427    }
1428    let token = uuid::Uuid::new_v4().simple().to_string();
1429    let replacement_temp = target_dir.join(format!(".rpi-update-{token}.new.exe"));
1430    let backup_file = target_dir.join(format!(".rpi-update-{token}.old.exe"));
1431    let rollback_displaced_file = target_dir.join(format!(".rpi-update-{token}.rollback-new.exe"));
1432    if replacement_temp.exists() || backup_file.exists() || rollback_displaced_file.exists() {
1433        return Err("self-update replacement path collision".to_string());
1434    }
1435
1436    Ok(WindowsUpdatePlan {
1437        staging_dir: staging_dir.clone(),
1438        staged_exe,
1439        target_exe,
1440        helper_script: staging_dir.join("apply-update.ps1"),
1441        status_file: status_file.to_path_buf(),
1442        replacement_temp,
1443        backup_file,
1444        rollback_displaced_file,
1445    })
1446}
1447
1448#[cfg(windows)]
1449fn preflight_windows_replacement(plan: &WindowsUpdatePlan) -> Result<(), String> {
1450    for path in [
1451        &plan.replacement_temp,
1452        &plan.backup_file,
1453        &plan.rollback_displaced_file,
1454    ] {
1455        if path.exists() {
1456            return Err(format!(
1457                "self-update path already exists: {}",
1458                path.display()
1459            ));
1460        }
1461        let file = std::fs::OpenOptions::new()
1462            .write(true)
1463            .create_new(true)
1464            .open(path)
1465            .map_err(|error| format!("cannot write beside the current executable: {error}"))?;
1466        drop(file);
1467        std::fs::remove_file(path)
1468            .map_err(|error| format!("could not remove self-update probe: {error}"))?;
1469    }
1470    Ok(())
1471}
1472
1473#[cfg(windows)]
1474fn validate_staged_rpi(plan: &WindowsUpdatePlan) -> Result<semver::Version, String> {
1475    let cwd = plan
1476        .helper_script
1477        .parent()
1478        .ok_or_else(|| "self-update staging directory is missing".to_string())?;
1479    validate_rpi_executable_version(&plan.staged_exe, cwd, crate::VERSION, "staged rpi")
1480}
1481
1482/// Captured output of a bounded self-update probe.
1483struct BoundedProcessOutput {
1484    status: std::process::ExitStatus,
1485    stdout: Vec<u8>,
1486    stderr: Vec<u8>,
1487}
1488
1489/// Run a short-lived probe under both a wall-clock and an output cap.
1490///
1491/// The child runs on its own thread and current-thread runtime so this stays
1492/// safe when `rpi update` is entered from inside a Tokio runtime. The old
1493/// implementation reused the npm bridge's process runner, which went away with
1494/// the Pi compatibility layer.
1495fn run_bounded_process_blocking(
1496    program: &str,
1497    args: &[String],
1498    cwd: &Path,
1499    timeout: std::time::Duration,
1500    max_output_bytes: usize,
1501) -> Result<BoundedProcessOutput, String> {
1502    let program = program.to_string();
1503    let args = args.to_vec();
1504    let cwd = cwd.to_path_buf();
1505    std::thread::Builder::new()
1506        .name("rpi-bounded-version-probe".to_string())
1507        .spawn(move || {
1508            let runtime = tokio::runtime::Builder::new_current_thread()
1509                .enable_all()
1510                .build()
1511                .map_err(|error| format!("probe runtime failed: {error}"))?;
1512            runtime.block_on(async move {
1513                let child = tokio::process::Command::new(&program)
1514                    .args(&args)
1515                    .current_dir(&cwd)
1516                    .stdin(std::process::Stdio::null())
1517                    .stdout(std::process::Stdio::piped())
1518                    .stderr(std::process::Stdio::piped())
1519                    .kill_on_drop(true)
1520                    .spawn()
1521                    .map_err(|error| format!("spawn failed: {error}"))?;
1522                let output = match tokio::time::timeout(timeout, child.wait_with_output()).await {
1523                    Ok(result) => result.map_err(|error| format!("wait failed: {error}"))?,
1524                    // Dropping the future drops the child, which `kill_on_drop`
1525                    // turns into a kill.
1526                    Err(_) => return Err(format!("timed out after {timeout:?}")),
1527                };
1528                Ok(BoundedProcessOutput {
1529                    status: output.status,
1530                    stdout: cap_bytes(output.stdout, max_output_bytes),
1531                    stderr: cap_bytes(output.stderr, max_output_bytes),
1532                })
1533            })
1534        })
1535        .map_err(|error| format!("probe thread failed: {error}"))?
1536        .join()
1537        .map_err(|_| "probe worker panicked".to_string())?
1538}
1539
1540fn cap_bytes(bytes: Vec<u8>, limit: usize) -> Vec<u8> {
1541    if bytes.len() <= limit {
1542        bytes
1543    } else {
1544        bytes[..limit].to_vec()
1545    }
1546}
1547
1548fn validate_rpi_executable_version(
1549    executable: &Path,
1550    cwd: &Path,
1551    current: &str,
1552    label: &str,
1553) -> Result<semver::Version, String> {
1554    let program = executable
1555        .to_str()
1556        .ok_or_else(|| format!("{label} path cannot be represented as Unicode"))?;
1557    let args = vec!["--version".to_string()];
1558    let output = run_bounded_process_blocking(
1559        program,
1560        &args,
1561        cwd,
1562        STAGED_RPI_CHECK_TIMEOUT,
1563        MAX_STAGED_RPI_OUTPUT_BYTES,
1564    )
1565    .map_err(|error| format!("{label} version check failed: {error}"))?;
1566    if !output.status.success() {
1567        return Err(format!("{label} --version exited with {}", output.status));
1568    }
1569    validate_staged_rpi_version_output(&output.stdout, &output.stderr, current)
1570}
1571
1572fn validate_staged_rpi_version_output(
1573    stdout: &[u8],
1574    stderr: &[u8],
1575    current: &str,
1576) -> Result<semver::Version, String> {
1577    if !stderr.is_empty() {
1578        return Err("staged rpi --version wrote unexpected stderr output".to_string());
1579    }
1580    let staged = parse_rpi_version_output(stdout)
1581        .ok_or_else(|| "staged rpi returned an invalid --version response".to_string())?;
1582    let current = semver::Version::parse(current)
1583        .map_err(|_| "the running rpi version is not valid semantic versioning".to_string())?;
1584    if staged < current {
1585        return Err(format!(
1586            "refusing to replace rpi {current} with older version {staged}"
1587        ));
1588    }
1589    Ok(staged)
1590}
1591
1592fn parse_rpi_version_output(output: &[u8]) -> Option<semver::Version> {
1593    let output = std::str::from_utf8(output).ok()?;
1594    let line = output
1595        .strip_suffix("\r\n")
1596        .or_else(|| output.strip_suffix('\n'))
1597        .unwrap_or(output);
1598    if line.contains(['\r', '\n']) {
1599        return None;
1600    }
1601    let version = line.strip_prefix(crate::APP_NAME)?.strip_prefix(' ')?;
1602    if version.is_empty() || version.chars().any(char::is_whitespace) {
1603        return None;
1604    }
1605    semver::Version::parse(version).ok()
1606}
1607
1608/// A real directory *by its own entry*: the leaf is not a link, so the update
1609/// is free to write into it. Links further up are resolved rather than
1610/// refused — a launch path such as `C:\tools\current\rpi.exe` where `current`
1611/// is a junction names the real install directory, which is exactly what an
1612/// update should rewrite.
1613fn validated_real_directory(path: &Path, label: &str) -> Result<PathBuf, String> {
1614    if !path.is_absolute() {
1615        return Err(format!("{label} must be absolute"));
1616    }
1617    let metadata = std::fs::symlink_metadata(path)
1618        .map_err(|error| format!("could not inspect {label}: {error}"))?;
1619    if metadata.file_type().is_symlink() {
1620        return Err(format!(
1621            "{label} is a link or junction, not a real directory ({} -> {})",
1622            path.display(),
1623            canonical_display(path),
1624        ));
1625    }
1626    if !metadata.is_dir() {
1627        return Err(format!("{label} is not a real directory"));
1628    }
1629    std::fs::canonicalize(path)
1630        .map(normalize_windows_path)
1631        .map_err(|error| format!("could not canonicalize {label}: {error}"))
1632}
1633
1634/// A non-empty regular file *by its own entry*: a link to a file is refused
1635/// with both paths named, because replacing the link would leave the real
1636/// target stale. Ancestor links are resolved, as in
1637/// [`validated_real_directory`].
1638fn validated_regular_file(path: &Path, label: &str) -> Result<PathBuf, String> {
1639    if !path.is_absolute() {
1640        return Err(format!("{label} must be absolute"));
1641    }
1642    let metadata = std::fs::symlink_metadata(path)
1643        .map_err(|error| format!("could not inspect {label}: {error}"))?;
1644    if metadata.file_type().is_symlink() {
1645        return Err(format!(
1646            "{label} is a link or junction, not a non-empty regular file ({} -> {})",
1647            path.display(),
1648            canonical_display(path),
1649        ));
1650    }
1651    if !metadata.is_file() || metadata.len() == 0 {
1652        return Err(format!("{label} is not a non-empty regular file"));
1653    }
1654    std::fs::canonicalize(path)
1655        .map(normalize_windows_path)
1656        .map_err(|error| format!("could not canonicalize {label}: {error}"))
1657}
1658
1659fn is_self_update_status_name(name: &std::ffi::OsStr) -> bool {
1660    let Some(name) = name.to_str() else {
1661        return false;
1662    };
1663    let Some(token) = name
1664        .strip_prefix("status-")
1665        .and_then(|name| name.strip_suffix(".json"))
1666    else {
1667        return false;
1668    };
1669    token.len() == 32 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
1670}
1671
1672fn validated_self_update_status_file(update_root: &Path, path: &Path) -> Result<PathBuf, String> {
1673    if !path.is_absolute() || path.parent() != Some(update_root) {
1674        return Err("self-update status file is outside the update root".to_string());
1675    }
1676    if !path.file_name().is_some_and(is_self_update_status_name) {
1677        return Err("self-update status file has an invalid name".to_string());
1678    }
1679    let path = validated_regular_file(path, "self-update status file")?;
1680    let length = std::fs::metadata(&path)
1681        .map_err(|error| format!("could not inspect self-update status file: {error}"))?
1682        .len();
1683    if length > MAX_SELF_UPDATE_STATUS_BYTES {
1684        return Err("self-update status file is too large".to_string());
1685    }
1686    Ok(path)
1687}
1688
1689fn read_self_update_status(path: &Path) -> Option<SelfUpdateStatus> {
1690    use std::io::Read;
1691
1692    let file = std::fs::File::open(path).ok()?;
1693    let mut data = Vec::with_capacity(
1694        usize::try_from(MAX_SELF_UPDATE_STATUS_BYTES)
1695            .unwrap_or_default()
1696            .min(8 * 1024),
1697    );
1698    file.take(MAX_SELF_UPDATE_STATUS_BYTES.saturating_add(1))
1699        .read_to_end(&mut data)
1700        .ok()?;
1701    if data.len() as u64 > MAX_SELF_UPDATE_STATUS_BYTES {
1702        return None;
1703    }
1704    serde_json::from_slice(&data).ok()
1705}
1706
1707fn sanitized_self_update_status_message(message: &str) -> String {
1708    let mut sanitized = String::new();
1709    let mut pending_space = false;
1710    let mut truncated = false;
1711    for character in message.trim().chars() {
1712        if character.is_control() || character.is_whitespace() {
1713            pending_space = !sanitized.is_empty();
1714            continue;
1715        }
1716        if sanitized.chars().count() >= MAX_SELF_UPDATE_STATUS_MESSAGE_CHARS {
1717            truncated = true;
1718            break;
1719        }
1720        if pending_space {
1721            sanitized.push(' ');
1722            pending_space = false;
1723        }
1724        sanitized.push(character);
1725    }
1726    if sanitized.is_empty() {
1727        return "no diagnostic message was recorded".to_string();
1728    }
1729    if truncated {
1730        sanitized.push_str("...");
1731    }
1732    sanitized
1733}
1734
1735fn consume_self_update_statuses(agent_dir: &Path) -> Vec<UpdateWarning> {
1736    let _guard = SELF_UPDATE_STATUS_LOCK
1737        .lock()
1738        .unwrap_or_else(std::sync::PoisonError::into_inner);
1739    let agent_dir = match validated_real_directory(agent_dir, "agent directory") {
1740        Ok(path) => path,
1741        Err(_) => return Vec::new(),
1742    };
1743    let update_root = match validated_real_directory(
1744        &agent_dir.join("self-update"),
1745        "self-update status directory",
1746    ) {
1747        Ok(path) if path.parent() == Some(agent_dir.as_path()) => path,
1748        _ => return Vec::new(),
1749    };
1750    let entries = match std::fs::read_dir(&update_root) {
1751        Ok(entries) => entries,
1752        Err(_) => return Vec::new(),
1753    };
1754
1755    let mut warnings = Vec::new();
1756    for entry in entries.flatten() {
1757        if !is_self_update_status_name(&entry.file_name()) {
1758            continue;
1759        }
1760        let path = match validated_self_update_status_file(&update_root, &entry.path()) {
1761            Ok(path) => path,
1762            Err(_) => continue,
1763        };
1764        let Some(status) = read_self_update_status(&path) else {
1765            continue;
1766        };
1767        if !matches!(status.state.as_str(), "failed" | "succeeded") {
1768            continue;
1769        }
1770
1771        match std::fs::remove_file(&path) {
1772            Ok(()) => {}
1773            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1774            // A failure is still actionable even when cleanup is denied. Keep
1775            // the file so a later startup can retry consumption.
1776            Err(_) if status.state == "failed" => {}
1777            Err(_) => continue,
1778        }
1779        if status.state == "failed" {
1780            warnings.push(UpdateWarning {
1781                message: format!(
1782                    "The previously scheduled rpi update failed: {}",
1783                    sanitized_self_update_status_message(&status.message)
1784                ),
1785                command: "rpi update".to_string(),
1786            });
1787        }
1788    }
1789    warnings
1790}
1791
1792#[cfg(windows)]
1793fn validated_windows_executable(path: &Path, label: &str) -> Result<PathBuf, String> {
1794    use std::io::Read;
1795
1796    let path = validated_regular_file(path, label)?;
1797    let mut file =
1798        std::fs::File::open(&path).map_err(|error| format!("could not open {label}: {error}"))?;
1799    let mut magic = [0u8; 2];
1800    file.read_exact(&mut magic)
1801        .map_err(|error| format!("could not read {label}: {error}"))?;
1802    if magic != *b"MZ" {
1803        return Err(format!("{label} is not a Windows executable"));
1804    }
1805    Ok(path)
1806}
1807
1808#[cfg(windows)]
1809fn system_powershell_path() -> Result<PathBuf, String> {
1810    use std::ffi::OsString;
1811    use std::os::windows::ffi::OsStringExt;
1812    use windows_sys::Win32::System::SystemInformation::GetWindowsDirectoryW;
1813
1814    let mut buffer = vec![0u16; 260];
1815    let system_root = loop {
1816        let length = unsafe {
1817            GetWindowsDirectoryW(
1818                buffer.as_mut_ptr(),
1819                u32::try_from(buffer.len()).unwrap_or(u32::MAX),
1820            )
1821        };
1822        if length == 0 {
1823            return Err(format!(
1824                "could not locate the Windows system directory: {}",
1825                std::io::Error::last_os_error()
1826            ));
1827        }
1828        let length = usize::try_from(length)
1829            .map_err(|error| format!("invalid Windows system directory length: {error}"))?;
1830        if length < buffer.len() {
1831            buffer.truncate(length);
1832            break PathBuf::from(OsString::from_wide(&buffer));
1833        }
1834        buffer.resize(length.saturating_add(1), 0);
1835    };
1836    let system_root = validated_real_directory(&system_root, "Windows system root")?;
1837    let powershell = validated_windows_executable(
1838        &system_root.join("System32/WindowsPowerShell/v1.0/powershell.exe"),
1839        "Windows PowerShell",
1840    )?;
1841    powershell
1842        .strip_prefix(&system_root)
1843        .map_err(|_| "Windows PowerShell is outside SystemRoot".to_string())?;
1844    Ok(powershell)
1845}
1846
1847#[cfg(windows)]
1848fn windows_update_helper_command(
1849    powershell: &Path,
1850    plan: &WindowsUpdatePlan,
1851    parent_pid: u32,
1852    staged_version: &str,
1853) -> std::process::Command {
1854    let mut command = std::process::Command::new(powershell);
1855    let powershell_modules = powershell
1856        .parent()
1857        .expect("validated PowerShell path has a parent")
1858        .join("Modules");
1859    command
1860        .args([
1861            "-NoLogo",
1862            "-NoProfile",
1863            "-NonInteractive",
1864            "-ExecutionPolicy",
1865            "Bypass",
1866            "-File",
1867        ])
1868        .arg(&plan.helper_script)
1869        .arg("-ParentPid")
1870        .arg(parent_pid.to_string())
1871        .arg("-StagingPath")
1872        .arg(&plan.staging_dir)
1873        .arg("-StagedPath")
1874        .arg(&plan.staged_exe)
1875        .arg("-StagedVersion")
1876        .arg(staged_version)
1877        .arg("-TargetPath")
1878        .arg(&plan.target_exe)
1879        .arg("-TempPath")
1880        .arg(&plan.replacement_temp)
1881        .arg("-BackupPath")
1882        .arg(&plan.backup_file)
1883        .arg("-RollbackDisplacedPath")
1884        .arg(&plan.rollback_displaced_file)
1885        .arg("-StatusPath")
1886        .arg(&plan.status_file)
1887        .current_dir(
1888            plan.status_file
1889                .parent()
1890                .expect("validated status path has an update-root parent"),
1891        )
1892        .env("PSModulePath", powershell_modules)
1893        .stdin(Stdio::null())
1894        .stdout(Stdio::null())
1895        .stderr(Stdio::null());
1896    command
1897}
1898
1899#[cfg(windows)]
1900fn write_windows_update_status(path: &Path, state: &str, message: &str) -> Result<(), String> {
1901    let data = serde_json::to_vec_pretty(&serde_json::json!({
1902        "state": state,
1903        "message": message,
1904        "parentPid": std::process::id(),
1905        "currentVersion": crate::VERSION,
1906        "updatedAtMs": now_ms(),
1907    }))
1908    .map_err(|error| error.to_string())?;
1909    std::fs::write(path, data)
1910        .map_err(|error| format!("could not write update status {}: {error}", path.display()))
1911}
1912
1913#[cfg(test)]
1914mod tests {
1915    /// Fixtures may live under an ambient TEMP that is itself a junction
1916    /// (e.g. `D:\Temp` on some Windows hosts). Ancestor links are resolved by
1917    /// the path validation rather than refused, so a plain `tempdir()` is a
1918    /// valid fixture — and using one keeps these tests honest about that.
1919    fn real_tempdir() -> tempfile::TempDir {
1920        tempfile::Builder::new()
1921            .prefix("rpi-update-test-")
1922            .tempdir()
1923            .unwrap()
1924    }
1925
1926    use std::collections::BTreeMap;
1927    use std::ffi::OsString;
1928    use std::path::Path;
1929    use std::sync::atomic::{AtomicUsize, Ordering};
1930    use std::sync::Arc;
1931
1932    #[cfg(unix)]
1933    use super::{activate_direct_update, build_direct_update_plan};
1934    #[cfg(windows)]
1935    use super::{
1936        build_windows_update_plan, preflight_windows_replacement, system_powershell_path,
1937        windows_update_helper_command, write_windows_update_status, WINDOWS_UPDATE_HELPER,
1938    };
1939    use super::{
1940        cargo_home_is_project_local, cargo_install_command, collect_bounded,
1941        create_self_update_command_dir, fetch_crates_url, is_newer, parse_rpi_version_output,
1942        path_is_within, results_with_individual_cache_fallback, validate_staged_rpi_version_output,
1943        RegistryLookup, CACHE_FALLBACK_MAX_AGE_MS, UPDATE_CHECK_CONCURRENCY,
1944    };
1945
1946    struct RestoreConfigDir(Option<OsString>);
1947
1948    impl Drop for RestoreConfigDir {
1949        fn drop(&mut self) {
1950            match self.0.take() {
1951                Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
1952                None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
1953            }
1954        }
1955    }
1956
1957    struct RestoreEnv {
1958        name: &'static str,
1959        value: Option<OsString>,
1960    }
1961
1962    impl RestoreEnv {
1963        fn capture(name: &'static str) -> Self {
1964            Self {
1965                name,
1966                value: std::env::var_os(name),
1967            }
1968        }
1969    }
1970
1971    impl Drop for RestoreEnv {
1972        fn drop(&mut self) {
1973            match self.value.take() {
1974                Some(value) => std::env::set_var(self.name, value),
1975                None => std::env::remove_var(self.name),
1976            }
1977        }
1978    }
1979
1980    #[cfg(windows)]
1981    fn fake_windows_executable(path: &Path, marker: &[u8]) {
1982        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1983        let mut bytes = b"MZ".to_vec();
1984        bytes.extend_from_slice(marker);
1985        std::fs::write(path, bytes).unwrap();
1986    }
1987
1988    #[cfg(unix)]
1989    fn fake_unix_rpi(path: &Path, version: &str, fail_after_rename_to: Option<&str>) {
1990        use std::os::unix::fs::PermissionsExt;
1991
1992        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1993        let failure = fail_after_rename_to.map_or_else(String::new, |name| {
1994            format!(
1995                "case \"$0\" in\n  */{name}) echo 'post-replace failure' >&2; exit 1 ;;\nesac\n"
1996            )
1997        });
1998        std::fs::write(
1999            path,
2000            format!("#!/bin/sh\n{failure}printf 'rpi {version}\\n'\n"),
2001        )
2002        .unwrap();
2003        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2004    }
2005
2006    fn write_test_update_status(path: &Path, state: &str, message: &str) {
2007        std::fs::write(
2008            path,
2009            serde_json::to_vec(&serde_json::json!({
2010                "state": state,
2011                "message": message,
2012            }))
2013            .unwrap(),
2014        )
2015        .unwrap();
2016    }
2017
2018    #[test]
2019    fn path_validation_resolves_ancestor_links_but_refuses_a_link_leaf() {
2020        let temp = real_tempdir();
2021        // Real targets, plus a link directory that points at one of them.
2022        let real_dir = temp.path().join("real-dir");
2023        let real_file = temp.path().join("real-file.exe");
2024        std::fs::create_dir_all(&real_dir).unwrap();
2025        std::fs::write(&real_file, b"payload").unwrap();
2026
2027        let link_dir = temp.path().join("link-dir");
2028        let link_file = temp.path().join("link-file.exe");
2029        #[cfg(unix)]
2030        let linked = {
2031            std::os::unix::fs::symlink(&real_dir, &link_dir).unwrap();
2032            std::os::unix::fs::symlink(&real_file, &link_file).unwrap();
2033            true
2034        };
2035        #[cfg(windows)]
2036        let linked = {
2037            // Junctions need no elevation; file symlinks do, so skip the file
2038            // half when this process cannot create one.
2039            let dir_ok = std::process::Command::new("cmd")
2040                .args(["/c", "mklink", "/J"])
2041                .arg(&link_dir)
2042                .arg(&real_dir)
2043                .output()
2044                .map(|out| out.status.success())
2045                .unwrap_or(false);
2046            if dir_ok {
2047                let file_ok = std::os::windows::fs::symlink_file(&real_file, &link_file).is_ok();
2048                if !file_ok {
2049                    eprintln!("skipping the file-link half: no symlink privilege");
2050                }
2051            }
2052            dir_ok
2053        };
2054        if !linked {
2055            eprintln!("skipping: this platform could not create the link fixture");
2056            return;
2057        }
2058
2059        // A file *below* a linked directory resolves: the link is an ancestor,
2060        // not the thing being written.
2061        let through_link = link_dir.join("nested").join("leaf.exe");
2062        std::fs::create_dir_all(through_link.parent().unwrap()).unwrap();
2063        std::fs::write(&through_link, b"payload").unwrap();
2064        super::validated_regular_file(&through_link, "file below a linked directory").unwrap();
2065        assert_eq!(
2066            std::fs::canonicalize(&link_dir).unwrap(),
2067            std::fs::canonicalize(&real_dir).unwrap(),
2068        );
2069
2070        // A link *leaf* is still refused, and the error names both paths.
2071        let error = super::validated_real_directory(&link_dir, "linked directory").unwrap_err();
2072        assert!(error.contains("link or junction"), "{error}");
2073        assert!(error.contains(&link_dir.display().to_string()), "{error}");
2074
2075        if link_file.exists() {
2076            let error = super::validated_regular_file(&link_file, "linked file").unwrap_err();
2077            assert!(error.contains("link or junction"), "{error}");
2078            assert!(error.contains(&link_file.display().to_string()), "{error}");
2079            assert!(error.contains(&real_file.display().to_string()), "{error}");
2080        }
2081
2082        // The link leaf check must not be a blanket refusal: the real targets
2083        // still validate.
2084        super::validated_real_directory(&real_dir, "real directory").unwrap();
2085        super::validated_regular_file(&real_file, "real file").unwrap();
2086    }
2087
2088    #[test]
2089    fn cargo_self_update_staging_is_passed_as_a_structured_argument() {
2090        let temp = real_tempdir();
2091        let staging = temp.path().join("staging");
2092        let command_dir = temp.path().join("command");
2093        std::fs::create_dir_all(&staging).unwrap();
2094        std::fs::create_dir_all(&command_dir).unwrap();
2095        let command = cargo_install_command(Some(&staging), &command_dir).unwrap();
2096        let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2097
2098        assert_eq!(command.get_program(), "cargo");
2099        assert_eq!(args.len(), 7);
2100        assert_eq!(args[0], "install");
2101        assert_eq!(args[1], "--root");
2102        assert_eq!(args[2], staging.as_os_str());
2103        assert_eq!(args[3], "rpi-cli");
2104        assert_eq!(args[4], "--locked");
2105        assert_eq!(args[5], "--force");
2106        // Suppresses cargo's "add this to your PATH" advice, which names the
2107        // staging root that the atomic replace is about to consume.
2108        assert_eq!(args[6], "--quiet");
2109        assert_eq!(command.get_current_dir(), Some(command_dir.as_path()));
2110    }
2111
2112    #[cfg(unix)]
2113    #[test]
2114    fn direct_update_replaces_the_selected_target_and_rejects_downgrades() {
2115        let temp = real_tempdir();
2116        let staging = temp.path().join("staging");
2117        let staged = staging.join("bin/rpi");
2118        let target = temp.path().join("target/rpi-current");
2119        fake_unix_rpi(&staged, "9.9.9", None);
2120        fake_unix_rpi(&target, crate::VERSION, None);
2121        let expected = std::fs::read(&staged).unwrap();
2122
2123        let plan = build_direct_update_plan(&staging, &target).unwrap();
2124        assert_eq!(activate_direct_update(&plan).unwrap().to_string(), "9.9.9");
2125        assert_eq!(std::fs::read(&target).unwrap(), expected);
2126        assert!(!plan.replacement_temp.exists());
2127        assert!(!plan.backup_file.exists());
2128
2129        fake_unix_rpi(&staged, "0.0.1", None);
2130        let plan = build_direct_update_plan(&staging, &target).unwrap();
2131        let error = activate_direct_update(&plan).unwrap_err();
2132        assert!(error.contains("older version"), "{error}");
2133        assert_eq!(std::fs::read(&target).unwrap(), expected);
2134    }
2135
2136    #[cfg(unix)]
2137    #[test]
2138    fn direct_update_rolls_back_when_post_replace_validation_fails() {
2139        let temp = real_tempdir();
2140        let staging = temp.path().join("staging");
2141        let staged = staging.join("bin/rpi");
2142        let target = temp.path().join("target/rpi-current");
2143        fake_unix_rpi(&staged, "9.9.9", Some("rpi-current"));
2144        fake_unix_rpi(&target, crate::VERSION, None);
2145        let original = std::fs::read(&target).unwrap();
2146
2147        let plan = build_direct_update_plan(&staging, &target).unwrap();
2148        let error = activate_direct_update(&plan).unwrap_err();
2149
2150        assert!(error.contains("restored the previous"), "{error}");
2151        assert_eq!(std::fs::read(&target).unwrap(), original);
2152        assert!(!plan.replacement_temp.exists());
2153        assert!(!plan.backup_file.exists());
2154    }
2155
2156    #[test]
2157    fn self_update_honors_env_and_early_dispatched_offline_flag() {
2158        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2159        let _restore = RestoreEnv::capture(crate::args::RPI_OFFLINE_ENV);
2160
2161        std::env::set_var(crate::args::RPI_OFFLINE_ENV, "tRuE");
2162        assert_eq!(super::run_self_update(&[]), 0);
2163
2164        std::env::set_var(crate::args::RPI_OFFLINE_ENV, "0");
2165        assert_eq!(super::run_self_update(&["--offline".into()]), 0);
2166        assert_eq!(
2167            std::env::var(crate::args::RPI_OFFLINE_ENV).as_deref(),
2168            Ok("1")
2169        );
2170    }
2171
2172    #[tokio::test]
2173    async fn offline_skips_rpi_startup_checks_before_cache_io() {
2174        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2175        let _restore_config = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2176        let _restore_offline = RestoreEnv::capture(crate::args::RPI_OFFLINE_ENV);
2177        let _restore_disabled = RestoreEnv::capture("RPI_DISABLE_UPDATE_CHECK");
2178        let temp = real_tempdir();
2179        let agent = temp.path().join("offline-agent");
2180        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2181        std::env::set_var(crate::args::RPI_OFFLINE_ENV, "YES");
2182        std::env::remove_var("RPI_DISABLE_UPDATE_CHECK");
2183
2184        let report = super::check_rpi_startup().await;
2185
2186        assert_eq!(report, super::UpdateReport::default());
2187        assert!(
2188            !agent.exists(),
2189            "offline checks must not create the update cache directory"
2190        );
2191    }
2192
2193    #[test]
2194    fn completed_self_update_statuses_are_consumed_once() {
2195        let temp = real_tempdir();
2196        let agent = temp.path().join("agent");
2197        let update_root = agent.join("self-update");
2198        std::fs::create_dir_all(&update_root).unwrap();
2199        let failed = update_root.join("status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json");
2200        let succeeded = update_root.join("status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.json");
2201        let waiting = update_root.join("status-cccccccccccccccccccccccccccccccc.json");
2202        let temporary = update_root.join("status-dddddddddddddddddddddddddddddddd.json.tmp");
2203        let malformed = update_root.join("status-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.json");
2204        let invalid_name = update_root.join("status-not-a-uuid.json");
2205        write_test_update_status(&failed, "failed", "permission\u{1b}[31m\r\n denied\u{7}");
2206        write_test_update_status(&succeeded, "succeeded", "done");
2207        write_test_update_status(&waiting, "waiting", "still running");
2208        write_test_update_status(&temporary, "failed", "not published");
2209        std::fs::write(&malformed, b"{not-json").unwrap();
2210        write_test_update_status(&invalid_name, "failed", "invalid name");
2211
2212        let warnings = super::consume_self_update_statuses(&agent);
2213
2214        assert_eq!(warnings.len(), 1);
2215        assert!(warnings[0]
2216            .message
2217            .contains("rpi update failed: permission [31m denied"));
2218        assert!(!warnings[0].message.chars().any(char::is_control));
2219        assert_eq!(warnings[0].command, "rpi update");
2220        assert!(!failed.exists());
2221        assert!(!succeeded.exists());
2222        assert!(waiting.exists());
2223        assert!(temporary.exists());
2224        assert!(malformed.exists());
2225        assert!(invalid_name.exists());
2226        assert!(super::consume_self_update_statuses(&agent).is_empty());
2227    }
2228
2229    #[test]
2230    fn self_update_status_validation_rejects_escape_and_links() {
2231        let temp = real_tempdir();
2232        let agent = temp.path().join("agent");
2233        let update_root = agent.join("self-update");
2234        let outside_root = temp.path().join("outside");
2235        std::fs::create_dir_all(&update_root).unwrap();
2236        std::fs::create_dir_all(&outside_root).unwrap();
2237        let name = "status-ffffffffffffffffffffffffffffffff.json";
2238        let outside = outside_root.join(name);
2239        write_test_update_status(&outside, "failed", "outside");
2240
2241        assert!(super::validated_self_update_status_file(&update_root, &outside).is_err());
2242
2243        let linked = update_root.join(name);
2244        #[cfg(unix)]
2245        std::os::unix::fs::symlink(&outside, &linked).unwrap();
2246        #[cfg(windows)]
2247        let link_created = std::os::windows::fs::symlink_file(&outside, &linked).is_ok();
2248        #[cfg(unix)]
2249        let link_created = true;
2250        if link_created {
2251            assert!(super::validated_self_update_status_file(&update_root, &linked).is_err());
2252            assert!(super::consume_self_update_statuses(&agent).is_empty());
2253            assert!(outside.exists());
2254        }
2255    }
2256
2257    #[test]
2258    fn cargo_self_update_without_staging_never_inherits_the_callers_cwd() {
2259        let temp = real_tempdir();
2260        let command_dir = temp.path().join("isolated");
2261        std::fs::create_dir_all(&command_dir).unwrap();
2262
2263        let command = cargo_install_command(None, &command_dir).unwrap();
2264        let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2265
2266        assert_eq!(
2267            args,
2268            ["install", "rpi-cli", "--locked", "--force", "--quiet"]
2269                .into_iter()
2270                .map(OsString::from)
2271                .collect::<Vec<_>>()
2272        );
2273        assert_eq!(command.get_current_dir(), Some(command_dir.as_path()));
2274        assert_ne!(
2275            command.get_current_dir(),
2276            std::env::current_dir().ok().as_deref()
2277        );
2278        for variable in [
2279            "RUSTC_WRAPPER",
2280            "RUSTC_WORKSPACE_WRAPPER",
2281            "CARGO_BUILD_RUSTC_WRAPPER",
2282            "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
2283        ] {
2284            assert!(command
2285                .get_envs()
2286                .any(|(name, value)| name == variable && value.is_none()));
2287        }
2288        assert!(cargo_install_command(None, Path::new("relative-command-dir")).is_err());
2289
2290        let generated = create_self_update_command_dir().unwrap();
2291        let caller = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap();
2292        assert!(generated.path().is_absolute());
2293        assert!(!path_is_within(generated.path(), &caller));
2294    }
2295
2296    #[test]
2297    fn project_local_cargo_home_is_rejected_even_when_its_leaf_is_missing() {
2298        let temp = real_tempdir();
2299        let project = temp.path().join("project");
2300        let global = temp.path().join("global-cargo");
2301        std::fs::create_dir_all(&project).unwrap();
2302        std::fs::create_dir_all(&global).unwrap();
2303        let project = std::fs::canonicalize(project).unwrap();
2304
2305        assert!(cargo_home_is_project_local(&project.join(".cargo-home"), &project).unwrap());
2306        assert!(cargo_home_is_project_local(&project, &project).unwrap());
2307        assert!(!cargo_home_is_project_local(&global, &project).unwrap());
2308        assert!(cargo_home_is_project_local(Path::new("relative"), &project).is_err());
2309    }
2310
2311    #[cfg(windows)]
2312    #[test]
2313    fn windows_update_helper_atomically_replaces_a_valid_target() {
2314        let temp = real_tempdir();
2315        let staging = temp.path().join("staging 'semi;& with spaces");
2316        let staged = staging.join("bin/rpi.exe");
2317        let target = temp.path().join("target 'semi;& with spaces/rpi-copy.exe");
2318        fake_windows_executable(&staged, b"new-version");
2319        fake_windows_executable(&target, b"old-version");
2320        let expected = std::fs::read(&staged).unwrap();
2321        let plan = build_windows_update_plan(
2322            &staging,
2323            &target,
2324            &temp
2325                .path()
2326                .join("status-00000000000000000000000000000000.json"),
2327        )
2328        .unwrap();
2329        preflight_windows_replacement(&plan).unwrap();
2330        std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER).unwrap();
2331        write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
2332
2333        let powershell = system_powershell_path().unwrap();
2334        let mut command =
2335            windows_update_helper_command(&powershell, &plan, i32::MAX as u32, "9.9.9");
2336        let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2337        for (flag, expected) in [
2338            ("-StagingPath", &plan.staging_dir),
2339            ("-StagedPath", &plan.staged_exe),
2340            ("-TargetPath", &plan.target_exe),
2341            ("-TempPath", &plan.replacement_temp),
2342            ("-BackupPath", &plan.backup_file),
2343            ("-RollbackDisplacedPath", &plan.rollback_displaced_file),
2344            ("-StatusPath", &plan.status_file),
2345        ] {
2346            let index = args.iter().position(|arg| arg == flag).unwrap();
2347            assert_eq!(args[index + 1], expected.as_os_str());
2348        }
2349        let version_index = args.iter().position(|arg| arg == "-StagedVersion").unwrap();
2350        assert_eq!(args[version_index + 1], "9.9.9");
2351        assert_eq!(command.get_current_dir(), plan.status_file.parent());
2352        assert!(command.get_envs().any(|(name, value)| {
2353            name == "PSModulePath"
2354                && value == Some(powershell.parent().unwrap().join("Modules").as_os_str())
2355        }));
2356        let status = command.status().unwrap();
2357
2358        let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
2359        assert!(
2360            status.success(),
2361            "helper exited with {status}: {status_text}"
2362        );
2363        assert_eq!(std::fs::read(&target).unwrap(), expected);
2364        assert!(!staging.exists());
2365        assert!(!plan.replacement_temp.exists());
2366        assert!(!plan.backup_file.exists());
2367        assert!(!plan.rollback_displaced_file.exists());
2368        let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
2369        assert_eq!(status["state"], "succeeded");
2370        assert_eq!(status["stagedVersion"], "9.9.9");
2371    }
2372
2373    #[cfg(windows)]
2374    #[test]
2375    fn windows_update_helper_failure_preserves_the_old_executable() {
2376        let temp = real_tempdir();
2377        let staging = temp.path().join("staging with spaces");
2378        let staged = staging.join("bin/rpi.exe");
2379        let target = temp.path().join("target with spaces/rpi-copy.exe");
2380        fake_windows_executable(&staged, b"new-version");
2381        fake_windows_executable(&target, b"old-version");
2382        let expected = std::fs::read(&target).unwrap();
2383        let plan = build_windows_update_plan(
2384            &staging,
2385            &target,
2386            &temp
2387                .path()
2388                .join("status-00000000000000000000000000000000.json"),
2389        )
2390        .unwrap();
2391        preflight_windows_replacement(&plan).unwrap();
2392        std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER).unwrap();
2393        write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
2394        std::fs::remove_file(&plan.staged_exe).unwrap();
2395
2396        let status = windows_update_helper_command(
2397            &system_powershell_path().unwrap(),
2398            &plan,
2399            i32::MAX as u32,
2400            "9.9.9",
2401        )
2402        .status()
2403        .unwrap();
2404
2405        let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
2406        assert!(!status.success());
2407        assert_eq!(
2408            std::fs::read(&target).unwrap(),
2409            expected,
2410            "status={status_text}; backup_exists={}",
2411            plan.backup_file.exists()
2412        );
2413        assert!(!staging.exists());
2414        assert!(!plan.replacement_temp.exists());
2415        assert!(!plan.backup_file.exists());
2416        assert!(!plan.rollback_displaced_file.exists());
2417        let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
2418        assert_eq!(status["state"], "failed");
2419    }
2420
2421    #[cfg(windows)]
2422    #[test]
2423    fn windows_update_helper_rolls_back_after_post_replace_validation_failure() {
2424        let temp = real_tempdir();
2425        let staging = temp.path().join("staging");
2426        let staged = staging.join("bin/rpi.exe");
2427        let target = temp.path().join("target/rpi-copy.exe");
2428        fake_windows_executable(&staged, b"new-version");
2429        fake_windows_executable(&target, b"old-version");
2430        let expected = std::fs::read(&target).unwrap();
2431        let plan = build_windows_update_plan(
2432            &staging,
2433            &target,
2434            &temp
2435                .path()
2436                .join("status-00000000000000000000000000000000.json"),
2437        )
2438        .unwrap();
2439        preflight_windows_replacement(&plan).unwrap();
2440        let forced_failure = WINDOWS_UPDATE_HELPER.replace(
2441            "(Get-FileSha256 -Path $targetFull) -ne $expectedHash",
2442            "$true",
2443        );
2444        assert_ne!(forced_failure, WINDOWS_UPDATE_HELPER);
2445        std::fs::write(&plan.helper_script, forced_failure).unwrap();
2446        write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
2447
2448        let status = windows_update_helper_command(
2449            &system_powershell_path().unwrap(),
2450            &plan,
2451            i32::MAX as u32,
2452            "9.9.9",
2453        )
2454        .status()
2455        .unwrap();
2456
2457        let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
2458        assert!(!status.success());
2459        assert_eq!(
2460            std::fs::read(&target).unwrap(),
2461            expected,
2462            "status={status_text}; backup_exists={}",
2463            plan.backup_file.exists()
2464        );
2465        assert!(!staging.exists());
2466        assert!(!plan.replacement_temp.exists());
2467        assert!(!plan.backup_file.exists());
2468        assert!(!plan.rollback_displaced_file.exists());
2469        let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
2470        assert_eq!(status["state"], "failed");
2471        assert!(status["message"]
2472            .as_str()
2473            .unwrap()
2474            .contains("updated executable hash mismatch"));
2475    }
2476
2477    #[cfg(windows)]
2478    #[test]
2479    fn windows_update_plan_rejects_a_non_executable_staged_file() {
2480        let temp = real_tempdir();
2481        let staging = temp.path().join("staging");
2482        let staged = staging.join("bin/rpi.exe");
2483        let target = temp.path().join("target/rpi.exe");
2484        std::fs::create_dir_all(staged.parent().unwrap()).unwrap();
2485        std::fs::write(&staged, b"not-a-pe").unwrap();
2486        fake_windows_executable(&target, b"old-version");
2487        let expected = std::fs::read(&target).unwrap();
2488
2489        let error = build_windows_update_plan(
2490            &staging,
2491            &target,
2492            &temp
2493                .path()
2494                .join("status-00000000000000000000000000000000.json"),
2495        )
2496        .unwrap_err();
2497
2498        assert!(error.contains("not a Windows executable"));
2499        assert_eq!(std::fs::read(&target).unwrap(), expected);
2500    }
2501
2502    #[test]
2503    fn staged_rpi_version_output_is_exact_and_cannot_downgrade() {
2504        assert_eq!(
2505            parse_rpi_version_output(b"rpi 1.2.3\r\n").unwrap(),
2506            semver::Version::new(1, 2, 3)
2507        );
2508        assert_eq!(
2509            validate_staged_rpi_version_output(b"rpi 1.2.3\n", b"", "1.2.3").unwrap(),
2510            semver::Version::new(1, 2, 3)
2511        );
2512        assert!(
2513            validate_staged_rpi_version_output(b"rpi 1.2.2\n", b"", "1.2.3")
2514                .unwrap_err()
2515                .contains("older version")
2516        );
2517
2518        for output in [
2519            &b"pi 1.2.3\n"[..],
2520            &b"rpi v1.2.3\n"[..],
2521            &b"rpi 1.2.3 extra\n"[..],
2522            &b"rpi 1.2.3\nsecond line\n"[..],
2523            &b" rpi 1.2.3\n"[..],
2524        ] {
2525            assert!(
2526                parse_rpi_version_output(output).is_none(),
2527                "output={output:?}"
2528            );
2529        }
2530        assert!(
2531            validate_staged_rpi_version_output(b"rpi 1.2.3\n", b"warning", "1.2.3")
2532                .unwrap_err()
2533                .contains("stderr")
2534        );
2535    }
2536
2537    #[test]
2538    fn compares_release_versions_conservatively() {
2539        assert!(is_newer("0.1.9", "0.1.10"));
2540        assert!(is_newer("v1.2.3", "1.3.0"));
2541        assert!(is_newer("1.2.3-beta.1", "1.2.3-beta.2"));
2542        assert!(is_newer("1.2.3-beta.2", "1.2.3"));
2543        assert!(!is_newer("1.2.3", "1.2.3"));
2544        assert!(!is_newer("nightly", "1.0.0"));
2545    }
2546
2547    #[test]
2548    fn failed_item_checks_keep_only_corresponding_cached_values() {
2549        let cached = BTreeMap::from([
2550            ("fresh".to_string(), "1.0.0".to_string()),
2551            ("failed".to_string(), "2.0.0".to_string()),
2552            ("removed".to_string(), "3.0.0".to_string()),
2553        ]);
2554        let results = vec![
2555            (
2556                "fresh".to_string(),
2557                RegistryLookup::Found("1.1.0".to_string()),
2558            ),
2559            ("failed".to_string(), RegistryLookup::TransientFailure),
2560            ("uncached".to_string(), RegistryLookup::TransientFailure),
2561            ("missing".to_string(), RegistryLookup::Missing),
2562        ];
2563
2564        let (merged, checked_at_values) =
2565            results_with_individual_cache_fallback(results, Some(&cached), None, 100, 100);
2566        assert_eq!(merged.get("fresh").map(String::as_str), Some("1.1.0"));
2567        assert_eq!(merged.get("failed").map(String::as_str), Some("2.0.0"));
2568        assert!(!merged.contains_key("removed"));
2569        assert!(!merged.contains_key("uncached"));
2570        assert!(!merged.contains_key("missing"));
2571        // A live lookup is stamped with the current check; a fallback keeps the
2572        // timestamp it was cached at, so the fallback window cannot be extended
2573        // indefinitely by repeated offline starts.
2574        assert_eq!(checked_at_values.get("fresh").copied(), Some(100));
2575        assert_eq!(checked_at_values.get("failed").copied(), Some(100));
2576        assert!(!checked_at_values.contains_key("uncached"));
2577        assert!(!checked_at_values.contains_key("missing"));
2578    }
2579
2580    #[tokio::test]
2581    async fn package_update_checks_have_a_shared_concurrency_limit() {
2582        let in_flight = Arc::new(AtomicUsize::new(0));
2583        let maximum = Arc::new(AtomicUsize::new(0));
2584        let checks = (0..(UPDATE_CHECK_CONCURRENCY * 3)).map(|index| {
2585            let in_flight = Arc::clone(&in_flight);
2586            let maximum = Arc::clone(&maximum);
2587            async move {
2588                let active = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
2589                maximum.fetch_max(active, Ordering::SeqCst);
2590                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2591                in_flight.fetch_sub(1, Ordering::SeqCst);
2592                index
2593            }
2594        });
2595
2596        let mut results = collect_bounded(checks).await;
2597        results.sort_unstable();
2598
2599        assert_eq!(
2600            results,
2601            (0..(UPDATE_CHECK_CONCURRENCY * 3)).collect::<Vec<_>>()
2602        );
2603        assert_eq!(maximum.load(Ordering::SeqCst), UPDATE_CHECK_CONCURRENCY);
2604        assert_eq!(in_flight.load(Ordering::SeqCst), 0);
2605    }
2606
2607    #[tokio::test]
2608    async fn bounded_checks_do_not_head_of_line_block_new_work() {
2609        let release_first = Arc::new(tokio::sync::Notify::new());
2610        let checks = (0..(UPDATE_CHECK_CONCURRENCY + 1)).map(|index| {
2611            let release_first = Arc::clone(&release_first);
2612            async move {
2613                if index == 0 {
2614                    release_first.notified().await;
2615                } else if index == UPDATE_CHECK_CONCURRENCY {
2616                    release_first.notify_one();
2617                }
2618                index
2619            }
2620        });
2621
2622        let mut results =
2623            tokio::time::timeout(std::time::Duration::from_secs(1), collect_bounded(checks))
2624                .await
2625                .expect("a completed later check must free a concurrency slot");
2626        results.sort_unstable();
2627        assert_eq!(results, (0..=UPDATE_CHECK_CONCURRENCY).collect::<Vec<_>>());
2628    }
2629
2630    #[test]
2631    fn fresh_results_keep_individual_timestamps_when_a_peer_uses_fallback() {
2632        let now = 10 * CACHE_FALLBACK_MAX_AGE_MS;
2633        let nearly_stale = now - CACHE_FALLBACK_MAX_AGE_MS + 60_000;
2634        let cached = BTreeMap::from([
2635            ("fresh".to_string(), "1.0.0".to_string()),
2636            ("failed".to_string(), "2.0.0".to_string()),
2637        ]);
2638        let freshness = BTreeMap::from([
2639            ("fresh".to_string(), nearly_stale),
2640            ("failed".to_string(), nearly_stale),
2641        ]);
2642        let (values, timestamps) = results_with_individual_cache_fallback(
2643            vec![
2644                (
2645                    "fresh".to_string(),
2646                    RegistryLookup::Found("1.1.0".to_string()),
2647                ),
2648                ("failed".to_string(), RegistryLookup::TransientFailure),
2649            ],
2650            Some(&cached),
2651            Some(&freshness),
2652            nearly_stale,
2653            now,
2654        );
2655
2656        assert_eq!(timestamps.get("fresh"), Some(&now));
2657        assert_eq!(timestamps.get("failed"), Some(&nearly_stale));
2658        let after_old_entry_expires = now + 120_000;
2659        let (fallback, _) = results_with_individual_cache_fallback(
2660            vec![
2661                ("fresh".to_string(), RegistryLookup::TransientFailure),
2662                ("failed".to_string(), RegistryLookup::TransientFailure),
2663            ],
2664            Some(&values),
2665            Some(&timestamps),
2666            nearly_stale,
2667            after_old_entry_expires,
2668        );
2669        assert_eq!(fallback.get("fresh").map(String::as_str), Some("1.1.0"));
2670        assert!(!fallback.contains_key("failed"));
2671    }
2672
2673    #[tokio::test]
2674    async fn crates_rate_limits_and_request_timeouts_are_transient() {
2675        async fn lookup(status: &str, body: &str) -> RegistryLookup {
2676            use std::io::{BufRead, BufReader, Write};
2677
2678            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2679            let address = listener.local_addr().unwrap();
2680            let status = status.to_string();
2681            let body = body.to_string();
2682            let server = std::thread::spawn(move || {
2683                let (mut stream, _) = listener.accept().unwrap();
2684                {
2685                    let mut reader = BufReader::new(&mut stream);
2686                    let mut line = String::new();
2687                    loop {
2688                        line.clear();
2689                        if reader.read_line(&mut line).unwrap() == 0 || line == "\r\n" {
2690                            break;
2691                        }
2692                    }
2693                }
2694                write!(
2695                    stream,
2696                    "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2697                    body.len()
2698                )
2699                .unwrap();
2700            });
2701            let client = reqwest::Client::builder().build().unwrap();
2702            let result = fetch_crates_url(&client, &format!("http://{address}/crate")).await;
2703            server.join().unwrap();
2704            result
2705        }
2706
2707        assert_eq!(
2708            lookup("408 Request Timeout", "").await,
2709            RegistryLookup::TransientFailure
2710        );
2711        assert_eq!(
2712            lookup("429 Too Many Requests", "").await,
2713            RegistryLookup::TransientFailure
2714        );
2715        let malformed = lookup("200 OK", "{malformed-json").await;
2716        assert_eq!(malformed, RegistryLookup::TransientFailure);
2717        assert_eq!(
2718            super::lookup_with_cache_fallback(malformed, Some("9.9.9"), true),
2719            (Some("9.9.9".to_string()), true)
2720        );
2721        assert_eq!(lookup("404 Not Found", "").await, RegistryLookup::Missing);
2722    }
2723}