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    command.args(["rpi-cli", "--locked", "--force"]);
508    Ok(command)
509}
510
511/// Cargo discovers `.cargo/config.toml` from its working directory upward.
512/// Put update commands directly below the user's home directory so neither an
513/// untrusted project, a project-local `CARGO_HOME`, nor a world-writable system
514/// temp directory can join that discovery chain. Cargo still reads its
515/// explicitly configured user-level home through its normal environment.
516fn create_self_update_command_dir() -> Result<tempfile::TempDir, String> {
517    let configured_root =
518        dirs::home_dir().ok_or_else(|| "could not resolve the user home directory".to_string())?;
519    if !configured_root.is_absolute() {
520        return Err("user home must be absolute for self-update".to_string());
521    }
522    let canonical_root = std::fs::canonicalize(&configured_root)
523        .map(normalize_windows_path)
524        .map_err(|error| format!("could not canonicalize user home: {error}"))?;
525    let metadata = std::fs::symlink_metadata(&canonical_root)
526        .map_err(|error| format!("could not inspect user home: {error}"))?;
527    if !metadata.is_dir() || metadata.file_type().is_symlink() {
528        return Err("user home does not resolve to a real directory".to_string());
529    }
530    let current = std::env::current_dir()
531        .and_then(std::fs::canonicalize)
532        .map(normalize_windows_path)
533        .map_err(|error| format!("could not validate the caller directory: {error}"))?;
534    let project_root = current
535        .ancestors()
536        .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
537        .map(Path::to_path_buf);
538    validate_configured_cargo_home(project_root.as_deref())?;
539    if project_root.as_deref().is_some_and(|root| {
540        !windows_paths_equal(&canonical_root, root) && path_is_within(&canonical_root, root)
541    }) {
542        return Err(
543            "refusing to create a self-update command directory inside the caller project"
544                .to_string(),
545        );
546    }
547    let directory = tempfile::Builder::new()
548        .prefix("rpi-self-update-command-")
549        .tempdir_in(&canonical_root)
550        .map_err(|error| format!("could not allocate Cargo command directory: {error}"))?;
551    validated_self_update_command_dir(directory.path())?;
552    #[cfg(unix)]
553    {
554        use std::os::unix::fs::{MetadataExt, PermissionsExt};
555
556        let directory_metadata = std::fs::symlink_metadata(directory.path())
557            .map_err(|error| format!("could not inspect Cargo command directory: {error}"))?;
558        if metadata.uid() != directory_metadata.uid() || metadata.permissions().mode() & 0o022 != 0
559        {
560            return Err("user home is not private to the current account".to_string());
561        }
562    }
563    Ok(directory)
564}
565
566fn path_is_within(candidate: &Path, ancestor: &Path) -> bool {
567    candidate
568        .ancestors()
569        .any(|component| windows_paths_equal(component, ancestor))
570}
571
572fn validate_configured_cargo_home(project_root: Option<&Path>) -> Result<(), String> {
573    let Some(configured) = std::env::var_os("CARGO_HOME") else {
574        return Ok(());
575    };
576    let configured = PathBuf::from(configured);
577    if !configured.is_absolute() {
578        return Err("CARGO_HOME must be absolute for self-update".to_string());
579    }
580    let project_local = match project_root {
581        Some(root) => cargo_home_is_project_local(&configured, root)?,
582        None => {
583            canonicalize_allow_missing(&configured, "CARGO_HOME")?;
584            false
585        }
586    };
587    if project_local {
588        return Err("refusing project-local CARGO_HOME during self-update".to_string());
589    }
590    Ok(())
591}
592
593fn cargo_home_is_project_local(configured: &Path, project_root: &Path) -> Result<bool, String> {
594    if !configured.is_absolute() {
595        return Err("CARGO_HOME must be absolute for self-update".to_string());
596    }
597    let configured = canonicalize_allow_missing(configured, "CARGO_HOME")?;
598    Ok(path_is_within(&configured, project_root))
599}
600
601fn canonicalize_allow_missing(path: &Path, label: &str) -> Result<PathBuf, String> {
602    if path.components().any(|component| {
603        matches!(
604            component,
605            std::path::Component::CurDir | std::path::Component::ParentDir
606        )
607    }) {
608        return Err(format!("{label} contains relative path components"));
609    }
610    let mut existing = path;
611    let mut missing = Vec::new();
612    loop {
613        match std::fs::symlink_metadata(existing) {
614            Ok(_) => break,
615            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
616                let name = existing
617                    .file_name()
618                    .ok_or_else(|| format!("could not resolve {label}"))?;
619                missing.push(name.to_os_string());
620                existing = existing
621                    .parent()
622                    .ok_or_else(|| format!("could not resolve {label}"))?;
623            }
624            Err(error) => return Err(format!("could not inspect {label}: {error}")),
625        }
626    }
627    let mut canonical = std::fs::canonicalize(existing)
628        .map(normalize_windows_path)
629        .map_err(|error| format!("could not canonicalize {label}: {error}"))?;
630    for component in missing.into_iter().rev() {
631        canonical.push(component);
632    }
633    Ok(canonical)
634}
635
636fn validated_self_update_command_dir(path: &Path) -> Result<PathBuf, String> {
637    if !path.is_absolute() {
638        return Err("self-update command directory must be absolute".to_string());
639    }
640    let metadata = std::fs::symlink_metadata(path)
641        .map_err(|error| format!("could not inspect self-update command directory: {error}"))?;
642    if !metadata.is_dir() || metadata.file_type().is_symlink() {
643        return Err("self-update command directory is not a real directory".to_string());
644    }
645    let canonical = std::fs::canonicalize(path)
646        .map(normalize_windows_path)
647        .map_err(|error| {
648            format!("could not canonicalize self-update command directory: {error}")
649        })?;
650    if !windows_paths_equal(&canonical, path) {
651        return Err(
652            "self-update command directory resolves through a link or junction".to_string(),
653        );
654    }
655    Ok(canonical)
656}
657
658#[cfg(not(windows))]
659#[derive(Debug, Clone)]
660struct DirectUpdatePlan {
661    staging_dir: PathBuf,
662    staged_exe: PathBuf,
663    target_exe: PathBuf,
664    replacement_temp: PathBuf,
665    backup_file: PathBuf,
666}
667
668#[cfg(not(windows))]
669fn run_direct_self_update() -> i32 {
670    let staging = match create_self_update_staging_directory() {
671        Ok(directory) => directory,
672        Err(error) => {
673            eprintln!("error: could not create self-update staging directory: {error}");
674            return 1;
675        }
676    };
677    let command_dir = match create_self_update_command_dir() {
678        Ok(directory) => directory,
679        Err(error) => {
680            eprintln!("error: could not create an isolated self-update directory: {error}");
681            return 1;
682        }
683    };
684    let mut command = match cargo_install_command(Some(staging.path()), command_dir.path()) {
685        Ok(command) => command,
686        Err(error) => {
687            eprintln!("error: invalid isolated self-update directory: {error}");
688            return 1;
689        }
690    };
691    let status = command.status();
692    match status {
693        Ok(status) if status.success() => {}
694        Ok(status) => {
695            eprintln!("error: cargo install exited with {status}");
696            return 1;
697        }
698        Err(error) => {
699            eprintln!("error: could not run cargo (install Rust/Cargo first): {error}");
700            return 1;
701        }
702    }
703
704    let current_exe = match std::env::current_exe() {
705        Ok(path) => path,
706        Err(error) => {
707            eprintln!("error: could not resolve the current rpi executable: {error}");
708            return 1;
709        }
710    };
711    let plan = match build_direct_update_plan(staging.path(), &current_exe) {
712        Ok(plan) => plan,
713        Err(error) => {
714            eprintln!("error: {error}");
715            return 1;
716        }
717    };
718    match activate_direct_update(&plan) {
719        Ok(version) => {
720            println!("rpi updated successfully to {version}");
721            0
722        }
723        Err(error) => {
724            eprintln!("error: {error}");
725            1
726        }
727    }
728}
729
730fn create_self_update_staging_directory() -> Result<tempfile::TempDir, String> {
731    let agent = crate::config::agent_dir().map_err(|error| error.to_string())?;
732    if !agent.is_absolute() {
733        return Err("agent directory must be absolute".to_string());
734    }
735    std::fs::create_dir_all(&agent)
736        .map_err(|error| format!("could not create {}: {error}", agent.display()))?;
737    let agent = validated_real_directory(&agent, "agent directory")?;
738    let update_root = agent.join("self-update");
739    std::fs::create_dir_all(&update_root)
740        .map_err(|error| format!("could not create {}: {error}", update_root.display()))?;
741    let update_root = validated_real_directory(&update_root, "self-update root")?;
742    let staging = tempfile::Builder::new()
743        .prefix("pending-")
744        .tempdir_in(&update_root)
745        .map_err(|error| format!("could not allocate staging directory: {error}"))?;
746    validated_real_directory(staging.path(), "self-update staging directory")?;
747    Ok(staging)
748}
749
750#[cfg(not(windows))]
751fn build_direct_update_plan(
752    staging_dir: &Path,
753    target_exe: &Path,
754) -> Result<DirectUpdatePlan, String> {
755    let staging_dir = validated_real_directory(staging_dir, "self-update staging directory")?;
756    let staged_exe = validated_regular_file(
757        &staging_dir.join("bin").join(crate::APP_NAME),
758        "staged rpi executable",
759    )?;
760    let target_exe = validated_regular_file(target_exe, "current rpi executable")?;
761    let target_dir = validated_real_directory(
762        target_exe
763            .parent()
764            .ok_or_else(|| "current executable has no parent directory".to_string())?,
765        "current executable directory",
766    )?;
767    if target_exe.parent() != Some(target_dir.as_path()) {
768        return Err("current executable is not a direct child of its validated directory".into());
769    }
770    let token = uuid::Uuid::new_v4().simple().to_string();
771    let replacement_temp = target_dir.join(format!(".rpi-update-{token}.new"));
772    let backup_file = target_dir.join(format!(".rpi-update-{token}.old"));
773    if replacement_temp.exists() || backup_file.exists() {
774        return Err("self-update replacement path collision".to_string());
775    }
776    Ok(DirectUpdatePlan {
777        staging_dir,
778        staged_exe,
779        target_exe,
780        replacement_temp,
781        backup_file,
782    })
783}
784
785#[cfg(not(windows))]
786fn activate_direct_update(plan: &DirectUpdatePlan) -> Result<semver::Version, String> {
787    let staged_version = validate_rpi_executable_version(
788        &plan.staged_exe,
789        &plan.staging_dir,
790        crate::VERSION,
791        "staged rpi",
792    )?;
793    preflight_direct_replacement(plan)?;
794
795    let result = (|| {
796        std::fs::copy(&plan.staged_exe, &plan.replacement_temp).map_err(|error| {
797            format!("could not copy staged rpi beside the current executable: {error}")
798        })?;
799        validated_regular_file(&plan.replacement_temp, "replacement rpi executable")?;
800        let copied_version = validate_rpi_executable_version(
801            &plan.replacement_temp,
802            plan.target_exe
803                .parent()
804                .ok_or_else(|| "current executable has no parent directory".to_string())?,
805            crate::VERSION,
806            "replacement rpi",
807        )?;
808        if copied_version != staged_version
809            || !files_are_identical(&plan.staged_exe, &plan.replacement_temp)?
810        {
811            return Err(
812                "replacement rpi does not match the validated staged executable".to_string(),
813            );
814        }
815
816        std::fs::hard_link(&plan.target_exe, &plan.backup_file)
817            .map_err(|error| format!("could not preserve the current rpi executable: {error}"))?;
818        let installed_version = validate_rpi_executable_version(
819            &plan.backup_file,
820            plan.target_exe
821                .parent()
822                .ok_or_else(|| "current executable has no parent directory".to_string())?,
823            crate::VERSION,
824            "current rpi",
825        )?;
826        if staged_version < installed_version {
827            return Err(format!(
828                "refusing to replace rpi {installed_version} with older version {staged_version}"
829            ));
830        }
831        if !files_are_identical(&plan.target_exe, &plan.backup_file)? {
832            return Err("current rpi executable changed while preparing the update".to_string());
833        }
834
835        std::fs::rename(&plan.replacement_temp, &plan.target_exe).map_err(|error| {
836            format!("could not atomically replace the current rpi executable: {error}")
837        })?;
838        Ok(())
839    })();
840
841    if let Err(error) = result {
842        let _ = remove_file_if_exists(&plan.replacement_temp);
843        let _ = remove_file_if_exists(&plan.backup_file);
844        return Err(error);
845    }
846
847    let validation = (|| {
848        if !files_are_identical(&plan.staged_exe, &plan.target_exe)? {
849            return Err("updated rpi executable does not match the staged executable".to_string());
850        }
851        let updated_version = validate_rpi_executable_version(
852            &plan.target_exe,
853            plan.target_exe
854                .parent()
855                .ok_or_else(|| "current executable has no parent directory".to_string())?,
856            crate::VERSION,
857            "updated rpi",
858        )?;
859        if updated_version != staged_version {
860            return Err(format!(
861                "updated rpi reported version {updated_version}, expected {staged_version}"
862            ));
863        }
864        Ok(())
865    })();
866
867    if let Err(error) = validation {
868        return Err(rollback_direct_update(plan, &error));
869    }
870    let _ = remove_file_if_exists(&plan.backup_file);
871    Ok(staged_version)
872}
873
874#[cfg(not(windows))]
875fn preflight_direct_replacement(plan: &DirectUpdatePlan) -> Result<(), String> {
876    for path in [&plan.replacement_temp, &plan.backup_file] {
877        if path.exists() {
878            return Err(format!(
879                "self-update path already exists: {}",
880                path.display()
881            ));
882        }
883        let file = std::fs::OpenOptions::new()
884            .write(true)
885            .create_new(true)
886            .open(path)
887            .map_err(|error| format!("cannot write beside the current executable: {error}"))?;
888        drop(file);
889        std::fs::remove_file(path)
890            .map_err(|error| format!("could not remove self-update probe: {error}"))?;
891    }
892    Ok(())
893}
894
895#[cfg(not(windows))]
896fn rollback_direct_update(plan: &DirectUpdatePlan, failure: &str) -> String {
897    let _ = remove_file_if_exists(&plan.replacement_temp);
898    match std::fs::rename(&plan.backup_file, &plan.target_exe) {
899        Ok(()) => format!("{failure}; restored the previous rpi executable"),
900        Err(rollback) => format!(
901            "{failure}; automatic rollback failed ({rollback}); the previous executable remains at {}",
902            plan.backup_file.display()
903        ),
904    }
905}
906
907#[cfg(not(windows))]
908fn remove_file_if_exists(path: &Path) -> Result<(), String> {
909    match std::fs::remove_file(path) {
910        Ok(()) => Ok(()),
911        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
912        Err(error) => Err(error.to_string()),
913    }
914}
915
916#[cfg(not(windows))]
917fn files_are_identical(left: &Path, right: &Path) -> Result<bool, String> {
918    use std::io::{BufReader, Read};
919
920    let left_file = std::fs::File::open(left)
921        .map_err(|error| format!("could not open {}: {error}", left.display()))?;
922    let right_file = std::fs::File::open(right)
923        .map_err(|error| format!("could not open {}: {error}", right.display()))?;
924    if left_file
925        .metadata()
926        .map_err(|error| error.to_string())?
927        .len()
928        != right_file
929            .metadata()
930            .map_err(|error| error.to_string())?
931            .len()
932    {
933        return Ok(false);
934    }
935    let mut left = BufReader::new(left_file);
936    let mut right = BufReader::new(right_file);
937    let mut left_buffer = [0u8; 64 * 1024];
938    let mut right_buffer = [0u8; 64 * 1024];
939    loop {
940        let left_read = left
941            .read(&mut left_buffer)
942            .map_err(|error| error.to_string())?;
943        let right_read = right
944            .read(&mut right_buffer)
945            .map_err(|error| error.to_string())?;
946        if left_read != right_read || left_buffer[..left_read] != right_buffer[..right_read] {
947            return Ok(false);
948        }
949        if left_read == 0 {
950            return Ok(true);
951        }
952    }
953}
954
955#[cfg(windows)]
956#[derive(Debug, Clone)]
957struct WindowsUpdatePlan {
958    staging_dir: PathBuf,
959    staged_exe: PathBuf,
960    target_exe: PathBuf,
961    helper_script: PathBuf,
962    status_file: PathBuf,
963    replacement_temp: PathBuf,
964    backup_file: PathBuf,
965    rollback_displaced_file: PathBuf,
966}
967
968#[cfg(windows)]
969struct WindowsUpdateStaging {
970    directory: tempfile::TempDir,
971    status_file: PathBuf,
972}
973
974#[cfg(windows)]
975const WINDOWS_UPDATE_HELPER: &str = r#"param(
976    [Parameter(Mandatory = $true)][long] $ParentPid,
977    [Parameter(Mandatory = $true)][string] $StagingPath,
978    [Parameter(Mandatory = $true)][string] $StagedPath,
979    [Parameter(Mandatory = $true)][string] $StagedVersion,
980    [Parameter(Mandatory = $true)][string] $TargetPath,
981    [Parameter(Mandatory = $true)][string] $TempPath,
982    [Parameter(Mandatory = $true)][string] $BackupPath,
983    [Parameter(Mandatory = $true)][string] $RollbackDisplacedPath,
984    [Parameter(Mandatory = $true)][string] $StatusPath
985)
986
987Set-StrictMode -Version Latest
988$ErrorActionPreference = 'Stop'
989
990function Write-UpdateStatus {
991    param([string] $State, [string] $Message)
992    $record = [ordered]@{
993        state = $State
994        message = $Message
995        parentPid = $ParentPid
996        staging = $StagingPath
997        target = $TargetPath
998        staged = $StagedPath
999        stagedVersion = $StagedVersion
1000        backup = $BackupPath
1001        rollbackDisplaced = $RollbackDisplacedPath
1002        updatedAt = [DateTime]::UtcNow.ToString('o')
1003    }
1004    $json = $record | ConvertTo-Json -Compress
1005    $statusTemp = $StatusPath + '.tmp'
1006    [IO.File]::WriteAllText($statusTemp, $json, [Text.UTF8Encoding]::new($false))
1007    Move-Item -LiteralPath $statusTemp -Destination $StatusPath -Force
1008}
1009
1010function Assert-RegularFile {
1011    param([string] $Path, [string] $Label)
1012    $item = Get-Item -LiteralPath $Path -Force
1013    if ($item.PSIsContainer -or $item.Length -le 0 -or
1014        (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
1015        throw "$Label is not a non-empty regular file"
1016    }
1017}
1018
1019function Assert-WindowsExecutable {
1020    param([string] $Path, [string] $Label)
1021    Assert-RegularFile -Path $Path -Label $Label
1022    $stream = [IO.File]::OpenRead($Path)
1023    try {
1024        if ($stream.ReadByte() -ne 77 -or $stream.ReadByte() -ne 90) {
1025            throw "$Label is not a Windows executable"
1026        }
1027    } finally {
1028        $stream.Dispose()
1029    }
1030}
1031
1032function Assert-RealDirectory {
1033    param([string] $Path, [string] $Label)
1034    $item = Get-Item -LiteralPath $Path -Force
1035    if (-not $item.PSIsContainer -or
1036        (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
1037        throw "$Label is not a real directory"
1038    }
1039}
1040
1041function Get-FileSha256 {
1042    param([string] $Path)
1043    $stream = [IO.File]::OpenRead($Path)
1044    try {
1045        $sha256 = [Security.Cryptography.SHA256]::Create()
1046        try {
1047            return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '')
1048        } finally {
1049            $sha256.Dispose()
1050        }
1051    } finally {
1052        $stream.Dispose()
1053    }
1054}
1055
1056function Remove-StagingPayload {
1057    try {
1058        $root = [IO.Path]::GetFullPath($StagingPath)
1059        Assert-RealDirectory -Path $root -Label 'self-update staging directory'
1060        $bin = [IO.Path]::Combine($root, 'bin')
1061        $helper = [IO.Path]::Combine($root, 'apply-update.ps1')
1062        foreach ($file in @(
1063            $StagedPath,
1064            [IO.Path]::Combine($root, '.crates.toml'),
1065            [IO.Path]::Combine($root, '.crates2.json'),
1066            $helper
1067        )) {
1068            if ([IO.File]::Exists($file)) {
1069                [IO.File]::Delete($file)
1070            }
1071        }
1072        if ([IO.Directory]::Exists($bin) -and
1073            [IO.Directory]::GetFileSystemEntries($bin).Length -eq 0) {
1074            [IO.Directory]::Delete($bin, $false)
1075        }
1076        if ([IO.Directory]::Exists($root) -and
1077            [IO.Directory]::GetFileSystemEntries($root).Length -eq 0) {
1078            [IO.Directory]::Delete($root, $false)
1079        }
1080    } catch {
1081        # Cleanup is best-effort; update status and recovery files stay intact.
1082    }
1083}
1084
1085$replaced = $false
1086try {
1087    $stagingFull = [IO.Path]::GetFullPath($StagingPath)
1088    $stagedFull = [IO.Path]::GetFullPath($StagedPath)
1089    $targetFull = [IO.Path]::GetFullPath($TargetPath)
1090    $tempFull = [IO.Path]::GetFullPath($TempPath)
1091    $backupFull = [IO.Path]::GetFullPath($BackupPath)
1092    $rollbackDisplacedFull = [IO.Path]::GetFullPath($RollbackDisplacedPath)
1093    $helperFull = [IO.Path]::Combine($stagingFull, 'apply-update.ps1')
1094    $statusFull = [IO.Path]::GetFullPath($StatusPath)
1095    $targetDirectory = [IO.Path]::GetDirectoryName($targetFull)
1096    if (-not [StringComparer]::OrdinalIgnoreCase.Equals(
1097            [IO.Path]::GetDirectoryName([IO.Path]::GetDirectoryName($stagedFull)), $stagingFull) -or
1098        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1099            [IO.Path]::GetDirectoryName($helperFull), $stagingFull) -or
1100        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1101            [IO.Path]::GetFullPath($PSScriptRoot), $stagingFull) -or
1102        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1103            [IO.Path]::GetDirectoryName($statusFull), [IO.Path]::GetDirectoryName($stagingFull)) -or
1104        [string]::IsNullOrWhiteSpace($targetDirectory) -or
1105        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1106            [IO.Path]::GetDirectoryName($tempFull), $targetDirectory) -or
1107        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1108            [IO.Path]::GetDirectoryName($backupFull), $targetDirectory) -or
1109        -not [StringComparer]::OrdinalIgnoreCase.Equals(
1110            [IO.Path]::GetDirectoryName($rollbackDisplacedFull), $targetDirectory)) {
1111        throw 'self-update paths are outside their validated staging or target directories'
1112    }
1113
1114    Assert-RealDirectory -Path $stagingFull -Label 'self-update staging directory'
1115    Assert-RealDirectory -Path $targetDirectory -Label 'target directory'
1116    Assert-WindowsExecutable -Path $stagedFull -Label 'staged executable'
1117    Assert-WindowsExecutable -Path $targetFull -Label 'current executable'
1118    $expectedHash = Get-FileSha256 -Path $stagedFull
1119    $originalTargetHash = Get-FileSha256 -Path $targetFull
1120    if ([IO.File]::Exists($tempFull) -or [IO.File]::Exists($backupFull) -or
1121        [IO.File]::Exists($rollbackDisplacedFull)) {
1122        throw 'replacement, backup, or rollback path already exists'
1123    }
1124
1125    Write-UpdateStatus -State 'waiting' -Message 'waiting for the current rpi process to exit'
1126    if ($ParentPid -lt 1 -or $ParentPid -gt [int]::MaxValue) {
1127        throw 'parent process id is outside the supported Windows range'
1128    }
1129    $parentProcess = $null
1130    try {
1131        $candidate = [Diagnostics.Process]::GetProcessById([int]$ParentPid)
1132        try {
1133            $candidatePath = [IO.Path]::GetFullPath($candidate.MainModule.FileName)
1134            if ([StringComparer]::OrdinalIgnoreCase.Equals($candidatePath, $targetFull)) {
1135                $parentProcess = $candidate
1136            } else {
1137                $candidate.Dispose()
1138            }
1139        } catch {
1140            if ($candidate.HasExited) {
1141                $candidate.Dispose()
1142            } else {
1143                throw
1144            }
1145        }
1146    } catch [ArgumentException] {
1147        # The invoking process exited before the helper acquired its handle.
1148    }
1149    if ($null -ne $parentProcess) {
1150        try {
1151            if (-not $parentProcess.WaitForExit(600000)) {
1152                throw 'timed out waiting for the current rpi process to exit'
1153            }
1154        } finally {
1155            $parentProcess.Dispose()
1156        }
1157    }
1158
1159    Assert-RealDirectory -Path $targetDirectory -Label 'target directory'
1160    Assert-WindowsExecutable -Path $stagedFull -Label 'staged executable'
1161    Assert-WindowsExecutable -Path $targetFull -Label 'current executable'
1162    if ((Get-FileSha256 -Path $stagedFull) -ne $expectedHash) {
1163        throw 'staged executable changed while waiting for rpi to exit'
1164    }
1165    if ((Get-FileSha256 -Path $targetFull) -ne $originalTargetHash) {
1166        throw 'current executable changed while waiting for rpi to exit'
1167    }
1168    if ([IO.File]::Exists($tempFull) -or [IO.File]::Exists($backupFull) -or
1169        [IO.File]::Exists($rollbackDisplacedFull)) {
1170        throw 'replacement, backup, or rollback path appeared while waiting for rpi to exit'
1171    }
1172
1173    [IO.File]::Copy($stagedFull, $tempFull, $false)
1174    Assert-WindowsExecutable -Path $tempFull -Label 'replacement executable'
1175    if ((Get-FileSha256 -Path $tempFull) -ne $expectedHash) {
1176        throw 'replacement executable hash mismatch'
1177    }
1178
1179    [IO.File]::Replace($tempFull, $targetFull, $backupFull, $true)
1180    $replaced = $true
1181    Assert-WindowsExecutable -Path $targetFull -Label 'updated executable'
1182    if ((Get-FileSha256 -Path $targetFull) -ne $expectedHash) {
1183        throw 'updated executable hash mismatch'
1184    }
1185} catch {
1186    $failureMessage = $_.Exception.Message
1187    $recoveryMessage = ''
1188    if ($replaced -and [IO.File]::Exists($BackupPath)) {
1189        try {
1190            [IO.File]::Copy($BackupPath, $TempPath, $false)
1191            Assert-WindowsExecutable -Path $TempPath -Label 'rollback executable'
1192            if ((Get-FileSha256 -Path $TempPath) -ne $originalTargetHash) {
1193                throw 'rollback executable hash mismatch'
1194            }
1195            [IO.File]::Replace($TempPath, $TargetPath, $RollbackDisplacedPath, $true)
1196            Assert-WindowsExecutable -Path $TargetPath -Label 'restored executable'
1197            if ((Get-FileSha256 -Path $TargetPath) -ne $originalTargetHash) {
1198                throw 'restored executable hash mismatch'
1199            }
1200            $replaced = $false
1201            try { [IO.File]::Delete($BackupPath) } catch {}
1202            try { [IO.File]::Delete($RollbackDisplacedPath) } catch {}
1203        } catch {
1204            $rollbackFailure = $_.Exception.Message
1205            if ([IO.File]::Exists($BackupPath)) {
1206                $recoveryMessage = "; automatic rollback failed ($rollbackFailure); the original executable remains at $BackupPath"
1207            } else {
1208                $recoveryMessage = "; automatic rollback failed ($rollbackFailure) and no verified recovery backup remains"
1209            }
1210        }
1211    }
1212    if ([IO.File]::Exists($TempPath)) {
1213        try { Remove-Item -LiteralPath $TempPath -Force } catch {}
1214    }
1215    try { Write-UpdateStatus -State 'failed' -Message ($failureMessage + $recoveryMessage) } catch {}
1216    Remove-StagingPayload
1217    exit 1
1218}
1219
1220try { Write-UpdateStatus -State 'succeeded' -Message 'rpi executable replaced successfully' } catch {}
1221if ([IO.File]::Exists($BackupPath)) {
1222    try { Remove-Item -LiteralPath $BackupPath -Force } catch {}
1223}
1224Remove-StagingPayload
1225exit 0
1226"#;
1227
1228#[cfg(windows)]
1229fn run_windows_self_update() -> i32 {
1230    let staging = match create_windows_update_staging() {
1231        Ok(staging) => staging,
1232        Err(error) => {
1233            eprintln!("error: could not create self-update staging directory: {error}");
1234            return 1;
1235        }
1236    };
1237    let staging_dir = staging.directory.path().to_path_buf();
1238    let status_file = staging.status_file.clone();
1239    let _ = write_windows_update_status(
1240        &status_file,
1241        "preparing",
1242        "installing the update into staging",
1243    );
1244
1245    let current_exe = match std::env::current_exe() {
1246        Ok(path) => path,
1247        Err(error) => {
1248            let message = format!("could not resolve the current rpi executable: {error}");
1249            let _ = write_windows_update_status(&status_file, "failed", &message);
1250            eprintln!("error: {message}");
1251            return 1;
1252        }
1253    };
1254    let powershell = match system_powershell_path() {
1255        Ok(path) => path,
1256        Err(error) => {
1257            let _ = write_windows_update_status(&status_file, "failed", &error);
1258            eprintln!("error: {error}");
1259            return 1;
1260        }
1261    };
1262
1263    let command_dir = match create_self_update_command_dir() {
1264        Ok(directory) => directory,
1265        Err(error) => {
1266            let message = format!("could not create an isolated Cargo directory: {error}");
1267            let _ = write_windows_update_status(&status_file, "failed", &message);
1268            eprintln!("error: {message}");
1269            return 1;
1270        }
1271    };
1272    let mut cargo = match cargo_install_command(Some(&staging_dir), command_dir.path()) {
1273        Ok(command) => command,
1274        Err(error) => {
1275            let message = format!("invalid isolated Cargo directory: {error}");
1276            let _ = write_windows_update_status(&status_file, "failed", &message);
1277            eprintln!("error: {message}");
1278            return 1;
1279        }
1280    };
1281    let status = cargo.status();
1282    match status {
1283        Ok(status) if status.success() => {}
1284        Ok(status) => {
1285            let message = format!("cargo install exited with {status}");
1286            let _ = write_windows_update_status(&status_file, "failed", &message);
1287            eprintln!("error: {message}");
1288            return 1;
1289        }
1290        Err(error) => {
1291            let message = format!("could not run cargo (install Rust/Cargo first): {error}");
1292            let _ = write_windows_update_status(&status_file, "failed", &message);
1293            eprintln!("error: {message}");
1294            return 1;
1295        }
1296    }
1297
1298    let plan = match build_windows_update_plan(&staging_dir, &current_exe, &status_file) {
1299        Ok(plan) => plan,
1300        Err(error) => {
1301            let _ = write_windows_update_status(&status_file, "failed", &error);
1302            eprintln!("error: {error}");
1303            return 1;
1304        }
1305    };
1306    let staged_version = match validate_staged_rpi(&plan) {
1307        Ok(version) => version,
1308        Err(error) => {
1309            let _ = write_windows_update_status(&plan.status_file, "failed", &error);
1310            eprintln!("error: {error}");
1311            return 1;
1312        }
1313    };
1314    if let Err(error) = preflight_windows_replacement(&plan) {
1315        let _ = write_windows_update_status(&plan.status_file, "failed", &error);
1316        eprintln!("error: {error}");
1317        return 1;
1318    }
1319    if let Err(error) = std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER.as_bytes()) {
1320        let message = format!("could not write the self-update helper: {error}");
1321        let _ = write_windows_update_status(&plan.status_file, "failed", &message);
1322        eprintln!("error: {message}");
1323        return 1;
1324    }
1325    if validated_regular_file(&plan.helper_script, "self-update helper").is_err() {
1326        let message = "self-update helper did not pass regular-file validation";
1327        let _ = write_windows_update_status(&plan.status_file, "failed", message);
1328        eprintln!("error: {message}");
1329        return 1;
1330    }
1331    let _ = write_windows_update_status(
1332        &plan.status_file,
1333        "scheduled",
1334        &format!("validated rpi {staged_version}; waiting to replace rpi after this process exits"),
1335    );
1336    let mut command = windows_update_helper_command(
1337        &powershell,
1338        &plan,
1339        std::process::id(),
1340        &staged_version.to_string(),
1341    );
1342    use std::os::windows::process::CommandExt;
1343    command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
1344    match command.spawn() {
1345        Ok(_) => {
1346            let persisted_staging = staging.directory.keep();
1347            println!(
1348                "rpi update staged; it will be applied after this process exits\nStatus: {}",
1349                plan.status_file.display()
1350            );
1351            debug_assert!(windows_paths_equal(&persisted_staging, &plan.staging_dir));
1352            0
1353        }
1354        Err(error) => {
1355            let message = format!("could not start the self-update helper: {error}");
1356            let _ = write_windows_update_status(&plan.status_file, "failed", &message);
1357            eprintln!("error: {message}");
1358            1
1359        }
1360    }
1361}
1362
1363#[cfg(windows)]
1364fn create_windows_update_staging() -> Result<WindowsUpdateStaging, String> {
1365    let staging = create_self_update_staging_directory()?;
1366    let update_root = staging
1367        .path()
1368        .parent()
1369        .ok_or_else(|| "self-update staging directory has no parent".to_string())?;
1370    let status_file = update_root.join(format!("status-{}.json", uuid::Uuid::new_v4().simple()));
1371    if status_file.exists() {
1372        return Err("self-update status path collision".to_string());
1373    }
1374    Ok(WindowsUpdateStaging {
1375        directory: staging,
1376        status_file,
1377    })
1378}
1379
1380#[cfg(windows)]
1381fn build_windows_update_plan(
1382    staging_dir: &Path,
1383    target_exe: &Path,
1384    status_file: &Path,
1385) -> Result<WindowsUpdatePlan, String> {
1386    let staging_dir = validated_real_directory(staging_dir, "self-update staging directory")?;
1387    if !status_file.is_absolute() {
1388        return Err("self-update status path must be absolute".to_string());
1389    }
1390    let status_parent = validated_real_directory(
1391        status_file
1392            .parent()
1393            .ok_or_else(|| "self-update status path has no parent".to_string())?,
1394        "self-update status directory",
1395    )?;
1396    if staging_dir.parent() != Some(status_parent.as_path()) {
1397        return Err("self-update status file is outside the staging parent".to_string());
1398    }
1399    let status_name = status_file
1400        .file_name()
1401        .ok_or_else(|| "self-update status file has no name".to_string())?;
1402    if !is_self_update_status_name(status_name) {
1403        return Err("self-update status file has an invalid name".to_string());
1404    }
1405    if status_file.exists() {
1406        validated_regular_file(status_file, "self-update status file")?;
1407    }
1408    let staged_exe = validated_windows_executable(
1409        &staging_dir.join("bin").join("rpi.exe"),
1410        "staged rpi executable",
1411    )?;
1412    let target_exe = validated_windows_executable(target_exe, "current rpi executable")?;
1413    let target_dir = validated_real_directory(
1414        target_exe
1415            .parent()
1416            .ok_or_else(|| "current executable has no parent directory".to_string())?,
1417        "current executable directory",
1418    )?;
1419    if target_exe.parent() != Some(target_dir.as_path()) {
1420        return Err("current executable is not a direct child of its validated directory".into());
1421    }
1422    let token = uuid::Uuid::new_v4().simple().to_string();
1423    let replacement_temp = target_dir.join(format!(".rpi-update-{token}.new.exe"));
1424    let backup_file = target_dir.join(format!(".rpi-update-{token}.old.exe"));
1425    let rollback_displaced_file = target_dir.join(format!(".rpi-update-{token}.rollback-new.exe"));
1426    if replacement_temp.exists() || backup_file.exists() || rollback_displaced_file.exists() {
1427        return Err("self-update replacement path collision".to_string());
1428    }
1429
1430    Ok(WindowsUpdatePlan {
1431        staging_dir: staging_dir.clone(),
1432        staged_exe,
1433        target_exe,
1434        helper_script: staging_dir.join("apply-update.ps1"),
1435        status_file: status_file.to_path_buf(),
1436        replacement_temp,
1437        backup_file,
1438        rollback_displaced_file,
1439    })
1440}
1441
1442#[cfg(windows)]
1443fn preflight_windows_replacement(plan: &WindowsUpdatePlan) -> Result<(), String> {
1444    for path in [
1445        &plan.replacement_temp,
1446        &plan.backup_file,
1447        &plan.rollback_displaced_file,
1448    ] {
1449        if path.exists() {
1450            return Err(format!(
1451                "self-update path already exists: {}",
1452                path.display()
1453            ));
1454        }
1455        let file = std::fs::OpenOptions::new()
1456            .write(true)
1457            .create_new(true)
1458            .open(path)
1459            .map_err(|error| format!("cannot write beside the current executable: {error}"))?;
1460        drop(file);
1461        std::fs::remove_file(path)
1462            .map_err(|error| format!("could not remove self-update probe: {error}"))?;
1463    }
1464    Ok(())
1465}
1466
1467#[cfg(windows)]
1468fn validate_staged_rpi(plan: &WindowsUpdatePlan) -> Result<semver::Version, String> {
1469    let cwd = plan
1470        .helper_script
1471        .parent()
1472        .ok_or_else(|| "self-update staging directory is missing".to_string())?;
1473    validate_rpi_executable_version(&plan.staged_exe, cwd, crate::VERSION, "staged rpi")
1474}
1475
1476/// Captured output of a bounded self-update probe.
1477struct BoundedProcessOutput {
1478    status: std::process::ExitStatus,
1479    stdout: Vec<u8>,
1480    stderr: Vec<u8>,
1481}
1482
1483/// Run a short-lived probe under both a wall-clock and an output cap.
1484///
1485/// The child runs on its own thread and current-thread runtime so this stays
1486/// safe when `rpi update` is entered from inside a Tokio runtime. The old
1487/// implementation reused the npm bridge's process runner, which went away with
1488/// the Pi compatibility layer.
1489fn run_bounded_process_blocking(
1490    program: &str,
1491    args: &[String],
1492    cwd: &Path,
1493    timeout: std::time::Duration,
1494    max_output_bytes: usize,
1495) -> Result<BoundedProcessOutput, String> {
1496    let program = program.to_string();
1497    let args = args.to_vec();
1498    let cwd = cwd.to_path_buf();
1499    std::thread::Builder::new()
1500        .name("rpi-bounded-version-probe".to_string())
1501        .spawn(move || {
1502            let runtime = tokio::runtime::Builder::new_current_thread()
1503                .enable_all()
1504                .build()
1505                .map_err(|error| format!("probe runtime failed: {error}"))?;
1506            runtime.block_on(async move {
1507                let child = tokio::process::Command::new(&program)
1508                    .args(&args)
1509                    .current_dir(&cwd)
1510                    .stdin(std::process::Stdio::null())
1511                    .stdout(std::process::Stdio::piped())
1512                    .stderr(std::process::Stdio::piped())
1513                    .kill_on_drop(true)
1514                    .spawn()
1515                    .map_err(|error| format!("spawn failed: {error}"))?;
1516                let output = match tokio::time::timeout(timeout, child.wait_with_output()).await {
1517                    Ok(result) => result.map_err(|error| format!("wait failed: {error}"))?,
1518                    // Dropping the future drops the child, which `kill_on_drop`
1519                    // turns into a kill.
1520                    Err(_) => return Err(format!("timed out after {timeout:?}")),
1521                };
1522                Ok(BoundedProcessOutput {
1523                    status: output.status,
1524                    stdout: cap_bytes(output.stdout, max_output_bytes),
1525                    stderr: cap_bytes(output.stderr, max_output_bytes),
1526                })
1527            })
1528        })
1529        .map_err(|error| format!("probe thread failed: {error}"))?
1530        .join()
1531        .map_err(|_| "probe worker panicked".to_string())?
1532}
1533
1534fn cap_bytes(bytes: Vec<u8>, limit: usize) -> Vec<u8> {
1535    if bytes.len() <= limit {
1536        bytes
1537    } else {
1538        bytes[..limit].to_vec()
1539    }
1540}
1541
1542fn validate_rpi_executable_version(
1543    executable: &Path,
1544    cwd: &Path,
1545    current: &str,
1546    label: &str,
1547) -> Result<semver::Version, String> {
1548    let program = executable
1549        .to_str()
1550        .ok_or_else(|| format!("{label} path cannot be represented as Unicode"))?;
1551    let args = vec!["--version".to_string()];
1552    let output = run_bounded_process_blocking(
1553        program,
1554        &args,
1555        cwd,
1556        STAGED_RPI_CHECK_TIMEOUT,
1557        MAX_STAGED_RPI_OUTPUT_BYTES,
1558    )
1559    .map_err(|error| format!("{label} version check failed: {error}"))?;
1560    if !output.status.success() {
1561        return Err(format!("{label} --version exited with {}", output.status));
1562    }
1563    validate_staged_rpi_version_output(&output.stdout, &output.stderr, current)
1564}
1565
1566fn validate_staged_rpi_version_output(
1567    stdout: &[u8],
1568    stderr: &[u8],
1569    current: &str,
1570) -> Result<semver::Version, String> {
1571    if !stderr.is_empty() {
1572        return Err("staged rpi --version wrote unexpected stderr output".to_string());
1573    }
1574    let staged = parse_rpi_version_output(stdout)
1575        .ok_or_else(|| "staged rpi returned an invalid --version response".to_string())?;
1576    let current = semver::Version::parse(current)
1577        .map_err(|_| "the running rpi version is not valid semantic versioning".to_string())?;
1578    if staged < current {
1579        return Err(format!(
1580            "refusing to replace rpi {current} with older version {staged}"
1581        ));
1582    }
1583    Ok(staged)
1584}
1585
1586fn parse_rpi_version_output(output: &[u8]) -> Option<semver::Version> {
1587    let output = std::str::from_utf8(output).ok()?;
1588    let line = output
1589        .strip_suffix("\r\n")
1590        .or_else(|| output.strip_suffix('\n'))
1591        .unwrap_or(output);
1592    if line.contains(['\r', '\n']) {
1593        return None;
1594    }
1595    let version = line.strip_prefix(crate::APP_NAME)?.strip_prefix(' ')?;
1596    if version.is_empty() || version.chars().any(char::is_whitespace) {
1597        return None;
1598    }
1599    semver::Version::parse(version).ok()
1600}
1601
1602fn validated_real_directory(path: &Path, label: &str) -> Result<PathBuf, String> {
1603    if !path.is_absolute() {
1604        return Err(format!("{label} must be absolute"));
1605    }
1606    let metadata = std::fs::symlink_metadata(path)
1607        .map_err(|error| format!("could not inspect {label}: {error}"))?;
1608    if !metadata.is_dir() || metadata.file_type().is_symlink() {
1609        return Err(format!("{label} is not a real directory"));
1610    }
1611    let canonical = std::fs::canonicalize(path)
1612        .map(normalize_windows_path)
1613        .map_err(|error| format!("could not canonicalize {label}: {error}"))?;
1614    if !windows_paths_equal(&canonical, path) {
1615        return Err(format!("{label} resolves through a link or junction"));
1616    }
1617    Ok(canonical)
1618}
1619
1620fn validated_regular_file(path: &Path, label: &str) -> Result<PathBuf, String> {
1621    if !path.is_absolute() {
1622        return Err(format!("{label} must be absolute"));
1623    }
1624    let metadata = std::fs::symlink_metadata(path)
1625        .map_err(|error| format!("could not inspect {label}: {error}"))?;
1626    if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() == 0 {
1627        return Err(format!("{label} is not a non-empty regular file"));
1628    }
1629    let canonical = std::fs::canonicalize(path)
1630        .map(normalize_windows_path)
1631        .map_err(|error| format!("could not canonicalize {label}: {error}"))?;
1632    if !windows_paths_equal(&canonical, path) {
1633        return Err(format!("{label} resolves through a link or junction"));
1634    }
1635    Ok(canonical)
1636}
1637
1638fn is_self_update_status_name(name: &std::ffi::OsStr) -> bool {
1639    let Some(name) = name.to_str() else {
1640        return false;
1641    };
1642    let Some(token) = name
1643        .strip_prefix("status-")
1644        .and_then(|name| name.strip_suffix(".json"))
1645    else {
1646        return false;
1647    };
1648    token.len() == 32 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
1649}
1650
1651fn validated_self_update_status_file(update_root: &Path, path: &Path) -> Result<PathBuf, String> {
1652    if !path.is_absolute() || path.parent() != Some(update_root) {
1653        return Err("self-update status file is outside the update root".to_string());
1654    }
1655    if !path.file_name().is_some_and(is_self_update_status_name) {
1656        return Err("self-update status file has an invalid name".to_string());
1657    }
1658    let path = validated_regular_file(path, "self-update status file")?;
1659    let length = std::fs::metadata(&path)
1660        .map_err(|error| format!("could not inspect self-update status file: {error}"))?
1661        .len();
1662    if length > MAX_SELF_UPDATE_STATUS_BYTES {
1663        return Err("self-update status file is too large".to_string());
1664    }
1665    Ok(path)
1666}
1667
1668fn read_self_update_status(path: &Path) -> Option<SelfUpdateStatus> {
1669    use std::io::Read;
1670
1671    let file = std::fs::File::open(path).ok()?;
1672    let mut data = Vec::with_capacity(
1673        usize::try_from(MAX_SELF_UPDATE_STATUS_BYTES)
1674            .unwrap_or_default()
1675            .min(8 * 1024),
1676    );
1677    file.take(MAX_SELF_UPDATE_STATUS_BYTES.saturating_add(1))
1678        .read_to_end(&mut data)
1679        .ok()?;
1680    if data.len() as u64 > MAX_SELF_UPDATE_STATUS_BYTES {
1681        return None;
1682    }
1683    serde_json::from_slice(&data).ok()
1684}
1685
1686fn sanitized_self_update_status_message(message: &str) -> String {
1687    let mut sanitized = String::new();
1688    let mut pending_space = false;
1689    let mut truncated = false;
1690    for character in message.trim().chars() {
1691        if character.is_control() || character.is_whitespace() {
1692            pending_space = !sanitized.is_empty();
1693            continue;
1694        }
1695        if sanitized.chars().count() >= MAX_SELF_UPDATE_STATUS_MESSAGE_CHARS {
1696            truncated = true;
1697            break;
1698        }
1699        if pending_space {
1700            sanitized.push(' ');
1701            pending_space = false;
1702        }
1703        sanitized.push(character);
1704    }
1705    if sanitized.is_empty() {
1706        return "no diagnostic message was recorded".to_string();
1707    }
1708    if truncated {
1709        sanitized.push_str("...");
1710    }
1711    sanitized
1712}
1713
1714fn consume_self_update_statuses(agent_dir: &Path) -> Vec<UpdateWarning> {
1715    let _guard = SELF_UPDATE_STATUS_LOCK
1716        .lock()
1717        .unwrap_or_else(std::sync::PoisonError::into_inner);
1718    let agent_dir = match validated_real_directory(agent_dir, "agent directory") {
1719        Ok(path) => path,
1720        Err(_) => return Vec::new(),
1721    };
1722    let update_root = match validated_real_directory(
1723        &agent_dir.join("self-update"),
1724        "self-update status directory",
1725    ) {
1726        Ok(path) if path.parent() == Some(agent_dir.as_path()) => path,
1727        _ => return Vec::new(),
1728    };
1729    let entries = match std::fs::read_dir(&update_root) {
1730        Ok(entries) => entries,
1731        Err(_) => return Vec::new(),
1732    };
1733
1734    let mut warnings = Vec::new();
1735    for entry in entries.flatten() {
1736        if !is_self_update_status_name(&entry.file_name()) {
1737            continue;
1738        }
1739        let path = match validated_self_update_status_file(&update_root, &entry.path()) {
1740            Ok(path) => path,
1741            Err(_) => continue,
1742        };
1743        let Some(status) = read_self_update_status(&path) else {
1744            continue;
1745        };
1746        if !matches!(status.state.as_str(), "failed" | "succeeded") {
1747            continue;
1748        }
1749
1750        match std::fs::remove_file(&path) {
1751            Ok(()) => {}
1752            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1753            // A failure is still actionable even when cleanup is denied. Keep
1754            // the file so a later startup can retry consumption.
1755            Err(_) if status.state == "failed" => {}
1756            Err(_) => continue,
1757        }
1758        if status.state == "failed" {
1759            warnings.push(UpdateWarning {
1760                message: format!(
1761                    "The previously scheduled rpi update failed: {}",
1762                    sanitized_self_update_status_message(&status.message)
1763                ),
1764                command: "rpi update".to_string(),
1765            });
1766        }
1767    }
1768    warnings
1769}
1770
1771#[cfg(windows)]
1772fn validated_windows_executable(path: &Path, label: &str) -> Result<PathBuf, String> {
1773    use std::io::Read;
1774
1775    let path = validated_regular_file(path, label)?;
1776    let mut file =
1777        std::fs::File::open(&path).map_err(|error| format!("could not open {label}: {error}"))?;
1778    let mut magic = [0u8; 2];
1779    file.read_exact(&mut magic)
1780        .map_err(|error| format!("could not read {label}: {error}"))?;
1781    if magic != *b"MZ" {
1782        return Err(format!("{label} is not a Windows executable"));
1783    }
1784    Ok(path)
1785}
1786
1787#[cfg(windows)]
1788fn system_powershell_path() -> Result<PathBuf, String> {
1789    use std::ffi::OsString;
1790    use std::os::windows::ffi::OsStringExt;
1791    use windows_sys::Win32::System::SystemInformation::GetWindowsDirectoryW;
1792
1793    let mut buffer = vec![0u16; 260];
1794    let system_root = loop {
1795        let length = unsafe {
1796            GetWindowsDirectoryW(
1797                buffer.as_mut_ptr(),
1798                u32::try_from(buffer.len()).unwrap_or(u32::MAX),
1799            )
1800        };
1801        if length == 0 {
1802            return Err(format!(
1803                "could not locate the Windows system directory: {}",
1804                std::io::Error::last_os_error()
1805            ));
1806        }
1807        let length = usize::try_from(length)
1808            .map_err(|error| format!("invalid Windows system directory length: {error}"))?;
1809        if length < buffer.len() {
1810            buffer.truncate(length);
1811            break PathBuf::from(OsString::from_wide(&buffer));
1812        }
1813        buffer.resize(length.saturating_add(1), 0);
1814    };
1815    let system_root = validated_real_directory(&system_root, "Windows system root")?;
1816    let powershell = validated_windows_executable(
1817        &system_root.join("System32/WindowsPowerShell/v1.0/powershell.exe"),
1818        "Windows PowerShell",
1819    )?;
1820    powershell
1821        .strip_prefix(&system_root)
1822        .map_err(|_| "Windows PowerShell is outside SystemRoot".to_string())?;
1823    Ok(powershell)
1824}
1825
1826#[cfg(windows)]
1827fn windows_update_helper_command(
1828    powershell: &Path,
1829    plan: &WindowsUpdatePlan,
1830    parent_pid: u32,
1831    staged_version: &str,
1832) -> std::process::Command {
1833    let mut command = std::process::Command::new(powershell);
1834    let powershell_modules = powershell
1835        .parent()
1836        .expect("validated PowerShell path has a parent")
1837        .join("Modules");
1838    command
1839        .args([
1840            "-NoLogo",
1841            "-NoProfile",
1842            "-NonInteractive",
1843            "-ExecutionPolicy",
1844            "Bypass",
1845            "-File",
1846        ])
1847        .arg(&plan.helper_script)
1848        .arg("-ParentPid")
1849        .arg(parent_pid.to_string())
1850        .arg("-StagingPath")
1851        .arg(&plan.staging_dir)
1852        .arg("-StagedPath")
1853        .arg(&plan.staged_exe)
1854        .arg("-StagedVersion")
1855        .arg(staged_version)
1856        .arg("-TargetPath")
1857        .arg(&plan.target_exe)
1858        .arg("-TempPath")
1859        .arg(&plan.replacement_temp)
1860        .arg("-BackupPath")
1861        .arg(&plan.backup_file)
1862        .arg("-RollbackDisplacedPath")
1863        .arg(&plan.rollback_displaced_file)
1864        .arg("-StatusPath")
1865        .arg(&plan.status_file)
1866        .current_dir(
1867            plan.status_file
1868                .parent()
1869                .expect("validated status path has an update-root parent"),
1870        )
1871        .env("PSModulePath", powershell_modules)
1872        .stdin(Stdio::null())
1873        .stdout(Stdio::null())
1874        .stderr(Stdio::null());
1875    command
1876}
1877
1878#[cfg(windows)]
1879fn write_windows_update_status(path: &Path, state: &str, message: &str) -> Result<(), String> {
1880    let data = serde_json::to_vec_pretty(&serde_json::json!({
1881        "state": state,
1882        "message": message,
1883        "parentPid": std::process::id(),
1884        "currentVersion": crate::VERSION,
1885        "updatedAtMs": now_ms(),
1886    }))
1887    .map_err(|error| error.to_string())?;
1888    std::fs::write(path, data)
1889        .map_err(|error| format!("could not write update status {}: {error}", path.display()))
1890}
1891
1892#[cfg(test)]
1893mod tests {
1894    /// The self-update path validation refuses any ancestor that resolves
1895    /// through a link or junction, so fixtures must not be built under an
1896    /// ambient TEMP that happens to be a junction (e.g. `D:\Temp` on some
1897    /// Windows hosts). Canonicalizing the base first strips the junction.
1898    fn real_tempdir() -> tempfile::TempDir {
1899        // Canonicalizing yields a verbatim (`\?\`) path on Windows; strip it
1900        // again so fixtures look like ordinary user paths. Leaving it verbatim
1901        // makes PowerShell compare `$PSScriptRoot` against a `\?\` argument
1902        // and reject the run as "outside the validated staging directory".
1903        let base = std::fs::canonicalize(std::env::temp_dir())
1904            .map(super::normalize_windows_path)
1905            .unwrap_or_else(|_| std::env::temp_dir());
1906        tempfile::Builder::new()
1907            .prefix("rpi-update-test-")
1908            .tempdir_in(base)
1909            .unwrap()
1910    }
1911
1912    use std::collections::BTreeMap;
1913    use std::ffi::OsString;
1914    use std::path::Path;
1915    use std::sync::atomic::{AtomicUsize, Ordering};
1916    use std::sync::Arc;
1917
1918    #[cfg(unix)]
1919    use super::{activate_direct_update, build_direct_update_plan};
1920    #[cfg(windows)]
1921    use super::{
1922        build_windows_update_plan, preflight_windows_replacement, system_powershell_path,
1923        windows_update_helper_command, write_windows_update_status, WINDOWS_UPDATE_HELPER,
1924    };
1925    use super::{
1926        cargo_home_is_project_local, cargo_install_command, collect_bounded,
1927        create_self_update_command_dir, fetch_crates_url, is_newer, parse_rpi_version_output,
1928        path_is_within, results_with_individual_cache_fallback, validate_staged_rpi_version_output,
1929        RegistryLookup, CACHE_FALLBACK_MAX_AGE_MS, UPDATE_CHECK_CONCURRENCY,
1930    };
1931
1932    struct RestoreConfigDir(Option<OsString>);
1933
1934    impl Drop for RestoreConfigDir {
1935        fn drop(&mut self) {
1936            match self.0.take() {
1937                Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
1938                None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
1939            }
1940        }
1941    }
1942
1943    struct RestoreEnv {
1944        name: &'static str,
1945        value: Option<OsString>,
1946    }
1947
1948    impl RestoreEnv {
1949        fn capture(name: &'static str) -> Self {
1950            Self {
1951                name,
1952                value: std::env::var_os(name),
1953            }
1954        }
1955    }
1956
1957    impl Drop for RestoreEnv {
1958        fn drop(&mut self) {
1959            match self.value.take() {
1960                Some(value) => std::env::set_var(self.name, value),
1961                None => std::env::remove_var(self.name),
1962            }
1963        }
1964    }
1965
1966    #[cfg(windows)]
1967    fn fake_windows_executable(path: &Path, marker: &[u8]) {
1968        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1969        let mut bytes = b"MZ".to_vec();
1970        bytes.extend_from_slice(marker);
1971        std::fs::write(path, bytes).unwrap();
1972    }
1973
1974    #[cfg(unix)]
1975    fn fake_unix_rpi(path: &Path, version: &str, fail_after_rename_to: Option<&str>) {
1976        use std::os::unix::fs::PermissionsExt;
1977
1978        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1979        let failure = fail_after_rename_to.map_or_else(String::new, |name| {
1980            format!(
1981                "case \"$0\" in\n  */{name}) echo 'post-replace failure' >&2; exit 1 ;;\nesac\n"
1982            )
1983        });
1984        std::fs::write(
1985            path,
1986            format!("#!/bin/sh\n{failure}printf 'rpi {version}\\n'\n"),
1987        )
1988        .unwrap();
1989        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
1990    }
1991
1992    fn write_test_update_status(path: &Path, state: &str, message: &str) {
1993        std::fs::write(
1994            path,
1995            serde_json::to_vec(&serde_json::json!({
1996                "state": state,
1997                "message": message,
1998            }))
1999            .unwrap(),
2000        )
2001        .unwrap();
2002    }
2003
2004    #[test]
2005    fn cargo_self_update_staging_is_passed_as_a_structured_argument() {
2006        let temp = real_tempdir();
2007        let staging = temp.path().join("staging");
2008        let command_dir = temp.path().join("command");
2009        std::fs::create_dir_all(&staging).unwrap();
2010        std::fs::create_dir_all(&command_dir).unwrap();
2011        let command = cargo_install_command(Some(&staging), &command_dir).unwrap();
2012        let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2013
2014        assert_eq!(command.get_program(), "cargo");
2015        assert_eq!(args.len(), 6);
2016        assert_eq!(args[0], "install");
2017        assert_eq!(args[1], "--root");
2018        assert_eq!(args[2], staging.as_os_str());
2019        assert_eq!(args[3], "rpi-cli");
2020        assert_eq!(args[4], "--locked");
2021        assert_eq!(args[5], "--force");
2022        assert_eq!(command.get_current_dir(), Some(command_dir.as_path()));
2023    }
2024
2025    #[cfg(unix)]
2026    #[test]
2027    fn direct_update_replaces_the_selected_target_and_rejects_downgrades() {
2028        let temp = real_tempdir();
2029        let staging = temp.path().join("staging");
2030        let staged = staging.join("bin/rpi");
2031        let target = temp.path().join("target/rpi-current");
2032        fake_unix_rpi(&staged, "9.9.9", None);
2033        fake_unix_rpi(&target, crate::VERSION, None);
2034        let expected = std::fs::read(&staged).unwrap();
2035
2036        let plan = build_direct_update_plan(&staging, &target).unwrap();
2037        assert_eq!(activate_direct_update(&plan).unwrap().to_string(), "9.9.9");
2038        assert_eq!(std::fs::read(&target).unwrap(), expected);
2039        assert!(!plan.replacement_temp.exists());
2040        assert!(!plan.backup_file.exists());
2041
2042        fake_unix_rpi(&staged, "0.0.1", None);
2043        let plan = build_direct_update_plan(&staging, &target).unwrap();
2044        let error = activate_direct_update(&plan).unwrap_err();
2045        assert!(error.contains("older version"), "{error}");
2046        assert_eq!(std::fs::read(&target).unwrap(), expected);
2047    }
2048
2049    #[cfg(unix)]
2050    #[test]
2051    fn direct_update_rolls_back_when_post_replace_validation_fails() {
2052        let temp = real_tempdir();
2053        let staging = temp.path().join("staging");
2054        let staged = staging.join("bin/rpi");
2055        let target = temp.path().join("target/rpi-current");
2056        fake_unix_rpi(&staged, "9.9.9", Some("rpi-current"));
2057        fake_unix_rpi(&target, crate::VERSION, None);
2058        let original = std::fs::read(&target).unwrap();
2059
2060        let plan = build_direct_update_plan(&staging, &target).unwrap();
2061        let error = activate_direct_update(&plan).unwrap_err();
2062
2063        assert!(error.contains("restored the previous"), "{error}");
2064        assert_eq!(std::fs::read(&target).unwrap(), original);
2065        assert!(!plan.replacement_temp.exists());
2066        assert!(!plan.backup_file.exists());
2067    }
2068
2069    #[test]
2070    fn self_update_honors_env_and_early_dispatched_offline_flag() {
2071        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2072        let _restore = RestoreEnv::capture(crate::args::RPI_OFFLINE_ENV);
2073
2074        std::env::set_var(crate::args::RPI_OFFLINE_ENV, "tRuE");
2075        assert_eq!(super::run_self_update(&[]), 0);
2076
2077        std::env::set_var(crate::args::RPI_OFFLINE_ENV, "0");
2078        assert_eq!(super::run_self_update(&["--offline".into()]), 0);
2079        assert_eq!(
2080            std::env::var(crate::args::RPI_OFFLINE_ENV).as_deref(),
2081            Ok("1")
2082        );
2083    }
2084
2085    #[tokio::test]
2086    async fn offline_skips_rpi_startup_checks_before_cache_io() {
2087        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2088        let _restore_config = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2089        let _restore_offline = RestoreEnv::capture(crate::args::RPI_OFFLINE_ENV);
2090        let _restore_disabled = RestoreEnv::capture("RPI_DISABLE_UPDATE_CHECK");
2091        let temp = real_tempdir();
2092        let agent = temp.path().join("offline-agent");
2093        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2094        std::env::set_var(crate::args::RPI_OFFLINE_ENV, "YES");
2095        std::env::remove_var("RPI_DISABLE_UPDATE_CHECK");
2096
2097        let report = super::check_rpi_startup().await;
2098
2099        assert_eq!(report, super::UpdateReport::default());
2100        assert!(
2101            !agent.exists(),
2102            "offline checks must not create the update cache directory"
2103        );
2104    }
2105
2106    #[test]
2107    fn completed_self_update_statuses_are_consumed_once() {
2108        let temp = real_tempdir();
2109        let agent = temp.path().join("agent");
2110        let update_root = agent.join("self-update");
2111        std::fs::create_dir_all(&update_root).unwrap();
2112        let failed = update_root.join("status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json");
2113        let succeeded = update_root.join("status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.json");
2114        let waiting = update_root.join("status-cccccccccccccccccccccccccccccccc.json");
2115        let temporary = update_root.join("status-dddddddddddddddddddddddddddddddd.json.tmp");
2116        let malformed = update_root.join("status-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.json");
2117        let invalid_name = update_root.join("status-not-a-uuid.json");
2118        write_test_update_status(&failed, "failed", "permission\u{1b}[31m\r\n denied\u{7}");
2119        write_test_update_status(&succeeded, "succeeded", "done");
2120        write_test_update_status(&waiting, "waiting", "still running");
2121        write_test_update_status(&temporary, "failed", "not published");
2122        std::fs::write(&malformed, b"{not-json").unwrap();
2123        write_test_update_status(&invalid_name, "failed", "invalid name");
2124
2125        let warnings = super::consume_self_update_statuses(&agent);
2126
2127        assert_eq!(warnings.len(), 1);
2128        assert!(warnings[0]
2129            .message
2130            .contains("rpi update failed: permission [31m denied"));
2131        assert!(!warnings[0].message.chars().any(char::is_control));
2132        assert_eq!(warnings[0].command, "rpi update");
2133        assert!(!failed.exists());
2134        assert!(!succeeded.exists());
2135        assert!(waiting.exists());
2136        assert!(temporary.exists());
2137        assert!(malformed.exists());
2138        assert!(invalid_name.exists());
2139        assert!(super::consume_self_update_statuses(&agent).is_empty());
2140    }
2141
2142    #[test]
2143    fn self_update_status_validation_rejects_escape_and_links() {
2144        let temp = real_tempdir();
2145        let agent = temp.path().join("agent");
2146        let update_root = agent.join("self-update");
2147        let outside_root = temp.path().join("outside");
2148        std::fs::create_dir_all(&update_root).unwrap();
2149        std::fs::create_dir_all(&outside_root).unwrap();
2150        let name = "status-ffffffffffffffffffffffffffffffff.json";
2151        let outside = outside_root.join(name);
2152        write_test_update_status(&outside, "failed", "outside");
2153
2154        assert!(super::validated_self_update_status_file(&update_root, &outside).is_err());
2155
2156        let linked = update_root.join(name);
2157        #[cfg(unix)]
2158        std::os::unix::fs::symlink(&outside, &linked).unwrap();
2159        #[cfg(windows)]
2160        let link_created = std::os::windows::fs::symlink_file(&outside, &linked).is_ok();
2161        #[cfg(unix)]
2162        let link_created = true;
2163        if link_created {
2164            assert!(super::validated_self_update_status_file(&update_root, &linked).is_err());
2165            assert!(super::consume_self_update_statuses(&agent).is_empty());
2166            assert!(outside.exists());
2167        }
2168    }
2169
2170    #[test]
2171    fn cargo_self_update_without_staging_never_inherits_the_callers_cwd() {
2172        let temp = real_tempdir();
2173        let command_dir = temp.path().join("isolated");
2174        std::fs::create_dir_all(&command_dir).unwrap();
2175
2176        let command = cargo_install_command(None, &command_dir).unwrap();
2177        let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2178
2179        assert_eq!(
2180            args,
2181            ["install", "rpi-cli", "--locked", "--force"]
2182                .into_iter()
2183                .map(OsString::from)
2184                .collect::<Vec<_>>()
2185        );
2186        assert_eq!(command.get_current_dir(), Some(command_dir.as_path()));
2187        assert_ne!(
2188            command.get_current_dir(),
2189            std::env::current_dir().ok().as_deref()
2190        );
2191        for variable in [
2192            "RUSTC_WRAPPER",
2193            "RUSTC_WORKSPACE_WRAPPER",
2194            "CARGO_BUILD_RUSTC_WRAPPER",
2195            "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
2196        ] {
2197            assert!(command
2198                .get_envs()
2199                .any(|(name, value)| name == variable && value.is_none()));
2200        }
2201        assert!(cargo_install_command(None, Path::new("relative-command-dir")).is_err());
2202
2203        let generated = create_self_update_command_dir().unwrap();
2204        let caller = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap();
2205        assert!(generated.path().is_absolute());
2206        assert!(!path_is_within(generated.path(), &caller));
2207    }
2208
2209    #[test]
2210    fn project_local_cargo_home_is_rejected_even_when_its_leaf_is_missing() {
2211        let temp = real_tempdir();
2212        let project = temp.path().join("project");
2213        let global = temp.path().join("global-cargo");
2214        std::fs::create_dir_all(&project).unwrap();
2215        std::fs::create_dir_all(&global).unwrap();
2216        let project = std::fs::canonicalize(project).unwrap();
2217
2218        assert!(cargo_home_is_project_local(&project.join(".cargo-home"), &project).unwrap());
2219        assert!(cargo_home_is_project_local(&project, &project).unwrap());
2220        assert!(!cargo_home_is_project_local(&global, &project).unwrap());
2221        assert!(cargo_home_is_project_local(Path::new("relative"), &project).is_err());
2222    }
2223
2224    #[cfg(windows)]
2225    #[test]
2226    fn windows_update_helper_atomically_replaces_a_valid_target() {
2227        let temp = real_tempdir();
2228        let staging = temp.path().join("staging 'semi;& with spaces");
2229        let staged = staging.join("bin/rpi.exe");
2230        let target = temp.path().join("target 'semi;& with spaces/rpi-copy.exe");
2231        fake_windows_executable(&staged, b"new-version");
2232        fake_windows_executable(&target, b"old-version");
2233        let expected = std::fs::read(&staged).unwrap();
2234        let plan = build_windows_update_plan(
2235            &staging,
2236            &target,
2237            &temp
2238                .path()
2239                .join("status-00000000000000000000000000000000.json"),
2240        )
2241        .unwrap();
2242        preflight_windows_replacement(&plan).unwrap();
2243        std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER).unwrap();
2244        write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
2245
2246        let powershell = system_powershell_path().unwrap();
2247        let mut command =
2248            windows_update_helper_command(&powershell, &plan, i32::MAX as u32, "9.9.9");
2249        let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2250        for (flag, expected) in [
2251            ("-StagingPath", &plan.staging_dir),
2252            ("-StagedPath", &plan.staged_exe),
2253            ("-TargetPath", &plan.target_exe),
2254            ("-TempPath", &plan.replacement_temp),
2255            ("-BackupPath", &plan.backup_file),
2256            ("-RollbackDisplacedPath", &plan.rollback_displaced_file),
2257            ("-StatusPath", &plan.status_file),
2258        ] {
2259            let index = args.iter().position(|arg| arg == flag).unwrap();
2260            assert_eq!(args[index + 1], expected.as_os_str());
2261        }
2262        let version_index = args.iter().position(|arg| arg == "-StagedVersion").unwrap();
2263        assert_eq!(args[version_index + 1], "9.9.9");
2264        assert_eq!(command.get_current_dir(), plan.status_file.parent());
2265        assert!(command.get_envs().any(|(name, value)| {
2266            name == "PSModulePath"
2267                && value == Some(powershell.parent().unwrap().join("Modules").as_os_str())
2268        }));
2269        let status = command.status().unwrap();
2270
2271        let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
2272        assert!(
2273            status.success(),
2274            "helper exited with {status}: {status_text}"
2275        );
2276        assert_eq!(std::fs::read(&target).unwrap(), expected);
2277        assert!(!staging.exists());
2278        assert!(!plan.replacement_temp.exists());
2279        assert!(!plan.backup_file.exists());
2280        assert!(!plan.rollback_displaced_file.exists());
2281        let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
2282        assert_eq!(status["state"], "succeeded");
2283        assert_eq!(status["stagedVersion"], "9.9.9");
2284    }
2285
2286    #[cfg(windows)]
2287    #[test]
2288    fn windows_update_helper_failure_preserves_the_old_executable() {
2289        let temp = real_tempdir();
2290        let staging = temp.path().join("staging with spaces");
2291        let staged = staging.join("bin/rpi.exe");
2292        let target = temp.path().join("target with spaces/rpi-copy.exe");
2293        fake_windows_executable(&staged, b"new-version");
2294        fake_windows_executable(&target, b"old-version");
2295        let expected = std::fs::read(&target).unwrap();
2296        let plan = build_windows_update_plan(
2297            &staging,
2298            &target,
2299            &temp
2300                .path()
2301                .join("status-00000000000000000000000000000000.json"),
2302        )
2303        .unwrap();
2304        preflight_windows_replacement(&plan).unwrap();
2305        std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER).unwrap();
2306        write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
2307        std::fs::remove_file(&plan.staged_exe).unwrap();
2308
2309        let status = windows_update_helper_command(
2310            &system_powershell_path().unwrap(),
2311            &plan,
2312            i32::MAX as u32,
2313            "9.9.9",
2314        )
2315        .status()
2316        .unwrap();
2317
2318        let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
2319        assert!(!status.success());
2320        assert_eq!(
2321            std::fs::read(&target).unwrap(),
2322            expected,
2323            "status={status_text}; backup_exists={}",
2324            plan.backup_file.exists()
2325        );
2326        assert!(!staging.exists());
2327        assert!(!plan.replacement_temp.exists());
2328        assert!(!plan.backup_file.exists());
2329        assert!(!plan.rollback_displaced_file.exists());
2330        let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
2331        assert_eq!(status["state"], "failed");
2332    }
2333
2334    #[cfg(windows)]
2335    #[test]
2336    fn windows_update_helper_rolls_back_after_post_replace_validation_failure() {
2337        let temp = real_tempdir();
2338        let staging = temp.path().join("staging");
2339        let staged = staging.join("bin/rpi.exe");
2340        let target = temp.path().join("target/rpi-copy.exe");
2341        fake_windows_executable(&staged, b"new-version");
2342        fake_windows_executable(&target, b"old-version");
2343        let expected = std::fs::read(&target).unwrap();
2344        let plan = build_windows_update_plan(
2345            &staging,
2346            &target,
2347            &temp
2348                .path()
2349                .join("status-00000000000000000000000000000000.json"),
2350        )
2351        .unwrap();
2352        preflight_windows_replacement(&plan).unwrap();
2353        let forced_failure = WINDOWS_UPDATE_HELPER.replace(
2354            "(Get-FileSha256 -Path $targetFull) -ne $expectedHash",
2355            "$true",
2356        );
2357        assert_ne!(forced_failure, WINDOWS_UPDATE_HELPER);
2358        std::fs::write(&plan.helper_script, forced_failure).unwrap();
2359        write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
2360
2361        let status = windows_update_helper_command(
2362            &system_powershell_path().unwrap(),
2363            &plan,
2364            i32::MAX as u32,
2365            "9.9.9",
2366        )
2367        .status()
2368        .unwrap();
2369
2370        let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
2371        assert!(!status.success());
2372        assert_eq!(
2373            std::fs::read(&target).unwrap(),
2374            expected,
2375            "status={status_text}; backup_exists={}",
2376            plan.backup_file.exists()
2377        );
2378        assert!(!staging.exists());
2379        assert!(!plan.replacement_temp.exists());
2380        assert!(!plan.backup_file.exists());
2381        assert!(!plan.rollback_displaced_file.exists());
2382        let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
2383        assert_eq!(status["state"], "failed");
2384        assert!(status["message"]
2385            .as_str()
2386            .unwrap()
2387            .contains("updated executable hash mismatch"));
2388    }
2389
2390    #[cfg(windows)]
2391    #[test]
2392    fn windows_update_plan_rejects_a_non_executable_staged_file() {
2393        let temp = real_tempdir();
2394        let staging = temp.path().join("staging");
2395        let staged = staging.join("bin/rpi.exe");
2396        let target = temp.path().join("target/rpi.exe");
2397        std::fs::create_dir_all(staged.parent().unwrap()).unwrap();
2398        std::fs::write(&staged, b"not-a-pe").unwrap();
2399        fake_windows_executable(&target, b"old-version");
2400        let expected = std::fs::read(&target).unwrap();
2401
2402        let error = build_windows_update_plan(
2403            &staging,
2404            &target,
2405            &temp
2406                .path()
2407                .join("status-00000000000000000000000000000000.json"),
2408        )
2409        .unwrap_err();
2410
2411        assert!(error.contains("not a Windows executable"));
2412        assert_eq!(std::fs::read(&target).unwrap(), expected);
2413    }
2414
2415    #[test]
2416    fn staged_rpi_version_output_is_exact_and_cannot_downgrade() {
2417        assert_eq!(
2418            parse_rpi_version_output(b"rpi 1.2.3\r\n").unwrap(),
2419            semver::Version::new(1, 2, 3)
2420        );
2421        assert_eq!(
2422            validate_staged_rpi_version_output(b"rpi 1.2.3\n", b"", "1.2.3").unwrap(),
2423            semver::Version::new(1, 2, 3)
2424        );
2425        assert!(
2426            validate_staged_rpi_version_output(b"rpi 1.2.2\n", b"", "1.2.3")
2427                .unwrap_err()
2428                .contains("older version")
2429        );
2430
2431        for output in [
2432            &b"pi 1.2.3\n"[..],
2433            &b"rpi v1.2.3\n"[..],
2434            &b"rpi 1.2.3 extra\n"[..],
2435            &b"rpi 1.2.3\nsecond line\n"[..],
2436            &b" rpi 1.2.3\n"[..],
2437        ] {
2438            assert!(
2439                parse_rpi_version_output(output).is_none(),
2440                "output={output:?}"
2441            );
2442        }
2443        assert!(
2444            validate_staged_rpi_version_output(b"rpi 1.2.3\n", b"warning", "1.2.3")
2445                .unwrap_err()
2446                .contains("stderr")
2447        );
2448    }
2449
2450    #[test]
2451    fn compares_release_versions_conservatively() {
2452        assert!(is_newer("0.1.9", "0.1.10"));
2453        assert!(is_newer("v1.2.3", "1.3.0"));
2454        assert!(is_newer("1.2.3-beta.1", "1.2.3-beta.2"));
2455        assert!(is_newer("1.2.3-beta.2", "1.2.3"));
2456        assert!(!is_newer("1.2.3", "1.2.3"));
2457        assert!(!is_newer("nightly", "1.0.0"));
2458    }
2459
2460    #[test]
2461    fn failed_item_checks_keep_only_corresponding_cached_values() {
2462        let cached = BTreeMap::from([
2463            ("fresh".to_string(), "1.0.0".to_string()),
2464            ("failed".to_string(), "2.0.0".to_string()),
2465            ("removed".to_string(), "3.0.0".to_string()),
2466        ]);
2467        let results = vec![
2468            (
2469                "fresh".to_string(),
2470                RegistryLookup::Found("1.1.0".to_string()),
2471            ),
2472            ("failed".to_string(), RegistryLookup::TransientFailure),
2473            ("uncached".to_string(), RegistryLookup::TransientFailure),
2474            ("missing".to_string(), RegistryLookup::Missing),
2475        ];
2476
2477        let (merged, checked_at_values) =
2478            results_with_individual_cache_fallback(results, Some(&cached), None, 100, 100);
2479        assert_eq!(merged.get("fresh").map(String::as_str), Some("1.1.0"));
2480        assert_eq!(merged.get("failed").map(String::as_str), Some("2.0.0"));
2481        assert!(!merged.contains_key("removed"));
2482        assert!(!merged.contains_key("uncached"));
2483        assert!(!merged.contains_key("missing"));
2484        // A live lookup is stamped with the current check; a fallback keeps the
2485        // timestamp it was cached at, so the fallback window cannot be extended
2486        // indefinitely by repeated offline starts.
2487        assert_eq!(checked_at_values.get("fresh").copied(), Some(100));
2488        assert_eq!(checked_at_values.get("failed").copied(), Some(100));
2489        assert!(!checked_at_values.contains_key("uncached"));
2490        assert!(!checked_at_values.contains_key("missing"));
2491    }
2492
2493    #[tokio::test]
2494    async fn package_update_checks_have_a_shared_concurrency_limit() {
2495        let in_flight = Arc::new(AtomicUsize::new(0));
2496        let maximum = Arc::new(AtomicUsize::new(0));
2497        let checks = (0..(UPDATE_CHECK_CONCURRENCY * 3)).map(|index| {
2498            let in_flight = Arc::clone(&in_flight);
2499            let maximum = Arc::clone(&maximum);
2500            async move {
2501                let active = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
2502                maximum.fetch_max(active, Ordering::SeqCst);
2503                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2504                in_flight.fetch_sub(1, Ordering::SeqCst);
2505                index
2506            }
2507        });
2508
2509        let mut results = collect_bounded(checks).await;
2510        results.sort_unstable();
2511
2512        assert_eq!(
2513            results,
2514            (0..(UPDATE_CHECK_CONCURRENCY * 3)).collect::<Vec<_>>()
2515        );
2516        assert_eq!(maximum.load(Ordering::SeqCst), UPDATE_CHECK_CONCURRENCY);
2517        assert_eq!(in_flight.load(Ordering::SeqCst), 0);
2518    }
2519
2520    #[tokio::test]
2521    async fn bounded_checks_do_not_head_of_line_block_new_work() {
2522        let release_first = Arc::new(tokio::sync::Notify::new());
2523        let checks = (0..(UPDATE_CHECK_CONCURRENCY + 1)).map(|index| {
2524            let release_first = Arc::clone(&release_first);
2525            async move {
2526                if index == 0 {
2527                    release_first.notified().await;
2528                } else if index == UPDATE_CHECK_CONCURRENCY {
2529                    release_first.notify_one();
2530                }
2531                index
2532            }
2533        });
2534
2535        let mut results =
2536            tokio::time::timeout(std::time::Duration::from_secs(1), collect_bounded(checks))
2537                .await
2538                .expect("a completed later check must free a concurrency slot");
2539        results.sort_unstable();
2540        assert_eq!(results, (0..=UPDATE_CHECK_CONCURRENCY).collect::<Vec<_>>());
2541    }
2542
2543    #[test]
2544    fn fresh_results_keep_individual_timestamps_when_a_peer_uses_fallback() {
2545        let now = 10 * CACHE_FALLBACK_MAX_AGE_MS;
2546        let nearly_stale = now - CACHE_FALLBACK_MAX_AGE_MS + 60_000;
2547        let cached = BTreeMap::from([
2548            ("fresh".to_string(), "1.0.0".to_string()),
2549            ("failed".to_string(), "2.0.0".to_string()),
2550        ]);
2551        let freshness = BTreeMap::from([
2552            ("fresh".to_string(), nearly_stale),
2553            ("failed".to_string(), nearly_stale),
2554        ]);
2555        let (values, timestamps) = results_with_individual_cache_fallback(
2556            vec![
2557                (
2558                    "fresh".to_string(),
2559                    RegistryLookup::Found("1.1.0".to_string()),
2560                ),
2561                ("failed".to_string(), RegistryLookup::TransientFailure),
2562            ],
2563            Some(&cached),
2564            Some(&freshness),
2565            nearly_stale,
2566            now,
2567        );
2568
2569        assert_eq!(timestamps.get("fresh"), Some(&now));
2570        assert_eq!(timestamps.get("failed"), Some(&nearly_stale));
2571        let after_old_entry_expires = now + 120_000;
2572        let (fallback, _) = results_with_individual_cache_fallback(
2573            vec![
2574                ("fresh".to_string(), RegistryLookup::TransientFailure),
2575                ("failed".to_string(), RegistryLookup::TransientFailure),
2576            ],
2577            Some(&values),
2578            Some(&timestamps),
2579            nearly_stale,
2580            after_old_entry_expires,
2581        );
2582        assert_eq!(fallback.get("fresh").map(String::as_str), Some("1.1.0"));
2583        assert!(!fallback.contains_key("failed"));
2584    }
2585
2586    #[tokio::test]
2587    async fn crates_rate_limits_and_request_timeouts_are_transient() {
2588        async fn lookup(status: &str, body: &str) -> RegistryLookup {
2589            use std::io::{BufRead, BufReader, Write};
2590
2591            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2592            let address = listener.local_addr().unwrap();
2593            let status = status.to_string();
2594            let body = body.to_string();
2595            let server = std::thread::spawn(move || {
2596                let (mut stream, _) = listener.accept().unwrap();
2597                {
2598                    let mut reader = BufReader::new(&mut stream);
2599                    let mut line = String::new();
2600                    loop {
2601                        line.clear();
2602                        if reader.read_line(&mut line).unwrap() == 0 || line == "\r\n" {
2603                            break;
2604                        }
2605                    }
2606                }
2607                write!(
2608                    stream,
2609                    "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2610                    body.len()
2611                )
2612                .unwrap();
2613            });
2614            let client = reqwest::Client::builder().build().unwrap();
2615            let result = fetch_crates_url(&client, &format!("http://{address}/crate")).await;
2616            server.join().unwrap();
2617            result
2618        }
2619
2620        assert_eq!(
2621            lookup("408 Request Timeout", "").await,
2622            RegistryLookup::TransientFailure
2623        );
2624        assert_eq!(
2625            lookup("429 Too Many Requests", "").await,
2626            RegistryLookup::TransientFailure
2627        );
2628        let malformed = lookup("200 OK", "{malformed-json").await;
2629        assert_eq!(malformed, RegistryLookup::TransientFailure);
2630        assert_eq!(
2631            super::lookup_with_cache_fallback(malformed, Some("9.9.9"), true),
2632            (Some("9.9.9".to_string()), true)
2633        );
2634        assert_eq!(lookup("404 Not Found", "").await, RegistryLookup::Missing);
2635    }
2636}