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