1use std::collections::BTreeMap;
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;
26static UPDATE_CACHE_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
27static SELF_UPDATE_STATUS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct UpdateNotice {
31 pub name: String,
32 pub current: String,
33 pub latest: String,
34 pub command: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct UpdateWarning {
39 pub message: String,
40 pub command: String,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct UpdateReport {
45 pub notices: Vec<UpdateNotice>,
46 pub warnings: Vec<UpdateWarning>,
47}
48
49impl UpdateReport {
50 pub fn is_empty(&self) -> bool {
51 self.notices.is_empty() && self.warnings.is_empty()
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56#[serde(rename_all = "camelCase")]
57struct UpdateCache {
58 checked_at: i64,
60 rpi_latest: Option<String>,
61 #[serde(default)]
62 native_packages: BTreeMap<String, String>,
63 #[serde(default)]
64 freshness: UpdateCacheFreshness,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68#[serde(rename_all = "camelCase")]
69struct UpdateCacheFreshness {
70 rpi: i64,
71 #[serde(default)]
72 native_packages: BTreeMap<String, i64>,
73}
74
75type RegistryCacheUpdate = (BTreeMap<String, String>, BTreeMap<String, i64>);
76
77#[derive(Debug, Deserialize)]
78struct CratesResponse {
79 #[serde(rename = "crate")]
80 crate_info: CrateInfo,
81}
82
83#[derive(Debug, Deserialize)]
84struct CrateInfo {
85 max_version: String,
86}
87
88#[derive(Debug, Deserialize)]
89struct SelfUpdateStatus {
90 state: String,
91 #[serde(default)]
92 message: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96enum RegistryLookup {
97 Found(String),
98 Missing,
99 TransientFailure,
100}
101
102pub async fn check_startup(cwd: &Path) -> UpdateReport {
104 let _ = cwd;
105 check_rpi_startup().await
106}
107
108pub async fn check_rpi_startup() -> UpdateReport {
113 if update_checks_disabled() {
114 return report_without_remote_checks();
115 }
116 check_startup_with_client(startup_http_client()).await
117}
118
119fn update_checks_disabled() -> bool {
120 crate::args::offline_env_enabled() || std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_some()
121}
122
123fn report_without_remote_checks() -> UpdateReport {
125 let warnings = crate::config::agent_dir()
126 .ok()
127 .map(|agent_dir| consume_self_update_statuses(&agent_dir))
128 .unwrap_or_default();
129 UpdateReport {
130 notices: Vec::new(),
131 warnings,
132 }
133}
134
135fn startup_http_client() -> Option<reqwest::Client> {
136 reqwest::Client::builder()
137 .timeout(std::time::Duration::from_millis(REQUEST_TIMEOUT_MS))
138 .user_agent(format!("rpi/{}", crate::VERSION))
139 .build()
140 .ok()
141}
142
143async fn check_startup_with_client(client: Option<reqwest::Client>) -> UpdateReport {
144 let agent_dir = match crate::config::agent_dir() {
145 Ok(dir) => dir,
146 Err(_) => return UpdateReport::default(),
147 };
148 let warnings = consume_self_update_statuses(&agent_dir);
149 let cache_path = agent_dir.join(CACHE_FILE);
150 let previous_cache = read_cache(&cache_path);
151
152 let native_checks = crate::install::installed_native_packages()
155 .into_iter()
156 .filter(|package| package.source.is_none())
157 .map(|package| {
158 let client = client.clone();
159 async move {
160 let latest = match client.as_ref() {
161 Some(client) => fetch_crates_latest(client, &package.name).await,
162 None => RegistryLookup::TransientFailure,
163 };
164 (package.name, latest)
165 }
166 });
167 let rpi_check = async {
168 Some(match client.as_ref() {
169 Some(client) => fetch_rpi_latest(client).await,
170 None => RegistryLookup::TransientFailure,
171 })
172 };
173 let (rpi_result, native_results) = tokio::join!(rpi_check, collect_bounded(native_checks));
174
175 let checked_at = now_ms();
176 let mut rpi_update = None;
177 if let Some(rpi_result) = rpi_result {
178 let cached_at = previous_cache
179 .as_ref()
180 .map(rpi_cache_checked_at)
181 .unwrap_or_default();
182 let fallback_allowed = timestamp_is_fresh(cached_at, checked_at);
183 let (latest, used_fallback) = lookup_with_cache_fallback(
184 rpi_result,
185 previous_cache
186 .as_ref()
187 .and_then(|cache| cache.rpi_latest.as_deref()),
188 fallback_allowed,
189 );
190 let value_checked_at =
191 resolved_checked_at(latest.as_deref(), used_fallback, cached_at, checked_at);
192 rpi_update = Some((latest, value_checked_at));
193 }
194
195 let legacy_checked_at = previous_cache
196 .as_ref()
197 .map(|cache| cache.checked_at)
198 .unwrap_or_default();
199 let native_packages = results_with_individual_cache_fallback(
200 native_results,
201 previous_cache.as_ref().map(|cache| &cache.native_packages),
202 previous_cache
203 .as_ref()
204 .map(|cache| &cache.freshness.native_packages),
205 legacy_checked_at,
206 checked_at,
207 );
208
209 let cache = merge_and_write_cache(&cache_path, checked_at, rpi_update, native_packages);
210 let mut report = report_from_cache(&cache);
211 report.warnings = warnings;
212 report
213}
214
215async fn collect_bounded<I, F, T>(checks: I) -> Vec<T>
216where
217 I: IntoIterator<Item = F>,
218 F: Future<Output = T>,
219{
220 stream::iter(checks)
221 .buffer_unordered(UPDATE_CHECK_CONCURRENCY)
222 .collect()
223 .await
224}
225
226fn rpi_cache_checked_at(cache: &UpdateCache) -> i64 {
227 if cache.freshness.rpi > 0 {
228 cache.freshness.rpi
229 } else {
230 cache.checked_at
231 }
232}
233
234fn entry_cache_checked_at(
235 freshness: Option<&BTreeMap<String, i64>>,
236 key: &str,
237 legacy_checked_at: i64,
238) -> i64 {
239 freshness
240 .and_then(|values| values.get(key).copied())
241 .filter(|checked_at| *checked_at > 0)
242 .unwrap_or(legacy_checked_at)
243}
244
245fn timestamp_is_fresh(checked_at: i64, now: i64) -> bool {
246 let age = now.saturating_sub(checked_at);
247 checked_at > 0 && age >= 0 && age <= CACHE_FALLBACK_MAX_AGE_MS
248}
249
250fn resolved_checked_at(
251 value: Option<&str>,
252 used_fallback: bool,
253 cached_at: i64,
254 checked_at: i64,
255) -> i64 {
256 if value.is_none() {
257 0
258 } else if used_fallback {
259 cached_at
260 } else {
261 checked_at
262 }
263}
264
265fn results_with_individual_cache_fallback(
266 results: Vec<(String, RegistryLookup)>,
267 cached: Option<&BTreeMap<String, String>>,
268 freshness: Option<&BTreeMap<String, i64>>,
269 legacy_checked_at: i64,
270 checked_at: i64,
271) -> RegistryCacheUpdate {
272 let mut values = BTreeMap::new();
273 let mut checked_at_values = BTreeMap::new();
274 for (name, result) in results {
275 let cached_value = cached.and_then(|cached| cached.get(&name).map(String::as_str));
276 let cached_at = entry_cache_checked_at(freshness, &name, legacy_checked_at);
277 let (latest, used_fallback) = lookup_with_cache_fallback(
278 result,
279 cached_value,
280 timestamp_is_fresh(cached_at, checked_at),
281 );
282 if let Some(latest) = latest {
283 checked_at_values.insert(
284 name.clone(),
285 resolved_checked_at(Some(&latest), used_fallback, cached_at, checked_at),
286 );
287 values.insert(name, latest);
288 }
289 }
290 (values, checked_at_values)
291}
292
293fn merge_and_write_cache(
294 path: &Path,
295 checked_at: i64,
296 rpi_update: Option<(Option<String>, i64)>,
297 native_packages: RegistryCacheUpdate,
298) -> UpdateCache {
299 let _guard = UPDATE_CACHE_WRITE_LOCK
300 .lock()
301 .unwrap_or_else(std::sync::PoisonError::into_inner);
302 let mut cache = read_cache(path).unwrap_or_default();
303 if cache.checked_at <= 0 {
304 cache.checked_at = checked_at;
305 }
306 if let Some((latest, latest_checked_at)) = rpi_update {
307 cache.rpi_latest = latest;
308 cache.freshness.rpi = latest_checked_at;
309 }
310 cache.native_packages = native_packages.0;
311 cache.freshness.native_packages = native_packages.1;
312 let _ = write_cache(path, &cache);
313 cache
314}
315
316fn lookup_with_cache_fallback(
317 result: RegistryLookup,
318 cached: Option<&str>,
319 fallback_allowed: bool,
320) -> (Option<String>, bool) {
321 match result {
322 RegistryLookup::Found(version) => (Some(version), false),
323 RegistryLookup::Missing => (None, false),
324 RegistryLookup::TransientFailure if fallback_allowed => {
325 (cached.map(str::to_owned), cached.is_some())
326 }
327 RegistryLookup::TransientFailure => (None, false),
328 }
329}
330fn report_from_cache(cache: &UpdateCache) -> UpdateReport {
331 let mut notices = Vec::new();
332 if let Some(latest) = cache.rpi_latest.as_deref() {
333 if is_newer(crate::VERSION, latest) {
334 notices.push(UpdateNotice {
335 name: "rpi".into(),
336 current: crate::VERSION.into(),
337 latest: latest.into(),
338 command: "rpi update".into(),
339 });
340 }
341 }
342 for package in crate::install::installed_native_packages() {
343 if package.source.is_some() {
344 continue;
345 }
346 let Some(latest) = cache.native_packages.get(&package.name) else {
347 continue;
348 };
349 if is_newer(&package.version, latest) {
350 notices.push(UpdateNotice {
351 name: package.name.clone(),
352 current: package.version,
353 latest: latest.clone(),
354 command: format!("rpi install {} --force", package.name),
355 });
356 }
357 }
358 UpdateReport {
359 notices,
360 warnings: Vec::new(),
361 }
362}
363
364fn normalize_windows_path(path: PathBuf) -> PathBuf {
365 if cfg!(windows) {
366 let text = path.to_string_lossy();
367 if let Some(unc) = text.strip_prefix(r"\\?\UNC\") {
368 return PathBuf::from(format!(r"\\{unc}"));
369 }
370 if let Some(local) = text.strip_prefix(r"\\?\") {
371 return PathBuf::from(local);
372 }
373 }
374 path
375}
376
377fn windows_paths_equal(left: &Path, right: &Path) -> bool {
378 let left = normalize_windows_path(left.to_path_buf());
379 let right = normalize_windows_path(right.to_path_buf());
380 if cfg!(windows) {
381 left.to_string_lossy()
382 .replace('/', "\\")
383 .eq_ignore_ascii_case(&right.to_string_lossy().replace('/', "\\"))
384 } else {
385 left == right
386 }
387}
388
389async fn fetch_rpi_latest(client: &reqwest::Client) -> RegistryLookup {
390 fetch_crates_url(client, CRATES_IO_API).await
391}
392
393async fn fetch_crates_latest(client: &reqwest::Client, name: &str) -> RegistryLookup {
394 fetch_crates_url(client, &format!("https://crates.io/api/v1/crates/{name}")).await
395}
396
397async fn fetch_crates_url(client: &reqwest::Client, url: &str) -> RegistryLookup {
398 let response = match client.get(url).send().await {
399 Ok(response) => response,
400 Err(_) => return RegistryLookup::TransientFailure,
401 };
402 if matches!(
403 response.status(),
404 reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::GONE
405 ) {
406 return RegistryLookup::Missing;
407 }
408 if !response.status().is_success() {
409 return RegistryLookup::TransientFailure;
410 }
411 match response.json::<CratesResponse>().await {
412 Ok(response) => RegistryLookup::Found(response.crate_info.max_version),
413 Err(_) => RegistryLookup::TransientFailure,
414 }
415}
416
417pub fn is_newer(current: &str, latest: &str) -> bool {
419 let parse = |value: &str| semver::Version::parse(value.trim().trim_start_matches('v')).ok();
420 match (parse(current), parse(latest)) {
421 (Some(current), Some(latest)) => latest > current,
422 _ => false,
423 }
424}
425
426fn read_cache(path: &Path) -> Option<UpdateCache> {
427 serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
428}
429
430fn write_cache(path: &Path, cache: &UpdateCache) -> Result<(), String> {
431 if let Some(parent) = path.parent() {
432 std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
433 }
434 let data = serde_json::to_vec_pretty(cache).map_err(|error| error.to_string())?;
435 crate::config::atomic_write(path, &data).map_err(|error| error.to_string())
436}
437
438fn now_ms() -> i64 {
439 SystemTime::now()
440 .duration_since(UNIX_EPOCH)
441 .map(|duration| duration.as_millis() as i64)
442 .unwrap_or(0)
443}
444
445pub fn print_startup_notices(report: &UpdateReport) {
446 for warning in &report.warnings {
447 eprintln!("warning: {} Run `{}`.", warning.message, warning.command);
448 }
449 for notice in &report.notices {
450 eprintln!(
451 "Update available: {} {} -> {}. Run `{}`.",
452 notice.name, notice.current, notice.latest, notice.command
453 );
454 }
455}
456
457pub fn run_self_update(args: &[String]) -> i32 {
461 let offline = crate::args::normalize_offline_mode(args);
462 let args = crate::args::without_offline_flag(args);
463 if args
464 .iter()
465 .any(|arg| matches!(arg.as_str(), "--help" | "-h"))
466 {
467 println!("Usage: rpi update [--offline]\n\nUpdate the rpi CLI from crates.io.");
468 return 0;
469 }
470 if !args.is_empty() {
471 eprintln!("error: `rpi update` does not accept arguments");
472 return 2;
473 }
474 if offline {
475 println!("rpi update skipped: offline mode is enabled");
476 return 0;
477 }
478
479 #[cfg(windows)]
480 {
481 run_windows_self_update()
482 }
483 #[cfg(not(windows))]
484 {
485 run_direct_self_update()
486 }
487}
488
489fn cargo_install_command(
490 staging_root: Option<&Path>,
491 isolated_cwd: &Path,
492) -> Result<std::process::Command, String> {
493 let isolated_cwd = validated_self_update_command_dir(isolated_cwd)?;
494 let mut command = std::process::Command::new("cargo");
495 command.arg("install").current_dir(isolated_cwd);
496 for variable in [
497 "RUSTC_WRAPPER",
498 "RUSTC_WORKSPACE_WRAPPER",
499 "CARGO_BUILD_RUSTC_WRAPPER",
500 "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
501 ] {
502 command.env_remove(variable);
503 }
504 if let Some(staging_root) = staging_root {
505 command.arg("--root").arg(staging_root);
506 }
507 command.args(["rpi-cli", "--locked", "--force", "--quiet"]);
512 Ok(command)
513}
514
515fn create_self_update_command_dir() -> Result<tempfile::TempDir, String> {
521 let configured_root =
522 dirs::home_dir().ok_or_else(|| "could not resolve the user home directory".to_string())?;
523 if !configured_root.is_absolute() {
524 return Err("user home must be absolute for self-update".to_string());
525 }
526 let canonical_root = std::fs::canonicalize(&configured_root)
527 .map(normalize_windows_path)
528 .map_err(|error| format!("could not canonicalize user home: {error}"))?;
529 let metadata = std::fs::symlink_metadata(&canonical_root)
530 .map_err(|error| format!("could not inspect user home: {error}"))?;
531 if !metadata.is_dir() || metadata.file_type().is_symlink() {
532 return Err("user home does not resolve to a real directory".to_string());
533 }
534 let current = std::env::current_dir()
535 .and_then(std::fs::canonicalize)
536 .map(normalize_windows_path)
537 .map_err(|error| format!("could not validate the caller directory: {error}"))?;
538 let project_root = current
539 .ancestors()
540 .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
541 .map(Path::to_path_buf);
542 validate_configured_cargo_home(project_root.as_deref())?;
543 if project_root.as_deref().is_some_and(|root| {
544 !windows_paths_equal(&canonical_root, root) && path_is_within(&canonical_root, root)
545 }) {
546 return Err(
547 "refusing to create a self-update command directory inside the caller project"
548 .to_string(),
549 );
550 }
551 let directory = tempfile::Builder::new()
552 .prefix("rpi-self-update-command-")
553 .tempdir_in(&canonical_root)
554 .map_err(|error| format!("could not allocate Cargo command directory: {error}"))?;
555 validated_self_update_command_dir(directory.path())?;
556 #[cfg(unix)]
557 {
558 use std::os::unix::fs::{MetadataExt, PermissionsExt};
559
560 let directory_metadata = std::fs::symlink_metadata(directory.path())
561 .map_err(|error| format!("could not inspect Cargo command directory: {error}"))?;
562 if metadata.uid() != directory_metadata.uid() || metadata.permissions().mode() & 0o022 != 0
563 {
564 return Err("user home is not private to the current account".to_string());
565 }
566 }
567 Ok(directory)
568}
569
570fn path_is_within(candidate: &Path, ancestor: &Path) -> bool {
571 candidate
572 .ancestors()
573 .any(|component| windows_paths_equal(component, ancestor))
574}
575
576fn validate_configured_cargo_home(project_root: Option<&Path>) -> Result<(), String> {
577 let Some(configured) = std::env::var_os("CARGO_HOME") else {
578 return Ok(());
579 };
580 let configured = PathBuf::from(configured);
581 if !configured.is_absolute() {
582 return Err("CARGO_HOME must be absolute for self-update".to_string());
583 }
584 let project_local = match project_root {
585 Some(root) => cargo_home_is_project_local(&configured, root)?,
586 None => {
587 canonicalize_allow_missing(&configured, "CARGO_HOME")?;
588 false
589 }
590 };
591 if project_local {
592 return Err("refusing project-local CARGO_HOME during self-update".to_string());
593 }
594 Ok(())
595}
596
597fn cargo_home_is_project_local(configured: &Path, project_root: &Path) -> Result<bool, String> {
598 if !configured.is_absolute() {
599 return Err("CARGO_HOME must be absolute for self-update".to_string());
600 }
601 let configured = canonicalize_allow_missing(configured, "CARGO_HOME")?;
602 Ok(path_is_within(&configured, project_root))
603}
604
605fn canonicalize_allow_missing(path: &Path, label: &str) -> Result<PathBuf, String> {
606 if path.components().any(|component| {
607 matches!(
608 component,
609 std::path::Component::CurDir | std::path::Component::ParentDir
610 )
611 }) {
612 return Err(format!("{label} contains relative path components"));
613 }
614 let mut existing = path;
615 let mut missing = Vec::new();
616 loop {
617 match std::fs::symlink_metadata(existing) {
618 Ok(_) => break,
619 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
620 let name = existing
621 .file_name()
622 .ok_or_else(|| format!("could not resolve {label}"))?;
623 missing.push(name.to_os_string());
624 existing = existing
625 .parent()
626 .ok_or_else(|| format!("could not resolve {label}"))?;
627 }
628 Err(error) => return Err(format!("could not inspect {label}: {error}")),
629 }
630 }
631 let mut canonical = std::fs::canonicalize(existing)
632 .map(normalize_windows_path)
633 .map_err(|error| format!("could not canonicalize {label}: {error}"))?;
634 for component in missing.into_iter().rev() {
635 canonical.push(component);
636 }
637 Ok(canonical)
638}
639
640fn canonical_display(path: &Path) -> String {
641 std::fs::canonicalize(path)
642 .map(normalize_windows_path)
643 .map(|path| path.display().to_string())
644 .unwrap_or_else(|_| "<unresolvable>".to_string())
645}
646
647fn validated_self_update_command_dir(path: &Path) -> Result<PathBuf, String> {
648 if !path.is_absolute() {
649 return Err("self-update command directory must be absolute".to_string());
650 }
651 let metadata = std::fs::symlink_metadata(path)
652 .map_err(|error| format!("could not inspect self-update command directory: {error}"))?;
653 if !metadata.is_dir() || metadata.file_type().is_symlink() {
654 return Err("self-update command directory is not a real directory".to_string());
655 }
656 let canonical = std::fs::canonicalize(path)
657 .map(normalize_windows_path)
658 .map_err(|error| {
659 format!("could not canonicalize self-update command directory: {error}")
660 })?;
661 Ok(canonical)
662}
663
664#[cfg(not(windows))]
665#[derive(Debug, Clone)]
666struct DirectUpdatePlan {
667 staging_dir: PathBuf,
668 staged_exe: PathBuf,
669 target_exe: PathBuf,
670 replacement_temp: PathBuf,
671 backup_file: PathBuf,
672}
673
674#[cfg(not(windows))]
675fn run_direct_self_update() -> i32 {
676 let staging = match create_self_update_staging_directory() {
677 Ok(directory) => directory,
678 Err(error) => {
679 eprintln!("error: could not create self-update staging directory: {error}");
680 return 1;
681 }
682 };
683 let command_dir = match create_self_update_command_dir() {
684 Ok(directory) => directory,
685 Err(error) => {
686 eprintln!("error: could not create an isolated self-update directory: {error}");
687 return 1;
688 }
689 };
690 let mut command = match cargo_install_command(Some(staging.path()), command_dir.path()) {
691 Ok(command) => command,
692 Err(error) => {
693 eprintln!("error: invalid isolated self-update directory: {error}");
694 return 1;
695 }
696 };
697 let status = command.status();
698 match status {
699 Ok(status) if status.success() => {}
700 Ok(status) => {
701 eprintln!("error: cargo install exited with {status}");
702 return 1;
703 }
704 Err(error) => {
705 eprintln!("error: could not run cargo (install Rust/Cargo first): {error}");
706 return 1;
707 }
708 }
709
710 let current_exe = match std::env::current_exe() {
711 Ok(path) => path,
712 Err(error) => {
713 eprintln!("error: could not resolve the current rpi executable: {error}");
714 return 1;
715 }
716 };
717 let plan = match build_direct_update_plan(staging.path(), ¤t_exe) {
718 Ok(plan) => plan,
719 Err(error) => {
720 eprintln!("error: {error}");
721 return 1;
722 }
723 };
724 match activate_direct_update(&plan) {
725 Ok(version) => {
726 println!("rpi updated successfully to {version}");
727 0
728 }
729 Err(error) => {
730 eprintln!("error: {error}");
731 1
732 }
733 }
734}
735
736fn create_self_update_staging_directory() -> Result<tempfile::TempDir, String> {
737 let agent = crate::config::agent_dir().map_err(|error| error.to_string())?;
738 if !agent.is_absolute() {
739 return Err("agent directory must be absolute".to_string());
740 }
741 std::fs::create_dir_all(&agent)
742 .map_err(|error| format!("could not create {}: {error}", agent.display()))?;
743 let agent = validated_real_directory(&agent, "agent directory")?;
744 let update_root = agent.join("self-update");
745 std::fs::create_dir_all(&update_root)
746 .map_err(|error| format!("could not create {}: {error}", update_root.display()))?;
747 let update_root = validated_real_directory(&update_root, "self-update root")?;
748 let staging = tempfile::Builder::new()
749 .prefix("pending-")
750 .tempdir_in(&update_root)
751 .map_err(|error| format!("could not allocate staging directory: {error}"))?;
752 validated_real_directory(staging.path(), "self-update staging directory")?;
753 Ok(staging)
754}
755
756#[cfg(not(windows))]
757fn build_direct_update_plan(
758 staging_dir: &Path,
759 target_exe: &Path,
760) -> Result<DirectUpdatePlan, String> {
761 let staging_dir = validated_real_directory(staging_dir, "self-update staging directory")?;
762 let staged_exe = validated_regular_file(
763 &staging_dir.join("bin").join(crate::APP_NAME),
764 "staged rpi executable",
765 )?;
766 let target_exe = validated_regular_file(target_exe, "current rpi executable")?;
767 let target_dir = validated_real_directory(
768 target_exe
769 .parent()
770 .ok_or_else(|| "current executable has no parent directory".to_string())?,
771 "current executable directory",
772 )?;
773 if target_exe.parent() != Some(target_dir.as_path()) {
774 return Err("current executable is not a direct child of its validated directory".into());
775 }
776 let token = uuid::Uuid::new_v4().simple().to_string();
777 let replacement_temp = target_dir.join(format!(".rpi-update-{token}.new"));
778 let backup_file = target_dir.join(format!(".rpi-update-{token}.old"));
779 if replacement_temp.exists() || backup_file.exists() {
780 return Err("self-update replacement path collision".to_string());
781 }
782 Ok(DirectUpdatePlan {
783 staging_dir,
784 staged_exe,
785 target_exe,
786 replacement_temp,
787 backup_file,
788 })
789}
790
791#[cfg(not(windows))]
792fn activate_direct_update(plan: &DirectUpdatePlan) -> Result<semver::Version, String> {
793 let staged_version = validate_rpi_executable_version(
794 &plan.staged_exe,
795 &plan.staging_dir,
796 crate::VERSION,
797 "staged rpi",
798 )?;
799 preflight_direct_replacement(plan)?;
800
801 let result = (|| {
802 std::fs::copy(&plan.staged_exe, &plan.replacement_temp).map_err(|error| {
803 format!("could not copy staged rpi beside the current executable: {error}")
804 })?;
805 validated_regular_file(&plan.replacement_temp, "replacement rpi executable")?;
806 let copied_version = validate_rpi_executable_version(
807 &plan.replacement_temp,
808 plan.target_exe
809 .parent()
810 .ok_or_else(|| "current executable has no parent directory".to_string())?,
811 crate::VERSION,
812 "replacement rpi",
813 )?;
814 if copied_version != staged_version
815 || !files_are_identical(&plan.staged_exe, &plan.replacement_temp)?
816 {
817 return Err(
818 "replacement rpi does not match the validated staged executable".to_string(),
819 );
820 }
821
822 std::fs::hard_link(&plan.target_exe, &plan.backup_file)
823 .map_err(|error| format!("could not preserve the current rpi executable: {error}"))?;
824 let installed_version = validate_rpi_executable_version(
825 &plan.backup_file,
826 plan.target_exe
827 .parent()
828 .ok_or_else(|| "current executable has no parent directory".to_string())?,
829 crate::VERSION,
830 "current rpi",
831 )?;
832 if staged_version < installed_version {
833 return Err(format!(
834 "refusing to replace rpi {installed_version} with older version {staged_version}"
835 ));
836 }
837 if !files_are_identical(&plan.target_exe, &plan.backup_file)? {
838 return Err("current rpi executable changed while preparing the update".to_string());
839 }
840
841 std::fs::rename(&plan.replacement_temp, &plan.target_exe).map_err(|error| {
842 format!("could not atomically replace the current rpi executable: {error}")
843 })?;
844 Ok(())
845 })();
846
847 if let Err(error) = result {
848 let _ = remove_file_if_exists(&plan.replacement_temp);
849 let _ = remove_file_if_exists(&plan.backup_file);
850 return Err(error);
851 }
852
853 let validation = (|| {
854 if !files_are_identical(&plan.staged_exe, &plan.target_exe)? {
855 return Err("updated rpi executable does not match the staged executable".to_string());
856 }
857 let updated_version = validate_rpi_executable_version(
858 &plan.target_exe,
859 plan.target_exe
860 .parent()
861 .ok_or_else(|| "current executable has no parent directory".to_string())?,
862 crate::VERSION,
863 "updated rpi",
864 )?;
865 if updated_version != staged_version {
866 return Err(format!(
867 "updated rpi reported version {updated_version}, expected {staged_version}"
868 ));
869 }
870 Ok(())
871 })();
872
873 if let Err(error) = validation {
874 return Err(rollback_direct_update(plan, &error));
875 }
876 let _ = remove_file_if_exists(&plan.backup_file);
877 Ok(staged_version)
878}
879
880#[cfg(not(windows))]
881fn preflight_direct_replacement(plan: &DirectUpdatePlan) -> Result<(), String> {
882 for path in [&plan.replacement_temp, &plan.backup_file] {
883 if path.exists() {
884 return Err(format!(
885 "self-update path already exists: {}",
886 path.display()
887 ));
888 }
889 let file = std::fs::OpenOptions::new()
890 .write(true)
891 .create_new(true)
892 .open(path)
893 .map_err(|error| format!("cannot write beside the current executable: {error}"))?;
894 drop(file);
895 std::fs::remove_file(path)
896 .map_err(|error| format!("could not remove self-update probe: {error}"))?;
897 }
898 Ok(())
899}
900
901#[cfg(not(windows))]
902fn rollback_direct_update(plan: &DirectUpdatePlan, failure: &str) -> String {
903 let _ = remove_file_if_exists(&plan.replacement_temp);
904 match std::fs::rename(&plan.backup_file, &plan.target_exe) {
905 Ok(()) => format!("{failure}; restored the previous rpi executable"),
906 Err(rollback) => format!(
907 "{failure}; automatic rollback failed ({rollback}); the previous executable remains at {}",
908 plan.backup_file.display()
909 ),
910 }
911}
912
913#[cfg(not(windows))]
914fn remove_file_if_exists(path: &Path) -> Result<(), String> {
915 match std::fs::remove_file(path) {
916 Ok(()) => Ok(()),
917 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
918 Err(error) => Err(error.to_string()),
919 }
920}
921
922#[cfg(not(windows))]
923fn files_are_identical(left: &Path, right: &Path) -> Result<bool, String> {
924 use std::io::{BufReader, Read};
925
926 let left_file = std::fs::File::open(left)
927 .map_err(|error| format!("could not open {}: {error}", left.display()))?;
928 let right_file = std::fs::File::open(right)
929 .map_err(|error| format!("could not open {}: {error}", right.display()))?;
930 if left_file
931 .metadata()
932 .map_err(|error| error.to_string())?
933 .len()
934 != right_file
935 .metadata()
936 .map_err(|error| error.to_string())?
937 .len()
938 {
939 return Ok(false);
940 }
941 let mut left = BufReader::new(left_file);
942 let mut right = BufReader::new(right_file);
943 let mut left_buffer = [0u8; 64 * 1024];
944 let mut right_buffer = [0u8; 64 * 1024];
945 loop {
946 let left_read = left
947 .read(&mut left_buffer)
948 .map_err(|error| error.to_string())?;
949 let right_read = right
950 .read(&mut right_buffer)
951 .map_err(|error| error.to_string())?;
952 if left_read != right_read || left_buffer[..left_read] != right_buffer[..right_read] {
953 return Ok(false);
954 }
955 if left_read == 0 {
956 return Ok(true);
957 }
958 }
959}
960
961#[cfg(windows)]
962#[derive(Debug, Clone)]
963struct WindowsUpdatePlan {
964 staging_dir: PathBuf,
965 staged_exe: PathBuf,
966 target_exe: PathBuf,
967 helper_script: PathBuf,
968 status_file: PathBuf,
969 replacement_temp: PathBuf,
970 backup_file: PathBuf,
971 rollback_displaced_file: PathBuf,
972}
973
974#[cfg(windows)]
975struct WindowsUpdateStaging {
976 directory: tempfile::TempDir,
977 status_file: PathBuf,
978}
979
980#[cfg(windows)]
981const WINDOWS_UPDATE_HELPER: &str = r#"param(
982 [Parameter(Mandatory = $true)][long] $ParentPid,
983 [Parameter(Mandatory = $true)][string] $StagingPath,
984 [Parameter(Mandatory = $true)][string] $StagedPath,
985 [Parameter(Mandatory = $true)][string] $StagedVersion,
986 [Parameter(Mandatory = $true)][string] $TargetPath,
987 [Parameter(Mandatory = $true)][string] $TempPath,
988 [Parameter(Mandatory = $true)][string] $BackupPath,
989 [Parameter(Mandatory = $true)][string] $RollbackDisplacedPath,
990 [Parameter(Mandatory = $true)][string] $StatusPath
991)
992
993Set-StrictMode -Version Latest
994$ErrorActionPreference = 'Stop'
995
996function Write-UpdateStatus {
997 param([string] $State, [string] $Message)
998 $record = [ordered]@{
999 state = $State
1000 message = $Message
1001 parentPid = $ParentPid
1002 staging = $StagingPath
1003 target = $TargetPath
1004 staged = $StagedPath
1005 stagedVersion = $StagedVersion
1006 backup = $BackupPath
1007 rollbackDisplaced = $RollbackDisplacedPath
1008 updatedAt = [DateTime]::UtcNow.ToString('o')
1009 }
1010 $json = $record | ConvertTo-Json -Compress
1011 $statusTemp = $StatusPath + '.tmp'
1012 [IO.File]::WriteAllText($statusTemp, $json, [Text.UTF8Encoding]::new($false))
1013 Move-Item -LiteralPath $statusTemp -Destination $StatusPath -Force
1014}
1015
1016function Assert-RegularFile {
1017 param([string] $Path, [string] $Label)
1018 $item = Get-Item -LiteralPath $Path -Force
1019 if ($item.PSIsContainer -or $item.Length -le 0 -or
1020 (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
1021 throw "$Label is not a non-empty regular file"
1022 }
1023}
1024
1025function Assert-WindowsExecutable {
1026 param([string] $Path, [string] $Label)
1027 Assert-RegularFile -Path $Path -Label $Label
1028 $stream = [IO.File]::OpenRead($Path)
1029 try {
1030 if ($stream.ReadByte() -ne 77 -or $stream.ReadByte() -ne 90) {
1031 throw "$Label is not a Windows executable"
1032 }
1033 } finally {
1034 $stream.Dispose()
1035 }
1036}
1037
1038function Assert-RealDirectory {
1039 param([string] $Path, [string] $Label)
1040 $item = Get-Item -LiteralPath $Path -Force
1041 if (-not $item.PSIsContainer -or
1042 (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
1043 throw "$Label is not a real directory"
1044 }
1045}
1046
1047function Get-FileSha256 {
1048 param([string] $Path)
1049 $stream = [IO.File]::OpenRead($Path)
1050 try {
1051 $sha256 = [Security.Cryptography.SHA256]::Create()
1052 try {
1053 return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '')
1054 } finally {
1055 $sha256.Dispose()
1056 }
1057 } finally {
1058 $stream.Dispose()
1059 }
1060}
1061
1062function Remove-StagingPayload {
1063 try {
1064 $root = [IO.Path]::GetFullPath($StagingPath)
1065 Assert-RealDirectory -Path $root -Label 'self-update staging directory'
1066 $bin = [IO.Path]::Combine($root, 'bin')
1067 $helper = [IO.Path]::Combine($root, 'apply-update.ps1')
1068 foreach ($file in @(
1069 $StagedPath,
1070 [IO.Path]::Combine($root, '.crates.toml'),
1071 [IO.Path]::Combine($root, '.crates2.json'),
1072 $helper
1073 )) {
1074 if ([IO.File]::Exists($file)) {
1075 [IO.File]::Delete($file)
1076 }
1077 }
1078 if ([IO.Directory]::Exists($bin) -and
1079 [IO.Directory]::GetFileSystemEntries($bin).Length -eq 0) {
1080 [IO.Directory]::Delete($bin, $false)
1081 }
1082 if ([IO.Directory]::Exists($root) -and
1083 [IO.Directory]::GetFileSystemEntries($root).Length -eq 0) {
1084 [IO.Directory]::Delete($root, $false)
1085 }
1086 } catch {
1087 # Cleanup is best-effort; update status and recovery files stay intact.
1088 }
1089}
1090
1091$replaced = $false
1092try {
1093 $stagingFull = [IO.Path]::GetFullPath($StagingPath)
1094 $stagedFull = [IO.Path]::GetFullPath($StagedPath)
1095 $targetFull = [IO.Path]::GetFullPath($TargetPath)
1096 $tempFull = [IO.Path]::GetFullPath($TempPath)
1097 $backupFull = [IO.Path]::GetFullPath($BackupPath)
1098 $rollbackDisplacedFull = [IO.Path]::GetFullPath($RollbackDisplacedPath)
1099 $helperFull = [IO.Path]::Combine($stagingFull, 'apply-update.ps1')
1100 $statusFull = [IO.Path]::GetFullPath($StatusPath)
1101 $targetDirectory = [IO.Path]::GetDirectoryName($targetFull)
1102 if (-not [StringComparer]::OrdinalIgnoreCase.Equals(
1103 [IO.Path]::GetDirectoryName([IO.Path]::GetDirectoryName($stagedFull)), $stagingFull) -or
1104 -not [StringComparer]::OrdinalIgnoreCase.Equals(
1105 [IO.Path]::GetDirectoryName($helperFull), $stagingFull) -or
1106 -not [StringComparer]::OrdinalIgnoreCase.Equals(
1107 [IO.Path]::GetFullPath($PSScriptRoot), $stagingFull) -or
1108 -not [StringComparer]::OrdinalIgnoreCase.Equals(
1109 [IO.Path]::GetDirectoryName($statusFull), [IO.Path]::GetDirectoryName($stagingFull)) -or
1110 [string]::IsNullOrWhiteSpace($targetDirectory) -or
1111 -not [StringComparer]::OrdinalIgnoreCase.Equals(
1112 [IO.Path]::GetDirectoryName($tempFull), $targetDirectory) -or
1113 -not [StringComparer]::OrdinalIgnoreCase.Equals(
1114 [IO.Path]::GetDirectoryName($backupFull), $targetDirectory) -or
1115 -not [StringComparer]::OrdinalIgnoreCase.Equals(
1116 [IO.Path]::GetDirectoryName($rollbackDisplacedFull), $targetDirectory)) {
1117 throw 'self-update paths are outside their validated staging or target directories'
1118 }
1119
1120 Assert-RealDirectory -Path $stagingFull -Label 'self-update staging directory'
1121 Assert-RealDirectory -Path $targetDirectory -Label 'target directory'
1122 Assert-WindowsExecutable -Path $stagedFull -Label 'staged executable'
1123 Assert-WindowsExecutable -Path $targetFull -Label 'current executable'
1124 $expectedHash = Get-FileSha256 -Path $stagedFull
1125 $originalTargetHash = Get-FileSha256 -Path $targetFull
1126 if ([IO.File]::Exists($tempFull) -or [IO.File]::Exists($backupFull) -or
1127 [IO.File]::Exists($rollbackDisplacedFull)) {
1128 throw 'replacement, backup, or rollback path already exists'
1129 }
1130
1131 Write-UpdateStatus -State 'waiting' -Message 'waiting for the current rpi process to exit'
1132 if ($ParentPid -lt 1 -or $ParentPid -gt [int]::MaxValue) {
1133 throw 'parent process id is outside the supported Windows range'
1134 }
1135 $parentProcess = $null
1136 try {
1137 $candidate = [Diagnostics.Process]::GetProcessById([int]$ParentPid)
1138 try {
1139 $candidatePath = [IO.Path]::GetFullPath($candidate.MainModule.FileName)
1140 if ([StringComparer]::OrdinalIgnoreCase.Equals($candidatePath, $targetFull)) {
1141 $parentProcess = $candidate
1142 } else {
1143 $candidate.Dispose()
1144 }
1145 } catch {
1146 if ($candidate.HasExited) {
1147 $candidate.Dispose()
1148 } else {
1149 throw
1150 }
1151 }
1152 } catch [ArgumentException] {
1153 # The invoking process exited before the helper acquired its handle.
1154 }
1155 if ($null -ne $parentProcess) {
1156 try {
1157 if (-not $parentProcess.WaitForExit(600000)) {
1158 throw 'timed out waiting for the current rpi process to exit'
1159 }
1160 } finally {
1161 $parentProcess.Dispose()
1162 }
1163 }
1164
1165 Assert-RealDirectory -Path $targetDirectory -Label 'target directory'
1166 Assert-WindowsExecutable -Path $stagedFull -Label 'staged executable'
1167 Assert-WindowsExecutable -Path $targetFull -Label 'current executable'
1168 if ((Get-FileSha256 -Path $stagedFull) -ne $expectedHash) {
1169 throw 'staged executable changed while waiting for rpi to exit'
1170 }
1171 if ((Get-FileSha256 -Path $targetFull) -ne $originalTargetHash) {
1172 throw 'current executable changed while waiting for rpi to exit'
1173 }
1174 if ([IO.File]::Exists($tempFull) -or [IO.File]::Exists($backupFull) -or
1175 [IO.File]::Exists($rollbackDisplacedFull)) {
1176 throw 'replacement, backup, or rollback path appeared while waiting for rpi to exit'
1177 }
1178
1179 [IO.File]::Copy($stagedFull, $tempFull, $false)
1180 Assert-WindowsExecutable -Path $tempFull -Label 'replacement executable'
1181 if ((Get-FileSha256 -Path $tempFull) -ne $expectedHash) {
1182 throw 'replacement executable hash mismatch'
1183 }
1184
1185 [IO.File]::Replace($tempFull, $targetFull, $backupFull, $true)
1186 $replaced = $true
1187 Assert-WindowsExecutable -Path $targetFull -Label 'updated executable'
1188 if ((Get-FileSha256 -Path $targetFull) -ne $expectedHash) {
1189 throw 'updated executable hash mismatch'
1190 }
1191} catch {
1192 $failureMessage = $_.Exception.Message
1193 $recoveryMessage = ''
1194 if ($replaced -and [IO.File]::Exists($BackupPath)) {
1195 try {
1196 [IO.File]::Copy($BackupPath, $TempPath, $false)
1197 Assert-WindowsExecutable -Path $TempPath -Label 'rollback executable'
1198 if ((Get-FileSha256 -Path $TempPath) -ne $originalTargetHash) {
1199 throw 'rollback executable hash mismatch'
1200 }
1201 [IO.File]::Replace($TempPath, $TargetPath, $RollbackDisplacedPath, $true)
1202 Assert-WindowsExecutable -Path $TargetPath -Label 'restored executable'
1203 if ((Get-FileSha256 -Path $TargetPath) -ne $originalTargetHash) {
1204 throw 'restored executable hash mismatch'
1205 }
1206 $replaced = $false
1207 try { [IO.File]::Delete($BackupPath) } catch {}
1208 try { [IO.File]::Delete($RollbackDisplacedPath) } catch {}
1209 } catch {
1210 $rollbackFailure = $_.Exception.Message
1211 if ([IO.File]::Exists($BackupPath)) {
1212 $recoveryMessage = "; automatic rollback failed ($rollbackFailure); the original executable remains at $BackupPath"
1213 } else {
1214 $recoveryMessage = "; automatic rollback failed ($rollbackFailure) and no verified recovery backup remains"
1215 }
1216 }
1217 }
1218 if ([IO.File]::Exists($TempPath)) {
1219 try { Remove-Item -LiteralPath $TempPath -Force } catch {}
1220 }
1221 try { Write-UpdateStatus -State 'failed' -Message ($failureMessage + $recoveryMessage) } catch {}
1222 Remove-StagingPayload
1223 exit 1
1224}
1225
1226try { Write-UpdateStatus -State 'succeeded' -Message 'rpi executable replaced successfully' } catch {}
1227if ([IO.File]::Exists($BackupPath)) {
1228 try { Remove-Item -LiteralPath $BackupPath -Force } catch {}
1229}
1230Remove-StagingPayload
1231exit 0
1232"#;
1233
1234#[cfg(windows)]
1235fn run_windows_self_update() -> i32 {
1236 let staging = match create_windows_update_staging() {
1237 Ok(staging) => staging,
1238 Err(error) => {
1239 eprintln!("error: could not create self-update staging directory: {error}");
1240 return 1;
1241 }
1242 };
1243 let staging_dir = staging.directory.path().to_path_buf();
1244 let status_file = staging.status_file.clone();
1245 let _ = write_windows_update_status(
1246 &status_file,
1247 "preparing",
1248 "installing the update into staging",
1249 );
1250
1251 let current_exe = match std::env::current_exe() {
1252 Ok(path) => path,
1253 Err(error) => {
1254 let message = format!("could not resolve the current rpi executable: {error}");
1255 let _ = write_windows_update_status(&status_file, "failed", &message);
1256 eprintln!("error: {message}");
1257 return 1;
1258 }
1259 };
1260 let powershell = match system_powershell_path() {
1261 Ok(path) => path,
1262 Err(error) => {
1263 let _ = write_windows_update_status(&status_file, "failed", &error);
1264 eprintln!("error: {error}");
1265 return 1;
1266 }
1267 };
1268
1269 let command_dir = match create_self_update_command_dir() {
1270 Ok(directory) => directory,
1271 Err(error) => {
1272 let message = format!("could not create an isolated Cargo directory: {error}");
1273 let _ = write_windows_update_status(&status_file, "failed", &message);
1274 eprintln!("error: {message}");
1275 return 1;
1276 }
1277 };
1278 let mut cargo = match cargo_install_command(Some(&staging_dir), command_dir.path()) {
1279 Ok(command) => command,
1280 Err(error) => {
1281 let message = format!("invalid isolated Cargo directory: {error}");
1282 let _ = write_windows_update_status(&status_file, "failed", &message);
1283 eprintln!("error: {message}");
1284 return 1;
1285 }
1286 };
1287 let status = cargo.status();
1288 match status {
1289 Ok(status) if status.success() => {}
1290 Ok(status) => {
1291 let message = format!("cargo install exited with {status}");
1292 let _ = write_windows_update_status(&status_file, "failed", &message);
1293 eprintln!("error: {message}");
1294 return 1;
1295 }
1296 Err(error) => {
1297 let message = format!("could not run cargo (install Rust/Cargo first): {error}");
1298 let _ = write_windows_update_status(&status_file, "failed", &message);
1299 eprintln!("error: {message}");
1300 return 1;
1301 }
1302 }
1303
1304 let plan = match build_windows_update_plan(&staging_dir, ¤t_exe, &status_file) {
1305 Ok(plan) => plan,
1306 Err(error) => {
1307 let _ = write_windows_update_status(&status_file, "failed", &error);
1308 eprintln!("error: {error}");
1309 return 1;
1310 }
1311 };
1312 let staged_version = match validate_staged_rpi(&plan) {
1313 Ok(version) => version,
1314 Err(error) => {
1315 let _ = write_windows_update_status(&plan.status_file, "failed", &error);
1316 eprintln!("error: {error}");
1317 return 1;
1318 }
1319 };
1320 if let Err(error) = preflight_windows_replacement(&plan) {
1321 let _ = write_windows_update_status(&plan.status_file, "failed", &error);
1322 eprintln!("error: {error}");
1323 return 1;
1324 }
1325 if let Err(error) = std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER.as_bytes()) {
1326 let message = format!("could not write the self-update helper: {error}");
1327 let _ = write_windows_update_status(&plan.status_file, "failed", &message);
1328 eprintln!("error: {message}");
1329 return 1;
1330 }
1331 if validated_regular_file(&plan.helper_script, "self-update helper").is_err() {
1332 let message = "self-update helper did not pass regular-file validation";
1333 let _ = write_windows_update_status(&plan.status_file, "failed", message);
1334 eprintln!("error: {message}");
1335 return 1;
1336 }
1337 let _ = write_windows_update_status(
1338 &plan.status_file,
1339 "scheduled",
1340 &format!("validated rpi {staged_version}; waiting to replace rpi after this process exits"),
1341 );
1342 let mut command = windows_update_helper_command(
1343 &powershell,
1344 &plan,
1345 std::process::id(),
1346 &staged_version.to_string(),
1347 );
1348 use std::os::windows::process::CommandExt;
1349 command.creation_flags(0x0800_0000); match command.spawn() {
1351 Ok(_) => {
1352 let persisted_staging = staging.directory.keep();
1353 println!(
1354 "rpi update staged; it will be applied after this process exits\nStatus: {}",
1355 plan.status_file.display()
1356 );
1357 debug_assert!(windows_paths_equal(&persisted_staging, &plan.staging_dir));
1358 0
1359 }
1360 Err(error) => {
1361 let message = format!("could not start the self-update helper: {error}");
1362 let _ = write_windows_update_status(&plan.status_file, "failed", &message);
1363 eprintln!("error: {message}");
1364 1
1365 }
1366 }
1367}
1368
1369#[cfg(windows)]
1370fn create_windows_update_staging() -> Result<WindowsUpdateStaging, String> {
1371 let staging = create_self_update_staging_directory()?;
1372 let update_root = staging
1373 .path()
1374 .parent()
1375 .ok_or_else(|| "self-update staging directory has no parent".to_string())?;
1376 let status_file = update_root.join(format!("status-{}.json", uuid::Uuid::new_v4().simple()));
1377 if status_file.exists() {
1378 return Err("self-update status path collision".to_string());
1379 }
1380 Ok(WindowsUpdateStaging {
1381 directory: staging,
1382 status_file,
1383 })
1384}
1385
1386#[cfg(windows)]
1387fn build_windows_update_plan(
1388 staging_dir: &Path,
1389 target_exe: &Path,
1390 status_file: &Path,
1391) -> Result<WindowsUpdatePlan, String> {
1392 let staging_dir = validated_real_directory(staging_dir, "self-update staging directory")?;
1393 if !status_file.is_absolute() {
1394 return Err("self-update status path must be absolute".to_string());
1395 }
1396 let status_parent = validated_real_directory(
1397 status_file
1398 .parent()
1399 .ok_or_else(|| "self-update status path has no parent".to_string())?,
1400 "self-update status directory",
1401 )?;
1402 if staging_dir.parent() != Some(status_parent.as_path()) {
1403 return Err("self-update status file is outside the staging parent".to_string());
1404 }
1405 let status_name = status_file
1406 .file_name()
1407 .ok_or_else(|| "self-update status file has no name".to_string())?;
1408 if !is_self_update_status_name(status_name) {
1409 return Err("self-update status file has an invalid name".to_string());
1410 }
1411 if status_file.exists() {
1412 validated_regular_file(status_file, "self-update status file")?;
1413 }
1414 let staged_exe = validated_windows_executable(
1415 &staging_dir.join("bin").join("rpi.exe"),
1416 "staged rpi executable",
1417 )?;
1418 let target_exe = validated_windows_executable(target_exe, "current rpi executable")?;
1419 let target_dir = validated_real_directory(
1420 target_exe
1421 .parent()
1422 .ok_or_else(|| "current executable has no parent directory".to_string())?,
1423 "current executable directory",
1424 )?;
1425 if target_exe.parent() != Some(target_dir.as_path()) {
1426 return Err("current executable is not a direct child of its validated directory".into());
1427 }
1428 let token = uuid::Uuid::new_v4().simple().to_string();
1429 let replacement_temp = target_dir.join(format!(".rpi-update-{token}.new.exe"));
1430 let backup_file = target_dir.join(format!(".rpi-update-{token}.old.exe"));
1431 let rollback_displaced_file = target_dir.join(format!(".rpi-update-{token}.rollback-new.exe"));
1432 if replacement_temp.exists() || backup_file.exists() || rollback_displaced_file.exists() {
1433 return Err("self-update replacement path collision".to_string());
1434 }
1435
1436 Ok(WindowsUpdatePlan {
1437 staging_dir: staging_dir.clone(),
1438 staged_exe,
1439 target_exe,
1440 helper_script: staging_dir.join("apply-update.ps1"),
1441 status_file: status_file.to_path_buf(),
1442 replacement_temp,
1443 backup_file,
1444 rollback_displaced_file,
1445 })
1446}
1447
1448#[cfg(windows)]
1449fn preflight_windows_replacement(plan: &WindowsUpdatePlan) -> Result<(), String> {
1450 for path in [
1451 &plan.replacement_temp,
1452 &plan.backup_file,
1453 &plan.rollback_displaced_file,
1454 ] {
1455 if path.exists() {
1456 return Err(format!(
1457 "self-update path already exists: {}",
1458 path.display()
1459 ));
1460 }
1461 let file = std::fs::OpenOptions::new()
1462 .write(true)
1463 .create_new(true)
1464 .open(path)
1465 .map_err(|error| format!("cannot write beside the current executable: {error}"))?;
1466 drop(file);
1467 std::fs::remove_file(path)
1468 .map_err(|error| format!("could not remove self-update probe: {error}"))?;
1469 }
1470 Ok(())
1471}
1472
1473#[cfg(windows)]
1474fn validate_staged_rpi(plan: &WindowsUpdatePlan) -> Result<semver::Version, String> {
1475 let cwd = plan
1476 .helper_script
1477 .parent()
1478 .ok_or_else(|| "self-update staging directory is missing".to_string())?;
1479 validate_rpi_executable_version(&plan.staged_exe, cwd, crate::VERSION, "staged rpi")
1480}
1481
1482struct BoundedProcessOutput {
1484 status: std::process::ExitStatus,
1485 stdout: Vec<u8>,
1486 stderr: Vec<u8>,
1487}
1488
1489fn run_bounded_process_blocking(
1496 program: &str,
1497 args: &[String],
1498 cwd: &Path,
1499 timeout: std::time::Duration,
1500 max_output_bytes: usize,
1501) -> Result<BoundedProcessOutput, String> {
1502 let program = program.to_string();
1503 let args = args.to_vec();
1504 let cwd = cwd.to_path_buf();
1505 std::thread::Builder::new()
1506 .name("rpi-bounded-version-probe".to_string())
1507 .spawn(move || {
1508 let runtime = tokio::runtime::Builder::new_current_thread()
1509 .enable_all()
1510 .build()
1511 .map_err(|error| format!("probe runtime failed: {error}"))?;
1512 runtime.block_on(async move {
1513 let child = tokio::process::Command::new(&program)
1514 .args(&args)
1515 .current_dir(&cwd)
1516 .stdin(std::process::Stdio::null())
1517 .stdout(std::process::Stdio::piped())
1518 .stderr(std::process::Stdio::piped())
1519 .kill_on_drop(true)
1520 .spawn()
1521 .map_err(|error| format!("spawn failed: {error}"))?;
1522 let output = match tokio::time::timeout(timeout, child.wait_with_output()).await {
1523 Ok(result) => result.map_err(|error| format!("wait failed: {error}"))?,
1524 Err(_) => return Err(format!("timed out after {timeout:?}")),
1527 };
1528 Ok(BoundedProcessOutput {
1529 status: output.status,
1530 stdout: cap_bytes(output.stdout, max_output_bytes),
1531 stderr: cap_bytes(output.stderr, max_output_bytes),
1532 })
1533 })
1534 })
1535 .map_err(|error| format!("probe thread failed: {error}"))?
1536 .join()
1537 .map_err(|_| "probe worker panicked".to_string())?
1538}
1539
1540fn cap_bytes(bytes: Vec<u8>, limit: usize) -> Vec<u8> {
1541 if bytes.len() <= limit {
1542 bytes
1543 } else {
1544 bytes[..limit].to_vec()
1545 }
1546}
1547
1548fn validate_rpi_executable_version(
1549 executable: &Path,
1550 cwd: &Path,
1551 current: &str,
1552 label: &str,
1553) -> Result<semver::Version, String> {
1554 let program = executable
1555 .to_str()
1556 .ok_or_else(|| format!("{label} path cannot be represented as Unicode"))?;
1557 let args = vec!["--version".to_string()];
1558 let output = run_bounded_process_blocking(
1559 program,
1560 &args,
1561 cwd,
1562 STAGED_RPI_CHECK_TIMEOUT,
1563 MAX_STAGED_RPI_OUTPUT_BYTES,
1564 )
1565 .map_err(|error| format!("{label} version check failed: {error}"))?;
1566 if !output.status.success() {
1567 return Err(format!("{label} --version exited with {}", output.status));
1568 }
1569 validate_staged_rpi_version_output(&output.stdout, &output.stderr, current)
1570}
1571
1572fn validate_staged_rpi_version_output(
1573 stdout: &[u8],
1574 stderr: &[u8],
1575 current: &str,
1576) -> Result<semver::Version, String> {
1577 if !stderr.is_empty() {
1578 return Err("staged rpi --version wrote unexpected stderr output".to_string());
1579 }
1580 let staged = parse_rpi_version_output(stdout)
1581 .ok_or_else(|| "staged rpi returned an invalid --version response".to_string())?;
1582 let current = semver::Version::parse(current)
1583 .map_err(|_| "the running rpi version is not valid semantic versioning".to_string())?;
1584 if staged < current {
1585 return Err(format!(
1586 "refusing to replace rpi {current} with older version {staged}"
1587 ));
1588 }
1589 Ok(staged)
1590}
1591
1592fn parse_rpi_version_output(output: &[u8]) -> Option<semver::Version> {
1593 let output = std::str::from_utf8(output).ok()?;
1594 let line = output
1595 .strip_suffix("\r\n")
1596 .or_else(|| output.strip_suffix('\n'))
1597 .unwrap_or(output);
1598 if line.contains(['\r', '\n']) {
1599 return None;
1600 }
1601 let version = line.strip_prefix(crate::APP_NAME)?.strip_prefix(' ')?;
1602 if version.is_empty() || version.chars().any(char::is_whitespace) {
1603 return None;
1604 }
1605 semver::Version::parse(version).ok()
1606}
1607
1608fn validated_real_directory(path: &Path, label: &str) -> Result<PathBuf, String> {
1614 if !path.is_absolute() {
1615 return Err(format!("{label} must be absolute"));
1616 }
1617 let metadata = std::fs::symlink_metadata(path)
1618 .map_err(|error| format!("could not inspect {label}: {error}"))?;
1619 if metadata.file_type().is_symlink() {
1620 return Err(format!(
1621 "{label} is a link or junction, not a real directory ({} -> {})",
1622 path.display(),
1623 canonical_display(path),
1624 ));
1625 }
1626 if !metadata.is_dir() {
1627 return Err(format!("{label} is not a real directory"));
1628 }
1629 std::fs::canonicalize(path)
1630 .map(normalize_windows_path)
1631 .map_err(|error| format!("could not canonicalize {label}: {error}"))
1632}
1633
1634fn validated_regular_file(path: &Path, label: &str) -> Result<PathBuf, String> {
1639 if !path.is_absolute() {
1640 return Err(format!("{label} must be absolute"));
1641 }
1642 let metadata = std::fs::symlink_metadata(path)
1643 .map_err(|error| format!("could not inspect {label}: {error}"))?;
1644 if metadata.file_type().is_symlink() {
1645 return Err(format!(
1646 "{label} is a link or junction, not a non-empty regular file ({} -> {})",
1647 path.display(),
1648 canonical_display(path),
1649 ));
1650 }
1651 if !metadata.is_file() || metadata.len() == 0 {
1652 return Err(format!("{label} is not a non-empty regular file"));
1653 }
1654 std::fs::canonicalize(path)
1655 .map(normalize_windows_path)
1656 .map_err(|error| format!("could not canonicalize {label}: {error}"))
1657}
1658
1659fn is_self_update_status_name(name: &std::ffi::OsStr) -> bool {
1660 let Some(name) = name.to_str() else {
1661 return false;
1662 };
1663 let Some(token) = name
1664 .strip_prefix("status-")
1665 .and_then(|name| name.strip_suffix(".json"))
1666 else {
1667 return false;
1668 };
1669 token.len() == 32 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
1670}
1671
1672fn validated_self_update_status_file(update_root: &Path, path: &Path) -> Result<PathBuf, String> {
1673 if !path.is_absolute() || path.parent() != Some(update_root) {
1674 return Err("self-update status file is outside the update root".to_string());
1675 }
1676 if !path.file_name().is_some_and(is_self_update_status_name) {
1677 return Err("self-update status file has an invalid name".to_string());
1678 }
1679 let path = validated_regular_file(path, "self-update status file")?;
1680 let length = std::fs::metadata(&path)
1681 .map_err(|error| format!("could not inspect self-update status file: {error}"))?
1682 .len();
1683 if length > MAX_SELF_UPDATE_STATUS_BYTES {
1684 return Err("self-update status file is too large".to_string());
1685 }
1686 Ok(path)
1687}
1688
1689fn read_self_update_status(path: &Path) -> Option<SelfUpdateStatus> {
1690 use std::io::Read;
1691
1692 let file = std::fs::File::open(path).ok()?;
1693 let mut data = Vec::with_capacity(
1694 usize::try_from(MAX_SELF_UPDATE_STATUS_BYTES)
1695 .unwrap_or_default()
1696 .min(8 * 1024),
1697 );
1698 file.take(MAX_SELF_UPDATE_STATUS_BYTES.saturating_add(1))
1699 .read_to_end(&mut data)
1700 .ok()?;
1701 if data.len() as u64 > MAX_SELF_UPDATE_STATUS_BYTES {
1702 return None;
1703 }
1704 serde_json::from_slice(&data).ok()
1705}
1706
1707fn sanitized_self_update_status_message(message: &str) -> String {
1708 let mut sanitized = String::new();
1709 let mut pending_space = false;
1710 let mut truncated = false;
1711 for character in message.trim().chars() {
1712 if character.is_control() || character.is_whitespace() {
1713 pending_space = !sanitized.is_empty();
1714 continue;
1715 }
1716 if sanitized.chars().count() >= MAX_SELF_UPDATE_STATUS_MESSAGE_CHARS {
1717 truncated = true;
1718 break;
1719 }
1720 if pending_space {
1721 sanitized.push(' ');
1722 pending_space = false;
1723 }
1724 sanitized.push(character);
1725 }
1726 if sanitized.is_empty() {
1727 return "no diagnostic message was recorded".to_string();
1728 }
1729 if truncated {
1730 sanitized.push_str("...");
1731 }
1732 sanitized
1733}
1734
1735fn consume_self_update_statuses(agent_dir: &Path) -> Vec<UpdateWarning> {
1736 let _guard = SELF_UPDATE_STATUS_LOCK
1737 .lock()
1738 .unwrap_or_else(std::sync::PoisonError::into_inner);
1739 let agent_dir = match validated_real_directory(agent_dir, "agent directory") {
1740 Ok(path) => path,
1741 Err(_) => return Vec::new(),
1742 };
1743 let update_root = match validated_real_directory(
1744 &agent_dir.join("self-update"),
1745 "self-update status directory",
1746 ) {
1747 Ok(path) if path.parent() == Some(agent_dir.as_path()) => path,
1748 _ => return Vec::new(),
1749 };
1750 let entries = match std::fs::read_dir(&update_root) {
1751 Ok(entries) => entries,
1752 Err(_) => return Vec::new(),
1753 };
1754
1755 let mut warnings = Vec::new();
1756 for entry in entries.flatten() {
1757 if !is_self_update_status_name(&entry.file_name()) {
1758 continue;
1759 }
1760 let path = match validated_self_update_status_file(&update_root, &entry.path()) {
1761 Ok(path) => path,
1762 Err(_) => continue,
1763 };
1764 let Some(status) = read_self_update_status(&path) else {
1765 continue;
1766 };
1767 if !matches!(status.state.as_str(), "failed" | "succeeded") {
1768 continue;
1769 }
1770
1771 match std::fs::remove_file(&path) {
1772 Ok(()) => {}
1773 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1774 Err(_) if status.state == "failed" => {}
1777 Err(_) => continue,
1778 }
1779 if status.state == "failed" {
1780 warnings.push(UpdateWarning {
1781 message: format!(
1782 "The previously scheduled rpi update failed: {}",
1783 sanitized_self_update_status_message(&status.message)
1784 ),
1785 command: "rpi update".to_string(),
1786 });
1787 }
1788 }
1789 warnings
1790}
1791
1792#[cfg(windows)]
1793fn validated_windows_executable(path: &Path, label: &str) -> Result<PathBuf, String> {
1794 use std::io::Read;
1795
1796 let path = validated_regular_file(path, label)?;
1797 let mut file =
1798 std::fs::File::open(&path).map_err(|error| format!("could not open {label}: {error}"))?;
1799 let mut magic = [0u8; 2];
1800 file.read_exact(&mut magic)
1801 .map_err(|error| format!("could not read {label}: {error}"))?;
1802 if magic != *b"MZ" {
1803 return Err(format!("{label} is not a Windows executable"));
1804 }
1805 Ok(path)
1806}
1807
1808#[cfg(windows)]
1809fn system_powershell_path() -> Result<PathBuf, String> {
1810 use std::ffi::OsString;
1811 use std::os::windows::ffi::OsStringExt;
1812 use windows_sys::Win32::System::SystemInformation::GetWindowsDirectoryW;
1813
1814 let mut buffer = vec![0u16; 260];
1815 let system_root = loop {
1816 let length = unsafe {
1817 GetWindowsDirectoryW(
1818 buffer.as_mut_ptr(),
1819 u32::try_from(buffer.len()).unwrap_or(u32::MAX),
1820 )
1821 };
1822 if length == 0 {
1823 return Err(format!(
1824 "could not locate the Windows system directory: {}",
1825 std::io::Error::last_os_error()
1826 ));
1827 }
1828 let length = usize::try_from(length)
1829 .map_err(|error| format!("invalid Windows system directory length: {error}"))?;
1830 if length < buffer.len() {
1831 buffer.truncate(length);
1832 break PathBuf::from(OsString::from_wide(&buffer));
1833 }
1834 buffer.resize(length.saturating_add(1), 0);
1835 };
1836 let system_root = validated_real_directory(&system_root, "Windows system root")?;
1837 let powershell = validated_windows_executable(
1838 &system_root.join("System32/WindowsPowerShell/v1.0/powershell.exe"),
1839 "Windows PowerShell",
1840 )?;
1841 powershell
1842 .strip_prefix(&system_root)
1843 .map_err(|_| "Windows PowerShell is outside SystemRoot".to_string())?;
1844 Ok(powershell)
1845}
1846
1847#[cfg(windows)]
1848fn windows_update_helper_command(
1849 powershell: &Path,
1850 plan: &WindowsUpdatePlan,
1851 parent_pid: u32,
1852 staged_version: &str,
1853) -> std::process::Command {
1854 let mut command = std::process::Command::new(powershell);
1855 let powershell_modules = powershell
1856 .parent()
1857 .expect("validated PowerShell path has a parent")
1858 .join("Modules");
1859 command
1860 .args([
1861 "-NoLogo",
1862 "-NoProfile",
1863 "-NonInteractive",
1864 "-ExecutionPolicy",
1865 "Bypass",
1866 "-File",
1867 ])
1868 .arg(&plan.helper_script)
1869 .arg("-ParentPid")
1870 .arg(parent_pid.to_string())
1871 .arg("-StagingPath")
1872 .arg(&plan.staging_dir)
1873 .arg("-StagedPath")
1874 .arg(&plan.staged_exe)
1875 .arg("-StagedVersion")
1876 .arg(staged_version)
1877 .arg("-TargetPath")
1878 .arg(&plan.target_exe)
1879 .arg("-TempPath")
1880 .arg(&plan.replacement_temp)
1881 .arg("-BackupPath")
1882 .arg(&plan.backup_file)
1883 .arg("-RollbackDisplacedPath")
1884 .arg(&plan.rollback_displaced_file)
1885 .arg("-StatusPath")
1886 .arg(&plan.status_file)
1887 .current_dir(
1888 plan.status_file
1889 .parent()
1890 .expect("validated status path has an update-root parent"),
1891 )
1892 .env("PSModulePath", powershell_modules)
1893 .stdin(Stdio::null())
1894 .stdout(Stdio::null())
1895 .stderr(Stdio::null());
1896 command
1897}
1898
1899#[cfg(windows)]
1900fn write_windows_update_status(path: &Path, state: &str, message: &str) -> Result<(), String> {
1901 let data = serde_json::to_vec_pretty(&serde_json::json!({
1902 "state": state,
1903 "message": message,
1904 "parentPid": std::process::id(),
1905 "currentVersion": crate::VERSION,
1906 "updatedAtMs": now_ms(),
1907 }))
1908 .map_err(|error| error.to_string())?;
1909 std::fs::write(path, data)
1910 .map_err(|error| format!("could not write update status {}: {error}", path.display()))
1911}
1912
1913#[cfg(test)]
1914mod tests {
1915 fn real_tempdir() -> tempfile::TempDir {
1920 tempfile::Builder::new()
1921 .prefix("rpi-update-test-")
1922 .tempdir()
1923 .unwrap()
1924 }
1925
1926 use std::collections::BTreeMap;
1927 use std::ffi::OsString;
1928 use std::path::Path;
1929 use std::sync::atomic::{AtomicUsize, Ordering};
1930 use std::sync::Arc;
1931
1932 #[cfg(unix)]
1933 use super::{activate_direct_update, build_direct_update_plan};
1934 #[cfg(windows)]
1935 use super::{
1936 build_windows_update_plan, preflight_windows_replacement, system_powershell_path,
1937 windows_update_helper_command, write_windows_update_status, WINDOWS_UPDATE_HELPER,
1938 };
1939 use super::{
1940 cargo_home_is_project_local, cargo_install_command, collect_bounded,
1941 create_self_update_command_dir, fetch_crates_url, is_newer, parse_rpi_version_output,
1942 path_is_within, results_with_individual_cache_fallback, validate_staged_rpi_version_output,
1943 RegistryLookup, CACHE_FALLBACK_MAX_AGE_MS, UPDATE_CHECK_CONCURRENCY,
1944 };
1945
1946 struct RestoreConfigDir(Option<OsString>);
1947
1948 impl Drop for RestoreConfigDir {
1949 fn drop(&mut self) {
1950 match self.0.take() {
1951 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
1952 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
1953 }
1954 }
1955 }
1956
1957 struct RestoreEnv {
1958 name: &'static str,
1959 value: Option<OsString>,
1960 }
1961
1962 impl RestoreEnv {
1963 fn capture(name: &'static str) -> Self {
1964 Self {
1965 name,
1966 value: std::env::var_os(name),
1967 }
1968 }
1969 }
1970
1971 impl Drop for RestoreEnv {
1972 fn drop(&mut self) {
1973 match self.value.take() {
1974 Some(value) => std::env::set_var(self.name, value),
1975 None => std::env::remove_var(self.name),
1976 }
1977 }
1978 }
1979
1980 #[cfg(windows)]
1981 fn fake_windows_executable(path: &Path, marker: &[u8]) {
1982 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1983 let mut bytes = b"MZ".to_vec();
1984 bytes.extend_from_slice(marker);
1985 std::fs::write(path, bytes).unwrap();
1986 }
1987
1988 #[cfg(unix)]
1989 fn fake_unix_rpi(path: &Path, version: &str, fail_after_rename_to: Option<&str>) {
1990 use std::os::unix::fs::PermissionsExt;
1991
1992 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1993 let failure = fail_after_rename_to.map_or_else(String::new, |name| {
1994 format!(
1995 "case \"$0\" in\n */{name}) echo 'post-replace failure' >&2; exit 1 ;;\nesac\n"
1996 )
1997 });
1998 std::fs::write(
1999 path,
2000 format!("#!/bin/sh\n{failure}printf 'rpi {version}\\n'\n"),
2001 )
2002 .unwrap();
2003 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2004 }
2005
2006 fn write_test_update_status(path: &Path, state: &str, message: &str) {
2007 std::fs::write(
2008 path,
2009 serde_json::to_vec(&serde_json::json!({
2010 "state": state,
2011 "message": message,
2012 }))
2013 .unwrap(),
2014 )
2015 .unwrap();
2016 }
2017
2018 #[test]
2019 fn path_validation_resolves_ancestor_links_but_refuses_a_link_leaf() {
2020 let temp = real_tempdir();
2021 let real_dir = temp.path().join("real-dir");
2023 let real_file = temp.path().join("real-file.exe");
2024 std::fs::create_dir_all(&real_dir).unwrap();
2025 std::fs::write(&real_file, b"payload").unwrap();
2026
2027 let link_dir = temp.path().join("link-dir");
2028 let link_file = temp.path().join("link-file.exe");
2029 #[cfg(unix)]
2030 let linked = {
2031 std::os::unix::fs::symlink(&real_dir, &link_dir).unwrap();
2032 std::os::unix::fs::symlink(&real_file, &link_file).unwrap();
2033 true
2034 };
2035 #[cfg(windows)]
2036 let linked = {
2037 let dir_ok = std::process::Command::new("cmd")
2040 .args(["/c", "mklink", "/J"])
2041 .arg(&link_dir)
2042 .arg(&real_dir)
2043 .output()
2044 .map(|out| out.status.success())
2045 .unwrap_or(false);
2046 if dir_ok {
2047 let file_ok = std::os::windows::fs::symlink_file(&real_file, &link_file).is_ok();
2048 if !file_ok {
2049 eprintln!("skipping the file-link half: no symlink privilege");
2050 }
2051 }
2052 dir_ok
2053 };
2054 if !linked {
2055 eprintln!("skipping: this platform could not create the link fixture");
2056 return;
2057 }
2058
2059 let through_link = link_dir.join("nested").join("leaf.exe");
2062 std::fs::create_dir_all(through_link.parent().unwrap()).unwrap();
2063 std::fs::write(&through_link, b"payload").unwrap();
2064 super::validated_regular_file(&through_link, "file below a linked directory").unwrap();
2065 assert_eq!(
2066 std::fs::canonicalize(&link_dir).unwrap(),
2067 std::fs::canonicalize(&real_dir).unwrap(),
2068 );
2069
2070 let error = super::validated_real_directory(&link_dir, "linked directory").unwrap_err();
2072 assert!(error.contains("link or junction"), "{error}");
2073 assert!(error.contains(&link_dir.display().to_string()), "{error}");
2074
2075 if link_file.exists() {
2076 let error = super::validated_regular_file(&link_file, "linked file").unwrap_err();
2077 assert!(error.contains("link or junction"), "{error}");
2078 assert!(error.contains(&link_file.display().to_string()), "{error}");
2079 assert!(error.contains(&real_file.display().to_string()), "{error}");
2080 }
2081
2082 super::validated_real_directory(&real_dir, "real directory").unwrap();
2085 super::validated_regular_file(&real_file, "real file").unwrap();
2086 }
2087
2088 #[test]
2089 fn cargo_self_update_staging_is_passed_as_a_structured_argument() {
2090 let temp = real_tempdir();
2091 let staging = temp.path().join("staging");
2092 let command_dir = temp.path().join("command");
2093 std::fs::create_dir_all(&staging).unwrap();
2094 std::fs::create_dir_all(&command_dir).unwrap();
2095 let command = cargo_install_command(Some(&staging), &command_dir).unwrap();
2096 let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2097
2098 assert_eq!(command.get_program(), "cargo");
2099 assert_eq!(args.len(), 7);
2100 assert_eq!(args[0], "install");
2101 assert_eq!(args[1], "--root");
2102 assert_eq!(args[2], staging.as_os_str());
2103 assert_eq!(args[3], "rpi-cli");
2104 assert_eq!(args[4], "--locked");
2105 assert_eq!(args[5], "--force");
2106 assert_eq!(args[6], "--quiet");
2109 assert_eq!(command.get_current_dir(), Some(command_dir.as_path()));
2110 }
2111
2112 #[cfg(unix)]
2113 #[test]
2114 fn direct_update_replaces_the_selected_target_and_rejects_downgrades() {
2115 let temp = real_tempdir();
2116 let staging = temp.path().join("staging");
2117 let staged = staging.join("bin/rpi");
2118 let target = temp.path().join("target/rpi-current");
2119 fake_unix_rpi(&staged, "9.9.9", None);
2120 fake_unix_rpi(&target, crate::VERSION, None);
2121 let expected = std::fs::read(&staged).unwrap();
2122
2123 let plan = build_direct_update_plan(&staging, &target).unwrap();
2124 assert_eq!(activate_direct_update(&plan).unwrap().to_string(), "9.9.9");
2125 assert_eq!(std::fs::read(&target).unwrap(), expected);
2126 assert!(!plan.replacement_temp.exists());
2127 assert!(!plan.backup_file.exists());
2128
2129 fake_unix_rpi(&staged, "0.0.1", None);
2130 let plan = build_direct_update_plan(&staging, &target).unwrap();
2131 let error = activate_direct_update(&plan).unwrap_err();
2132 assert!(error.contains("older version"), "{error}");
2133 assert_eq!(std::fs::read(&target).unwrap(), expected);
2134 }
2135
2136 #[cfg(unix)]
2137 #[test]
2138 fn direct_update_rolls_back_when_post_replace_validation_fails() {
2139 let temp = real_tempdir();
2140 let staging = temp.path().join("staging");
2141 let staged = staging.join("bin/rpi");
2142 let target = temp.path().join("target/rpi-current");
2143 fake_unix_rpi(&staged, "9.9.9", Some("rpi-current"));
2144 fake_unix_rpi(&target, crate::VERSION, None);
2145 let original = std::fs::read(&target).unwrap();
2146
2147 let plan = build_direct_update_plan(&staging, &target).unwrap();
2148 let error = activate_direct_update(&plan).unwrap_err();
2149
2150 assert!(error.contains("restored the previous"), "{error}");
2151 assert_eq!(std::fs::read(&target).unwrap(), original);
2152 assert!(!plan.replacement_temp.exists());
2153 assert!(!plan.backup_file.exists());
2154 }
2155
2156 #[test]
2157 fn self_update_honors_env_and_early_dispatched_offline_flag() {
2158 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2159 let _restore = RestoreEnv::capture(crate::args::RPI_OFFLINE_ENV);
2160
2161 std::env::set_var(crate::args::RPI_OFFLINE_ENV, "tRuE");
2162 assert_eq!(super::run_self_update(&[]), 0);
2163
2164 std::env::set_var(crate::args::RPI_OFFLINE_ENV, "0");
2165 assert_eq!(super::run_self_update(&["--offline".into()]), 0);
2166 assert_eq!(
2167 std::env::var(crate::args::RPI_OFFLINE_ENV).as_deref(),
2168 Ok("1")
2169 );
2170 }
2171
2172 #[tokio::test]
2173 async fn offline_skips_rpi_startup_checks_before_cache_io() {
2174 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2175 let _restore_config = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2176 let _restore_offline = RestoreEnv::capture(crate::args::RPI_OFFLINE_ENV);
2177 let _restore_disabled = RestoreEnv::capture("RPI_DISABLE_UPDATE_CHECK");
2178 let temp = real_tempdir();
2179 let agent = temp.path().join("offline-agent");
2180 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2181 std::env::set_var(crate::args::RPI_OFFLINE_ENV, "YES");
2182 std::env::remove_var("RPI_DISABLE_UPDATE_CHECK");
2183
2184 let report = super::check_rpi_startup().await;
2185
2186 assert_eq!(report, super::UpdateReport::default());
2187 assert!(
2188 !agent.exists(),
2189 "offline checks must not create the update cache directory"
2190 );
2191 }
2192
2193 #[test]
2194 fn completed_self_update_statuses_are_consumed_once() {
2195 let temp = real_tempdir();
2196 let agent = temp.path().join("agent");
2197 let update_root = agent.join("self-update");
2198 std::fs::create_dir_all(&update_root).unwrap();
2199 let failed = update_root.join("status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json");
2200 let succeeded = update_root.join("status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.json");
2201 let waiting = update_root.join("status-cccccccccccccccccccccccccccccccc.json");
2202 let temporary = update_root.join("status-dddddddddddddddddddddddddddddddd.json.tmp");
2203 let malformed = update_root.join("status-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.json");
2204 let invalid_name = update_root.join("status-not-a-uuid.json");
2205 write_test_update_status(&failed, "failed", "permission\u{1b}[31m\r\n denied\u{7}");
2206 write_test_update_status(&succeeded, "succeeded", "done");
2207 write_test_update_status(&waiting, "waiting", "still running");
2208 write_test_update_status(&temporary, "failed", "not published");
2209 std::fs::write(&malformed, b"{not-json").unwrap();
2210 write_test_update_status(&invalid_name, "failed", "invalid name");
2211
2212 let warnings = super::consume_self_update_statuses(&agent);
2213
2214 assert_eq!(warnings.len(), 1);
2215 assert!(warnings[0]
2216 .message
2217 .contains("rpi update failed: permission [31m denied"));
2218 assert!(!warnings[0].message.chars().any(char::is_control));
2219 assert_eq!(warnings[0].command, "rpi update");
2220 assert!(!failed.exists());
2221 assert!(!succeeded.exists());
2222 assert!(waiting.exists());
2223 assert!(temporary.exists());
2224 assert!(malformed.exists());
2225 assert!(invalid_name.exists());
2226 assert!(super::consume_self_update_statuses(&agent).is_empty());
2227 }
2228
2229 #[test]
2230 fn self_update_status_validation_rejects_escape_and_links() {
2231 let temp = real_tempdir();
2232 let agent = temp.path().join("agent");
2233 let update_root = agent.join("self-update");
2234 let outside_root = temp.path().join("outside");
2235 std::fs::create_dir_all(&update_root).unwrap();
2236 std::fs::create_dir_all(&outside_root).unwrap();
2237 let name = "status-ffffffffffffffffffffffffffffffff.json";
2238 let outside = outside_root.join(name);
2239 write_test_update_status(&outside, "failed", "outside");
2240
2241 assert!(super::validated_self_update_status_file(&update_root, &outside).is_err());
2242
2243 let linked = update_root.join(name);
2244 #[cfg(unix)]
2245 std::os::unix::fs::symlink(&outside, &linked).unwrap();
2246 #[cfg(windows)]
2247 let link_created = std::os::windows::fs::symlink_file(&outside, &linked).is_ok();
2248 #[cfg(unix)]
2249 let link_created = true;
2250 if link_created {
2251 assert!(super::validated_self_update_status_file(&update_root, &linked).is_err());
2252 assert!(super::consume_self_update_statuses(&agent).is_empty());
2253 assert!(outside.exists());
2254 }
2255 }
2256
2257 #[test]
2258 fn cargo_self_update_without_staging_never_inherits_the_callers_cwd() {
2259 let temp = real_tempdir();
2260 let command_dir = temp.path().join("isolated");
2261 std::fs::create_dir_all(&command_dir).unwrap();
2262
2263 let command = cargo_install_command(None, &command_dir).unwrap();
2264 let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2265
2266 assert_eq!(
2267 args,
2268 ["install", "rpi-cli", "--locked", "--force", "--quiet"]
2269 .into_iter()
2270 .map(OsString::from)
2271 .collect::<Vec<_>>()
2272 );
2273 assert_eq!(command.get_current_dir(), Some(command_dir.as_path()));
2274 assert_ne!(
2275 command.get_current_dir(),
2276 std::env::current_dir().ok().as_deref()
2277 );
2278 for variable in [
2279 "RUSTC_WRAPPER",
2280 "RUSTC_WORKSPACE_WRAPPER",
2281 "CARGO_BUILD_RUSTC_WRAPPER",
2282 "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
2283 ] {
2284 assert!(command
2285 .get_envs()
2286 .any(|(name, value)| name == variable && value.is_none()));
2287 }
2288 assert!(cargo_install_command(None, Path::new("relative-command-dir")).is_err());
2289
2290 let generated = create_self_update_command_dir().unwrap();
2291 let caller = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap();
2292 assert!(generated.path().is_absolute());
2293 assert!(!path_is_within(generated.path(), &caller));
2294 }
2295
2296 #[test]
2297 fn project_local_cargo_home_is_rejected_even_when_its_leaf_is_missing() {
2298 let temp = real_tempdir();
2299 let project = temp.path().join("project");
2300 let global = temp.path().join("global-cargo");
2301 std::fs::create_dir_all(&project).unwrap();
2302 std::fs::create_dir_all(&global).unwrap();
2303 let project = std::fs::canonicalize(project).unwrap();
2304
2305 assert!(cargo_home_is_project_local(&project.join(".cargo-home"), &project).unwrap());
2306 assert!(cargo_home_is_project_local(&project, &project).unwrap());
2307 assert!(!cargo_home_is_project_local(&global, &project).unwrap());
2308 assert!(cargo_home_is_project_local(Path::new("relative"), &project).is_err());
2309 }
2310
2311 #[cfg(windows)]
2312 #[test]
2313 fn windows_update_helper_atomically_replaces_a_valid_target() {
2314 let temp = real_tempdir();
2315 let staging = temp.path().join("staging 'semi;& with spaces");
2316 let staged = staging.join("bin/rpi.exe");
2317 let target = temp.path().join("target 'semi;& with spaces/rpi-copy.exe");
2318 fake_windows_executable(&staged, b"new-version");
2319 fake_windows_executable(&target, b"old-version");
2320 let expected = std::fs::read(&staged).unwrap();
2321 let plan = build_windows_update_plan(
2322 &staging,
2323 &target,
2324 &temp
2325 .path()
2326 .join("status-00000000000000000000000000000000.json"),
2327 )
2328 .unwrap();
2329 preflight_windows_replacement(&plan).unwrap();
2330 std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER).unwrap();
2331 write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
2332
2333 let powershell = system_powershell_path().unwrap();
2334 let mut command =
2335 windows_update_helper_command(&powershell, &plan, i32::MAX as u32, "9.9.9");
2336 let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2337 for (flag, expected) in [
2338 ("-StagingPath", &plan.staging_dir),
2339 ("-StagedPath", &plan.staged_exe),
2340 ("-TargetPath", &plan.target_exe),
2341 ("-TempPath", &plan.replacement_temp),
2342 ("-BackupPath", &plan.backup_file),
2343 ("-RollbackDisplacedPath", &plan.rollback_displaced_file),
2344 ("-StatusPath", &plan.status_file),
2345 ] {
2346 let index = args.iter().position(|arg| arg == flag).unwrap();
2347 assert_eq!(args[index + 1], expected.as_os_str());
2348 }
2349 let version_index = args.iter().position(|arg| arg == "-StagedVersion").unwrap();
2350 assert_eq!(args[version_index + 1], "9.9.9");
2351 assert_eq!(command.get_current_dir(), plan.status_file.parent());
2352 assert!(command.get_envs().any(|(name, value)| {
2353 name == "PSModulePath"
2354 && value == Some(powershell.parent().unwrap().join("Modules").as_os_str())
2355 }));
2356 let status = command.status().unwrap();
2357
2358 let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
2359 assert!(
2360 status.success(),
2361 "helper exited with {status}: {status_text}"
2362 );
2363 assert_eq!(std::fs::read(&target).unwrap(), expected);
2364 assert!(!staging.exists());
2365 assert!(!plan.replacement_temp.exists());
2366 assert!(!plan.backup_file.exists());
2367 assert!(!plan.rollback_displaced_file.exists());
2368 let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
2369 assert_eq!(status["state"], "succeeded");
2370 assert_eq!(status["stagedVersion"], "9.9.9");
2371 }
2372
2373 #[cfg(windows)]
2374 #[test]
2375 fn windows_update_helper_failure_preserves_the_old_executable() {
2376 let temp = real_tempdir();
2377 let staging = temp.path().join("staging with spaces");
2378 let staged = staging.join("bin/rpi.exe");
2379 let target = temp.path().join("target with spaces/rpi-copy.exe");
2380 fake_windows_executable(&staged, b"new-version");
2381 fake_windows_executable(&target, b"old-version");
2382 let expected = std::fs::read(&target).unwrap();
2383 let plan = build_windows_update_plan(
2384 &staging,
2385 &target,
2386 &temp
2387 .path()
2388 .join("status-00000000000000000000000000000000.json"),
2389 )
2390 .unwrap();
2391 preflight_windows_replacement(&plan).unwrap();
2392 std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER).unwrap();
2393 write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
2394 std::fs::remove_file(&plan.staged_exe).unwrap();
2395
2396 let status = windows_update_helper_command(
2397 &system_powershell_path().unwrap(),
2398 &plan,
2399 i32::MAX as u32,
2400 "9.9.9",
2401 )
2402 .status()
2403 .unwrap();
2404
2405 let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
2406 assert!(!status.success());
2407 assert_eq!(
2408 std::fs::read(&target).unwrap(),
2409 expected,
2410 "status={status_text}; backup_exists={}",
2411 plan.backup_file.exists()
2412 );
2413 assert!(!staging.exists());
2414 assert!(!plan.replacement_temp.exists());
2415 assert!(!plan.backup_file.exists());
2416 assert!(!plan.rollback_displaced_file.exists());
2417 let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
2418 assert_eq!(status["state"], "failed");
2419 }
2420
2421 #[cfg(windows)]
2422 #[test]
2423 fn windows_update_helper_rolls_back_after_post_replace_validation_failure() {
2424 let temp = real_tempdir();
2425 let staging = temp.path().join("staging");
2426 let staged = staging.join("bin/rpi.exe");
2427 let target = temp.path().join("target/rpi-copy.exe");
2428 fake_windows_executable(&staged, b"new-version");
2429 fake_windows_executable(&target, b"old-version");
2430 let expected = std::fs::read(&target).unwrap();
2431 let plan = build_windows_update_plan(
2432 &staging,
2433 &target,
2434 &temp
2435 .path()
2436 .join("status-00000000000000000000000000000000.json"),
2437 )
2438 .unwrap();
2439 preflight_windows_replacement(&plan).unwrap();
2440 let forced_failure = WINDOWS_UPDATE_HELPER.replace(
2441 "(Get-FileSha256 -Path $targetFull) -ne $expectedHash",
2442 "$true",
2443 );
2444 assert_ne!(forced_failure, WINDOWS_UPDATE_HELPER);
2445 std::fs::write(&plan.helper_script, forced_failure).unwrap();
2446 write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
2447
2448 let status = windows_update_helper_command(
2449 &system_powershell_path().unwrap(),
2450 &plan,
2451 i32::MAX as u32,
2452 "9.9.9",
2453 )
2454 .status()
2455 .unwrap();
2456
2457 let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
2458 assert!(!status.success());
2459 assert_eq!(
2460 std::fs::read(&target).unwrap(),
2461 expected,
2462 "status={status_text}; backup_exists={}",
2463 plan.backup_file.exists()
2464 );
2465 assert!(!staging.exists());
2466 assert!(!plan.replacement_temp.exists());
2467 assert!(!plan.backup_file.exists());
2468 assert!(!plan.rollback_displaced_file.exists());
2469 let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
2470 assert_eq!(status["state"], "failed");
2471 assert!(status["message"]
2472 .as_str()
2473 .unwrap()
2474 .contains("updated executable hash mismatch"));
2475 }
2476
2477 #[cfg(windows)]
2478 #[test]
2479 fn windows_update_plan_rejects_a_non_executable_staged_file() {
2480 let temp = real_tempdir();
2481 let staging = temp.path().join("staging");
2482 let staged = staging.join("bin/rpi.exe");
2483 let target = temp.path().join("target/rpi.exe");
2484 std::fs::create_dir_all(staged.parent().unwrap()).unwrap();
2485 std::fs::write(&staged, b"not-a-pe").unwrap();
2486 fake_windows_executable(&target, b"old-version");
2487 let expected = std::fs::read(&target).unwrap();
2488
2489 let error = build_windows_update_plan(
2490 &staging,
2491 &target,
2492 &temp
2493 .path()
2494 .join("status-00000000000000000000000000000000.json"),
2495 )
2496 .unwrap_err();
2497
2498 assert!(error.contains("not a Windows executable"));
2499 assert_eq!(std::fs::read(&target).unwrap(), expected);
2500 }
2501
2502 #[test]
2503 fn staged_rpi_version_output_is_exact_and_cannot_downgrade() {
2504 assert_eq!(
2505 parse_rpi_version_output(b"rpi 1.2.3\r\n").unwrap(),
2506 semver::Version::new(1, 2, 3)
2507 );
2508 assert_eq!(
2509 validate_staged_rpi_version_output(b"rpi 1.2.3\n", b"", "1.2.3").unwrap(),
2510 semver::Version::new(1, 2, 3)
2511 );
2512 assert!(
2513 validate_staged_rpi_version_output(b"rpi 1.2.2\n", b"", "1.2.3")
2514 .unwrap_err()
2515 .contains("older version")
2516 );
2517
2518 for output in [
2519 &b"pi 1.2.3\n"[..],
2520 &b"rpi v1.2.3\n"[..],
2521 &b"rpi 1.2.3 extra\n"[..],
2522 &b"rpi 1.2.3\nsecond line\n"[..],
2523 &b" rpi 1.2.3\n"[..],
2524 ] {
2525 assert!(
2526 parse_rpi_version_output(output).is_none(),
2527 "output={output:?}"
2528 );
2529 }
2530 assert!(
2531 validate_staged_rpi_version_output(b"rpi 1.2.3\n", b"warning", "1.2.3")
2532 .unwrap_err()
2533 .contains("stderr")
2534 );
2535 }
2536
2537 #[test]
2538 fn compares_release_versions_conservatively() {
2539 assert!(is_newer("0.1.9", "0.1.10"));
2540 assert!(is_newer("v1.2.3", "1.3.0"));
2541 assert!(is_newer("1.2.3-beta.1", "1.2.3-beta.2"));
2542 assert!(is_newer("1.2.3-beta.2", "1.2.3"));
2543 assert!(!is_newer("1.2.3", "1.2.3"));
2544 assert!(!is_newer("nightly", "1.0.0"));
2545 }
2546
2547 #[test]
2548 fn failed_item_checks_keep_only_corresponding_cached_values() {
2549 let cached = BTreeMap::from([
2550 ("fresh".to_string(), "1.0.0".to_string()),
2551 ("failed".to_string(), "2.0.0".to_string()),
2552 ("removed".to_string(), "3.0.0".to_string()),
2553 ]);
2554 let results = vec![
2555 (
2556 "fresh".to_string(),
2557 RegistryLookup::Found("1.1.0".to_string()),
2558 ),
2559 ("failed".to_string(), RegistryLookup::TransientFailure),
2560 ("uncached".to_string(), RegistryLookup::TransientFailure),
2561 ("missing".to_string(), RegistryLookup::Missing),
2562 ];
2563
2564 let (merged, checked_at_values) =
2565 results_with_individual_cache_fallback(results, Some(&cached), None, 100, 100);
2566 assert_eq!(merged.get("fresh").map(String::as_str), Some("1.1.0"));
2567 assert_eq!(merged.get("failed").map(String::as_str), Some("2.0.0"));
2568 assert!(!merged.contains_key("removed"));
2569 assert!(!merged.contains_key("uncached"));
2570 assert!(!merged.contains_key("missing"));
2571 assert_eq!(checked_at_values.get("fresh").copied(), Some(100));
2575 assert_eq!(checked_at_values.get("failed").copied(), Some(100));
2576 assert!(!checked_at_values.contains_key("uncached"));
2577 assert!(!checked_at_values.contains_key("missing"));
2578 }
2579
2580 #[tokio::test]
2581 async fn package_update_checks_have_a_shared_concurrency_limit() {
2582 let in_flight = Arc::new(AtomicUsize::new(0));
2583 let maximum = Arc::new(AtomicUsize::new(0));
2584 let checks = (0..(UPDATE_CHECK_CONCURRENCY * 3)).map(|index| {
2585 let in_flight = Arc::clone(&in_flight);
2586 let maximum = Arc::clone(&maximum);
2587 async move {
2588 let active = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
2589 maximum.fetch_max(active, Ordering::SeqCst);
2590 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2591 in_flight.fetch_sub(1, Ordering::SeqCst);
2592 index
2593 }
2594 });
2595
2596 let mut results = collect_bounded(checks).await;
2597 results.sort_unstable();
2598
2599 assert_eq!(
2600 results,
2601 (0..(UPDATE_CHECK_CONCURRENCY * 3)).collect::<Vec<_>>()
2602 );
2603 assert_eq!(maximum.load(Ordering::SeqCst), UPDATE_CHECK_CONCURRENCY);
2604 assert_eq!(in_flight.load(Ordering::SeqCst), 0);
2605 }
2606
2607 #[tokio::test]
2608 async fn bounded_checks_do_not_head_of_line_block_new_work() {
2609 let release_first = Arc::new(tokio::sync::Notify::new());
2610 let checks = (0..(UPDATE_CHECK_CONCURRENCY + 1)).map(|index| {
2611 let release_first = Arc::clone(&release_first);
2612 async move {
2613 if index == 0 {
2614 release_first.notified().await;
2615 } else if index == UPDATE_CHECK_CONCURRENCY {
2616 release_first.notify_one();
2617 }
2618 index
2619 }
2620 });
2621
2622 let mut results =
2623 tokio::time::timeout(std::time::Duration::from_secs(1), collect_bounded(checks))
2624 .await
2625 .expect("a completed later check must free a concurrency slot");
2626 results.sort_unstable();
2627 assert_eq!(results, (0..=UPDATE_CHECK_CONCURRENCY).collect::<Vec<_>>());
2628 }
2629
2630 #[test]
2631 fn fresh_results_keep_individual_timestamps_when_a_peer_uses_fallback() {
2632 let now = 10 * CACHE_FALLBACK_MAX_AGE_MS;
2633 let nearly_stale = now - CACHE_FALLBACK_MAX_AGE_MS + 60_000;
2634 let cached = BTreeMap::from([
2635 ("fresh".to_string(), "1.0.0".to_string()),
2636 ("failed".to_string(), "2.0.0".to_string()),
2637 ]);
2638 let freshness = BTreeMap::from([
2639 ("fresh".to_string(), nearly_stale),
2640 ("failed".to_string(), nearly_stale),
2641 ]);
2642 let (values, timestamps) = results_with_individual_cache_fallback(
2643 vec![
2644 (
2645 "fresh".to_string(),
2646 RegistryLookup::Found("1.1.0".to_string()),
2647 ),
2648 ("failed".to_string(), RegistryLookup::TransientFailure),
2649 ],
2650 Some(&cached),
2651 Some(&freshness),
2652 nearly_stale,
2653 now,
2654 );
2655
2656 assert_eq!(timestamps.get("fresh"), Some(&now));
2657 assert_eq!(timestamps.get("failed"), Some(&nearly_stale));
2658 let after_old_entry_expires = now + 120_000;
2659 let (fallback, _) = results_with_individual_cache_fallback(
2660 vec![
2661 ("fresh".to_string(), RegistryLookup::TransientFailure),
2662 ("failed".to_string(), RegistryLookup::TransientFailure),
2663 ],
2664 Some(&values),
2665 Some(×tamps),
2666 nearly_stale,
2667 after_old_entry_expires,
2668 );
2669 assert_eq!(fallback.get("fresh").map(String::as_str), Some("1.1.0"));
2670 assert!(!fallback.contains_key("failed"));
2671 }
2672
2673 #[tokio::test]
2674 async fn crates_rate_limits_and_request_timeouts_are_transient() {
2675 async fn lookup(status: &str, body: &str) -> RegistryLookup {
2676 use std::io::{BufRead, BufReader, Write};
2677
2678 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2679 let address = listener.local_addr().unwrap();
2680 let status = status.to_string();
2681 let body = body.to_string();
2682 let server = std::thread::spawn(move || {
2683 let (mut stream, _) = listener.accept().unwrap();
2684 {
2685 let mut reader = BufReader::new(&mut stream);
2686 let mut line = String::new();
2687 loop {
2688 line.clear();
2689 if reader.read_line(&mut line).unwrap() == 0 || line == "\r\n" {
2690 break;
2691 }
2692 }
2693 }
2694 write!(
2695 stream,
2696 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2697 body.len()
2698 )
2699 .unwrap();
2700 });
2701 let client = reqwest::Client::builder().build().unwrap();
2702 let result = fetch_crates_url(&client, &format!("http://{address}/crate")).await;
2703 server.join().unwrap();
2704 result
2705 }
2706
2707 assert_eq!(
2708 lookup("408 Request Timeout", "").await,
2709 RegistryLookup::TransientFailure
2710 );
2711 assert_eq!(
2712 lookup("429 Too Many Requests", "").await,
2713 RegistryLookup::TransientFailure
2714 );
2715 let malformed = lookup("200 OK", "{malformed-json").await;
2716 assert_eq!(malformed, RegistryLookup::TransientFailure);
2717 assert_eq!(
2718 super::lookup_with_cache_fallback(malformed, Some("9.9.9"), true),
2719 (Some("9.9.9".to_string()), true)
2720 );
2721 assert_eq!(lookup("404 Not Found", "").await, RegistryLookup::Missing);
2722 }
2723}