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