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