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