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