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"]);
508 Ok(command)
509}
510
511fn create_self_update_command_dir() -> Result<tempfile::TempDir, String> {
517 let configured_root =
518 dirs::home_dir().ok_or_else(|| "could not resolve the user home directory".to_string())?;
519 if !configured_root.is_absolute() {
520 return Err("user home must be absolute for self-update".to_string());
521 }
522 let canonical_root = std::fs::canonicalize(&configured_root)
523 .map(normalize_windows_path)
524 .map_err(|error| format!("could not canonicalize user home: {error}"))?;
525 let metadata = std::fs::symlink_metadata(&canonical_root)
526 .map_err(|error| format!("could not inspect user home: {error}"))?;
527 if !metadata.is_dir() || metadata.file_type().is_symlink() {
528 return Err("user home does not resolve to a real directory".to_string());
529 }
530 let current = std::env::current_dir()
531 .and_then(std::fs::canonicalize)
532 .map(normalize_windows_path)
533 .map_err(|error| format!("could not validate the caller directory: {error}"))?;
534 let project_root = current
535 .ancestors()
536 .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
537 .map(Path::to_path_buf);
538 validate_configured_cargo_home(project_root.as_deref())?;
539 if project_root.as_deref().is_some_and(|root| {
540 !windows_paths_equal(&canonical_root, root) && path_is_within(&canonical_root, root)
541 }) {
542 return Err(
543 "refusing to create a self-update command directory inside the caller project"
544 .to_string(),
545 );
546 }
547 let directory = tempfile::Builder::new()
548 .prefix("rpi-self-update-command-")
549 .tempdir_in(&canonical_root)
550 .map_err(|error| format!("could not allocate Cargo command directory: {error}"))?;
551 validated_self_update_command_dir(directory.path())?;
552 #[cfg(unix)]
553 {
554 use std::os::unix::fs::{MetadataExt, PermissionsExt};
555
556 let directory_metadata = std::fs::symlink_metadata(directory.path())
557 .map_err(|error| format!("could not inspect Cargo command directory: {error}"))?;
558 if metadata.uid() != directory_metadata.uid() || metadata.permissions().mode() & 0o022 != 0
559 {
560 return Err("user home is not private to the current account".to_string());
561 }
562 }
563 Ok(directory)
564}
565
566fn path_is_within(candidate: &Path, ancestor: &Path) -> bool {
567 candidate
568 .ancestors()
569 .any(|component| windows_paths_equal(component, ancestor))
570}
571
572fn validate_configured_cargo_home(project_root: Option<&Path>) -> Result<(), String> {
573 let Some(configured) = std::env::var_os("CARGO_HOME") else {
574 return Ok(());
575 };
576 let configured = PathBuf::from(configured);
577 if !configured.is_absolute() {
578 return Err("CARGO_HOME must be absolute for self-update".to_string());
579 }
580 let project_local = match project_root {
581 Some(root) => cargo_home_is_project_local(&configured, root)?,
582 None => {
583 canonicalize_allow_missing(&configured, "CARGO_HOME")?;
584 false
585 }
586 };
587 if project_local {
588 return Err("refusing project-local CARGO_HOME during self-update".to_string());
589 }
590 Ok(())
591}
592
593fn cargo_home_is_project_local(configured: &Path, project_root: &Path) -> Result<bool, String> {
594 if !configured.is_absolute() {
595 return Err("CARGO_HOME must be absolute for self-update".to_string());
596 }
597 let configured = canonicalize_allow_missing(configured, "CARGO_HOME")?;
598 Ok(path_is_within(&configured, project_root))
599}
600
601fn canonicalize_allow_missing(path: &Path, label: &str) -> Result<PathBuf, String> {
602 if path.components().any(|component| {
603 matches!(
604 component,
605 std::path::Component::CurDir | std::path::Component::ParentDir
606 )
607 }) {
608 return Err(format!("{label} contains relative path components"));
609 }
610 let mut existing = path;
611 let mut missing = Vec::new();
612 loop {
613 match std::fs::symlink_metadata(existing) {
614 Ok(_) => break,
615 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
616 let name = existing
617 .file_name()
618 .ok_or_else(|| format!("could not resolve {label}"))?;
619 missing.push(name.to_os_string());
620 existing = existing
621 .parent()
622 .ok_or_else(|| format!("could not resolve {label}"))?;
623 }
624 Err(error) => return Err(format!("could not inspect {label}: {error}")),
625 }
626 }
627 let mut canonical = std::fs::canonicalize(existing)
628 .map(normalize_windows_path)
629 .map_err(|error| format!("could not canonicalize {label}: {error}"))?;
630 for component in missing.into_iter().rev() {
631 canonical.push(component);
632 }
633 Ok(canonical)
634}
635
636fn validated_self_update_command_dir(path: &Path) -> Result<PathBuf, String> {
637 if !path.is_absolute() {
638 return Err("self-update command directory must be absolute".to_string());
639 }
640 let metadata = std::fs::symlink_metadata(path)
641 .map_err(|error| format!("could not inspect self-update command directory: {error}"))?;
642 if !metadata.is_dir() || metadata.file_type().is_symlink() {
643 return Err("self-update command directory is not a real directory".to_string());
644 }
645 let canonical = std::fs::canonicalize(path)
646 .map(normalize_windows_path)
647 .map_err(|error| {
648 format!("could not canonicalize self-update command directory: {error}")
649 })?;
650 if !windows_paths_equal(&canonical, path) {
651 return Err(
652 "self-update command directory resolves through a link or junction".to_string(),
653 );
654 }
655 Ok(canonical)
656}
657
658#[cfg(not(windows))]
659#[derive(Debug, Clone)]
660struct DirectUpdatePlan {
661 staging_dir: PathBuf,
662 staged_exe: PathBuf,
663 target_exe: PathBuf,
664 replacement_temp: PathBuf,
665 backup_file: PathBuf,
666}
667
668#[cfg(not(windows))]
669fn run_direct_self_update() -> i32 {
670 let staging = match create_self_update_staging_directory() {
671 Ok(directory) => directory,
672 Err(error) => {
673 eprintln!("error: could not create self-update staging directory: {error}");
674 return 1;
675 }
676 };
677 let command_dir = match create_self_update_command_dir() {
678 Ok(directory) => directory,
679 Err(error) => {
680 eprintln!("error: could not create an isolated self-update directory: {error}");
681 return 1;
682 }
683 };
684 let mut command = match cargo_install_command(Some(staging.path()), command_dir.path()) {
685 Ok(command) => command,
686 Err(error) => {
687 eprintln!("error: invalid isolated self-update directory: {error}");
688 return 1;
689 }
690 };
691 let status = command.status();
692 match status {
693 Ok(status) if status.success() => {}
694 Ok(status) => {
695 eprintln!("error: cargo install exited with {status}");
696 return 1;
697 }
698 Err(error) => {
699 eprintln!("error: could not run cargo (install Rust/Cargo first): {error}");
700 return 1;
701 }
702 }
703
704 let current_exe = match std::env::current_exe() {
705 Ok(path) => path,
706 Err(error) => {
707 eprintln!("error: could not resolve the current rpi executable: {error}");
708 return 1;
709 }
710 };
711 let plan = match build_direct_update_plan(staging.path(), ¤t_exe) {
712 Ok(plan) => plan,
713 Err(error) => {
714 eprintln!("error: {error}");
715 return 1;
716 }
717 };
718 match activate_direct_update(&plan) {
719 Ok(version) => {
720 println!("rpi updated successfully to {version}");
721 0
722 }
723 Err(error) => {
724 eprintln!("error: {error}");
725 1
726 }
727 }
728}
729
730fn create_self_update_staging_directory() -> Result<tempfile::TempDir, String> {
731 let agent = crate::config::agent_dir().map_err(|error| error.to_string())?;
732 if !agent.is_absolute() {
733 return Err("agent directory must be absolute".to_string());
734 }
735 std::fs::create_dir_all(&agent)
736 .map_err(|error| format!("could not create {}: {error}", agent.display()))?;
737 let agent = validated_real_directory(&agent, "agent directory")?;
738 let update_root = agent.join("self-update");
739 std::fs::create_dir_all(&update_root)
740 .map_err(|error| format!("could not create {}: {error}", update_root.display()))?;
741 let update_root = validated_real_directory(&update_root, "self-update root")?;
742 let staging = tempfile::Builder::new()
743 .prefix("pending-")
744 .tempdir_in(&update_root)
745 .map_err(|error| format!("could not allocate staging directory: {error}"))?;
746 validated_real_directory(staging.path(), "self-update staging directory")?;
747 Ok(staging)
748}
749
750#[cfg(not(windows))]
751fn build_direct_update_plan(
752 staging_dir: &Path,
753 target_exe: &Path,
754) -> Result<DirectUpdatePlan, String> {
755 let staging_dir = validated_real_directory(staging_dir, "self-update staging directory")?;
756 let staged_exe = validated_regular_file(
757 &staging_dir.join("bin").join(crate::APP_NAME),
758 "staged rpi executable",
759 )?;
760 let target_exe = validated_regular_file(target_exe, "current rpi executable")?;
761 let target_dir = validated_real_directory(
762 target_exe
763 .parent()
764 .ok_or_else(|| "current executable has no parent directory".to_string())?,
765 "current executable directory",
766 )?;
767 if target_exe.parent() != Some(target_dir.as_path()) {
768 return Err("current executable is not a direct child of its validated directory".into());
769 }
770 let token = uuid::Uuid::new_v4().simple().to_string();
771 let replacement_temp = target_dir.join(format!(".rpi-update-{token}.new"));
772 let backup_file = target_dir.join(format!(".rpi-update-{token}.old"));
773 if replacement_temp.exists() || backup_file.exists() {
774 return Err("self-update replacement path collision".to_string());
775 }
776 Ok(DirectUpdatePlan {
777 staging_dir,
778 staged_exe,
779 target_exe,
780 replacement_temp,
781 backup_file,
782 })
783}
784
785#[cfg(not(windows))]
786fn activate_direct_update(plan: &DirectUpdatePlan) -> Result<semver::Version, String> {
787 let staged_version = validate_rpi_executable_version(
788 &plan.staged_exe,
789 &plan.staging_dir,
790 crate::VERSION,
791 "staged rpi",
792 )?;
793 preflight_direct_replacement(plan)?;
794
795 let result = (|| {
796 std::fs::copy(&plan.staged_exe, &plan.replacement_temp).map_err(|error| {
797 format!("could not copy staged rpi beside the current executable: {error}")
798 })?;
799 validated_regular_file(&plan.replacement_temp, "replacement rpi executable")?;
800 let copied_version = validate_rpi_executable_version(
801 &plan.replacement_temp,
802 plan.target_exe
803 .parent()
804 .ok_or_else(|| "current executable has no parent directory".to_string())?,
805 crate::VERSION,
806 "replacement rpi",
807 )?;
808 if copied_version != staged_version
809 || !files_are_identical(&plan.staged_exe, &plan.replacement_temp)?
810 {
811 return Err(
812 "replacement rpi does not match the validated staged executable".to_string(),
813 );
814 }
815
816 std::fs::hard_link(&plan.target_exe, &plan.backup_file)
817 .map_err(|error| format!("could not preserve the current rpi executable: {error}"))?;
818 let installed_version = validate_rpi_executable_version(
819 &plan.backup_file,
820 plan.target_exe
821 .parent()
822 .ok_or_else(|| "current executable has no parent directory".to_string())?,
823 crate::VERSION,
824 "current rpi",
825 )?;
826 if staged_version < installed_version {
827 return Err(format!(
828 "refusing to replace rpi {installed_version} with older version {staged_version}"
829 ));
830 }
831 if !files_are_identical(&plan.target_exe, &plan.backup_file)? {
832 return Err("current rpi executable changed while preparing the update".to_string());
833 }
834
835 std::fs::rename(&plan.replacement_temp, &plan.target_exe).map_err(|error| {
836 format!("could not atomically replace the current rpi executable: {error}")
837 })?;
838 Ok(())
839 })();
840
841 if let Err(error) = result {
842 let _ = remove_file_if_exists(&plan.replacement_temp);
843 let _ = remove_file_if_exists(&plan.backup_file);
844 return Err(error);
845 }
846
847 let validation = (|| {
848 if !files_are_identical(&plan.staged_exe, &plan.target_exe)? {
849 return Err("updated rpi executable does not match the staged executable".to_string());
850 }
851 let updated_version = validate_rpi_executable_version(
852 &plan.target_exe,
853 plan.target_exe
854 .parent()
855 .ok_or_else(|| "current executable has no parent directory".to_string())?,
856 crate::VERSION,
857 "updated rpi",
858 )?;
859 if updated_version != staged_version {
860 return Err(format!(
861 "updated rpi reported version {updated_version}, expected {staged_version}"
862 ));
863 }
864 Ok(())
865 })();
866
867 if let Err(error) = validation {
868 return Err(rollback_direct_update(plan, &error));
869 }
870 let _ = remove_file_if_exists(&plan.backup_file);
871 Ok(staged_version)
872}
873
874#[cfg(not(windows))]
875fn preflight_direct_replacement(plan: &DirectUpdatePlan) -> Result<(), String> {
876 for path in [&plan.replacement_temp, &plan.backup_file] {
877 if path.exists() {
878 return Err(format!(
879 "self-update path already exists: {}",
880 path.display()
881 ));
882 }
883 let file = std::fs::OpenOptions::new()
884 .write(true)
885 .create_new(true)
886 .open(path)
887 .map_err(|error| format!("cannot write beside the current executable: {error}"))?;
888 drop(file);
889 std::fs::remove_file(path)
890 .map_err(|error| format!("could not remove self-update probe: {error}"))?;
891 }
892 Ok(())
893}
894
895#[cfg(not(windows))]
896fn rollback_direct_update(plan: &DirectUpdatePlan, failure: &str) -> String {
897 let _ = remove_file_if_exists(&plan.replacement_temp);
898 match std::fs::rename(&plan.backup_file, &plan.target_exe) {
899 Ok(()) => format!("{failure}; restored the previous rpi executable"),
900 Err(rollback) => format!(
901 "{failure}; automatic rollback failed ({rollback}); the previous executable remains at {}",
902 plan.backup_file.display()
903 ),
904 }
905}
906
907#[cfg(not(windows))]
908fn remove_file_if_exists(path: &Path) -> Result<(), String> {
909 match std::fs::remove_file(path) {
910 Ok(()) => Ok(()),
911 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
912 Err(error) => Err(error.to_string()),
913 }
914}
915
916#[cfg(not(windows))]
917fn files_are_identical(left: &Path, right: &Path) -> Result<bool, String> {
918 use std::io::{BufReader, Read};
919
920 let left_file = std::fs::File::open(left)
921 .map_err(|error| format!("could not open {}: {error}", left.display()))?;
922 let right_file = std::fs::File::open(right)
923 .map_err(|error| format!("could not open {}: {error}", right.display()))?;
924 if left_file
925 .metadata()
926 .map_err(|error| error.to_string())?
927 .len()
928 != right_file
929 .metadata()
930 .map_err(|error| error.to_string())?
931 .len()
932 {
933 return Ok(false);
934 }
935 let mut left = BufReader::new(left_file);
936 let mut right = BufReader::new(right_file);
937 let mut left_buffer = [0u8; 64 * 1024];
938 let mut right_buffer = [0u8; 64 * 1024];
939 loop {
940 let left_read = left
941 .read(&mut left_buffer)
942 .map_err(|error| error.to_string())?;
943 let right_read = right
944 .read(&mut right_buffer)
945 .map_err(|error| error.to_string())?;
946 if left_read != right_read || left_buffer[..left_read] != right_buffer[..right_read] {
947 return Ok(false);
948 }
949 if left_read == 0 {
950 return Ok(true);
951 }
952 }
953}
954
955#[cfg(windows)]
956#[derive(Debug, Clone)]
957struct WindowsUpdatePlan {
958 staging_dir: PathBuf,
959 staged_exe: PathBuf,
960 target_exe: PathBuf,
961 helper_script: PathBuf,
962 status_file: PathBuf,
963 replacement_temp: PathBuf,
964 backup_file: PathBuf,
965 rollback_displaced_file: PathBuf,
966}
967
968#[cfg(windows)]
969struct WindowsUpdateStaging {
970 directory: tempfile::TempDir,
971 status_file: PathBuf,
972}
973
974#[cfg(windows)]
975const WINDOWS_UPDATE_HELPER: &str = r#"param(
976 [Parameter(Mandatory = $true)][long] $ParentPid,
977 [Parameter(Mandatory = $true)][string] $StagingPath,
978 [Parameter(Mandatory = $true)][string] $StagedPath,
979 [Parameter(Mandatory = $true)][string] $StagedVersion,
980 [Parameter(Mandatory = $true)][string] $TargetPath,
981 [Parameter(Mandatory = $true)][string] $TempPath,
982 [Parameter(Mandatory = $true)][string] $BackupPath,
983 [Parameter(Mandatory = $true)][string] $RollbackDisplacedPath,
984 [Parameter(Mandatory = $true)][string] $StatusPath
985)
986
987Set-StrictMode -Version Latest
988$ErrorActionPreference = 'Stop'
989
990function Write-UpdateStatus {
991 param([string] $State, [string] $Message)
992 $record = [ordered]@{
993 state = $State
994 message = $Message
995 parentPid = $ParentPid
996 staging = $StagingPath
997 target = $TargetPath
998 staged = $StagedPath
999 stagedVersion = $StagedVersion
1000 backup = $BackupPath
1001 rollbackDisplaced = $RollbackDisplacedPath
1002 updatedAt = [DateTime]::UtcNow.ToString('o')
1003 }
1004 $json = $record | ConvertTo-Json -Compress
1005 $statusTemp = $StatusPath + '.tmp'
1006 [IO.File]::WriteAllText($statusTemp, $json, [Text.UTF8Encoding]::new($false))
1007 Move-Item -LiteralPath $statusTemp -Destination $StatusPath -Force
1008}
1009
1010function Assert-RegularFile {
1011 param([string] $Path, [string] $Label)
1012 $item = Get-Item -LiteralPath $Path -Force
1013 if ($item.PSIsContainer -or $item.Length -le 0 -or
1014 (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
1015 throw "$Label is not a non-empty regular file"
1016 }
1017}
1018
1019function Assert-WindowsExecutable {
1020 param([string] $Path, [string] $Label)
1021 Assert-RegularFile -Path $Path -Label $Label
1022 $stream = [IO.File]::OpenRead($Path)
1023 try {
1024 if ($stream.ReadByte() -ne 77 -or $stream.ReadByte() -ne 90) {
1025 throw "$Label is not a Windows executable"
1026 }
1027 } finally {
1028 $stream.Dispose()
1029 }
1030}
1031
1032function Assert-RealDirectory {
1033 param([string] $Path, [string] $Label)
1034 $item = Get-Item -LiteralPath $Path -Force
1035 if (-not $item.PSIsContainer -or
1036 (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
1037 throw "$Label is not a real directory"
1038 }
1039}
1040
1041function Get-FileSha256 {
1042 param([string] $Path)
1043 $stream = [IO.File]::OpenRead($Path)
1044 try {
1045 $sha256 = [Security.Cryptography.SHA256]::Create()
1046 try {
1047 return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '')
1048 } finally {
1049 $sha256.Dispose()
1050 }
1051 } finally {
1052 $stream.Dispose()
1053 }
1054}
1055
1056function Remove-StagingPayload {
1057 try {
1058 $root = [IO.Path]::GetFullPath($StagingPath)
1059 Assert-RealDirectory -Path $root -Label 'self-update staging directory'
1060 $bin = [IO.Path]::Combine($root, 'bin')
1061 $helper = [IO.Path]::Combine($root, 'apply-update.ps1')
1062 foreach ($file in @(
1063 $StagedPath,
1064 [IO.Path]::Combine($root, '.crates.toml'),
1065 [IO.Path]::Combine($root, '.crates2.json'),
1066 $helper
1067 )) {
1068 if ([IO.File]::Exists($file)) {
1069 [IO.File]::Delete($file)
1070 }
1071 }
1072 if ([IO.Directory]::Exists($bin) -and
1073 [IO.Directory]::GetFileSystemEntries($bin).Length -eq 0) {
1074 [IO.Directory]::Delete($bin, $false)
1075 }
1076 if ([IO.Directory]::Exists($root) -and
1077 [IO.Directory]::GetFileSystemEntries($root).Length -eq 0) {
1078 [IO.Directory]::Delete($root, $false)
1079 }
1080 } catch {
1081 # Cleanup is best-effort; update status and recovery files stay intact.
1082 }
1083}
1084
1085$replaced = $false
1086try {
1087 $stagingFull = [IO.Path]::GetFullPath($StagingPath)
1088 $stagedFull = [IO.Path]::GetFullPath($StagedPath)
1089 $targetFull = [IO.Path]::GetFullPath($TargetPath)
1090 $tempFull = [IO.Path]::GetFullPath($TempPath)
1091 $backupFull = [IO.Path]::GetFullPath($BackupPath)
1092 $rollbackDisplacedFull = [IO.Path]::GetFullPath($RollbackDisplacedPath)
1093 $helperFull = [IO.Path]::Combine($stagingFull, 'apply-update.ps1')
1094 $statusFull = [IO.Path]::GetFullPath($StatusPath)
1095 $targetDirectory = [IO.Path]::GetDirectoryName($targetFull)
1096 if (-not [StringComparer]::OrdinalIgnoreCase.Equals(
1097 [IO.Path]::GetDirectoryName([IO.Path]::GetDirectoryName($stagedFull)), $stagingFull) -or
1098 -not [StringComparer]::OrdinalIgnoreCase.Equals(
1099 [IO.Path]::GetDirectoryName($helperFull), $stagingFull) -or
1100 -not [StringComparer]::OrdinalIgnoreCase.Equals(
1101 [IO.Path]::GetFullPath($PSScriptRoot), $stagingFull) -or
1102 -not [StringComparer]::OrdinalIgnoreCase.Equals(
1103 [IO.Path]::GetDirectoryName($statusFull), [IO.Path]::GetDirectoryName($stagingFull)) -or
1104 [string]::IsNullOrWhiteSpace($targetDirectory) -or
1105 -not [StringComparer]::OrdinalIgnoreCase.Equals(
1106 [IO.Path]::GetDirectoryName($tempFull), $targetDirectory) -or
1107 -not [StringComparer]::OrdinalIgnoreCase.Equals(
1108 [IO.Path]::GetDirectoryName($backupFull), $targetDirectory) -or
1109 -not [StringComparer]::OrdinalIgnoreCase.Equals(
1110 [IO.Path]::GetDirectoryName($rollbackDisplacedFull), $targetDirectory)) {
1111 throw 'self-update paths are outside their validated staging or target directories'
1112 }
1113
1114 Assert-RealDirectory -Path $stagingFull -Label 'self-update staging directory'
1115 Assert-RealDirectory -Path $targetDirectory -Label 'target directory'
1116 Assert-WindowsExecutable -Path $stagedFull -Label 'staged executable'
1117 Assert-WindowsExecutable -Path $targetFull -Label 'current executable'
1118 $expectedHash = Get-FileSha256 -Path $stagedFull
1119 $originalTargetHash = Get-FileSha256 -Path $targetFull
1120 if ([IO.File]::Exists($tempFull) -or [IO.File]::Exists($backupFull) -or
1121 [IO.File]::Exists($rollbackDisplacedFull)) {
1122 throw 'replacement, backup, or rollback path already exists'
1123 }
1124
1125 Write-UpdateStatus -State 'waiting' -Message 'waiting for the current rpi process to exit'
1126 if ($ParentPid -lt 1 -or $ParentPid -gt [int]::MaxValue) {
1127 throw 'parent process id is outside the supported Windows range'
1128 }
1129 $parentProcess = $null
1130 try {
1131 $candidate = [Diagnostics.Process]::GetProcessById([int]$ParentPid)
1132 try {
1133 $candidatePath = [IO.Path]::GetFullPath($candidate.MainModule.FileName)
1134 if ([StringComparer]::OrdinalIgnoreCase.Equals($candidatePath, $targetFull)) {
1135 $parentProcess = $candidate
1136 } else {
1137 $candidate.Dispose()
1138 }
1139 } catch {
1140 if ($candidate.HasExited) {
1141 $candidate.Dispose()
1142 } else {
1143 throw
1144 }
1145 }
1146 } catch [ArgumentException] {
1147 # The invoking process exited before the helper acquired its handle.
1148 }
1149 if ($null -ne $parentProcess) {
1150 try {
1151 if (-not $parentProcess.WaitForExit(600000)) {
1152 throw 'timed out waiting for the current rpi process to exit'
1153 }
1154 } finally {
1155 $parentProcess.Dispose()
1156 }
1157 }
1158
1159 Assert-RealDirectory -Path $targetDirectory -Label 'target directory'
1160 Assert-WindowsExecutable -Path $stagedFull -Label 'staged executable'
1161 Assert-WindowsExecutable -Path $targetFull -Label 'current executable'
1162 if ((Get-FileSha256 -Path $stagedFull) -ne $expectedHash) {
1163 throw 'staged executable changed while waiting for rpi to exit'
1164 }
1165 if ((Get-FileSha256 -Path $targetFull) -ne $originalTargetHash) {
1166 throw 'current executable changed while waiting for rpi to exit'
1167 }
1168 if ([IO.File]::Exists($tempFull) -or [IO.File]::Exists($backupFull) -or
1169 [IO.File]::Exists($rollbackDisplacedFull)) {
1170 throw 'replacement, backup, or rollback path appeared while waiting for rpi to exit'
1171 }
1172
1173 [IO.File]::Copy($stagedFull, $tempFull, $false)
1174 Assert-WindowsExecutable -Path $tempFull -Label 'replacement executable'
1175 if ((Get-FileSha256 -Path $tempFull) -ne $expectedHash) {
1176 throw 'replacement executable hash mismatch'
1177 }
1178
1179 [IO.File]::Replace($tempFull, $targetFull, $backupFull, $true)
1180 $replaced = $true
1181 Assert-WindowsExecutable -Path $targetFull -Label 'updated executable'
1182 if ((Get-FileSha256 -Path $targetFull) -ne $expectedHash) {
1183 throw 'updated executable hash mismatch'
1184 }
1185} catch {
1186 $failureMessage = $_.Exception.Message
1187 $recoveryMessage = ''
1188 if ($replaced -and [IO.File]::Exists($BackupPath)) {
1189 try {
1190 [IO.File]::Copy($BackupPath, $TempPath, $false)
1191 Assert-WindowsExecutable -Path $TempPath -Label 'rollback executable'
1192 if ((Get-FileSha256 -Path $TempPath) -ne $originalTargetHash) {
1193 throw 'rollback executable hash mismatch'
1194 }
1195 [IO.File]::Replace($TempPath, $TargetPath, $RollbackDisplacedPath, $true)
1196 Assert-WindowsExecutable -Path $TargetPath -Label 'restored executable'
1197 if ((Get-FileSha256 -Path $TargetPath) -ne $originalTargetHash) {
1198 throw 'restored executable hash mismatch'
1199 }
1200 $replaced = $false
1201 try { [IO.File]::Delete($BackupPath) } catch {}
1202 try { [IO.File]::Delete($RollbackDisplacedPath) } catch {}
1203 } catch {
1204 $rollbackFailure = $_.Exception.Message
1205 if ([IO.File]::Exists($BackupPath)) {
1206 $recoveryMessage = "; automatic rollback failed ($rollbackFailure); the original executable remains at $BackupPath"
1207 } else {
1208 $recoveryMessage = "; automatic rollback failed ($rollbackFailure) and no verified recovery backup remains"
1209 }
1210 }
1211 }
1212 if ([IO.File]::Exists($TempPath)) {
1213 try { Remove-Item -LiteralPath $TempPath -Force } catch {}
1214 }
1215 try { Write-UpdateStatus -State 'failed' -Message ($failureMessage + $recoveryMessage) } catch {}
1216 Remove-StagingPayload
1217 exit 1
1218}
1219
1220try { Write-UpdateStatus -State 'succeeded' -Message 'rpi executable replaced successfully' } catch {}
1221if ([IO.File]::Exists($BackupPath)) {
1222 try { Remove-Item -LiteralPath $BackupPath -Force } catch {}
1223}
1224Remove-StagingPayload
1225exit 0
1226"#;
1227
1228#[cfg(windows)]
1229fn run_windows_self_update() -> i32 {
1230 let staging = match create_windows_update_staging() {
1231 Ok(staging) => staging,
1232 Err(error) => {
1233 eprintln!("error: could not create self-update staging directory: {error}");
1234 return 1;
1235 }
1236 };
1237 let staging_dir = staging.directory.path().to_path_buf();
1238 let status_file = staging.status_file.clone();
1239 let _ = write_windows_update_status(
1240 &status_file,
1241 "preparing",
1242 "installing the update into staging",
1243 );
1244
1245 let current_exe = match std::env::current_exe() {
1246 Ok(path) => path,
1247 Err(error) => {
1248 let message = format!("could not resolve the current rpi executable: {error}");
1249 let _ = write_windows_update_status(&status_file, "failed", &message);
1250 eprintln!("error: {message}");
1251 return 1;
1252 }
1253 };
1254 let powershell = match system_powershell_path() {
1255 Ok(path) => path,
1256 Err(error) => {
1257 let _ = write_windows_update_status(&status_file, "failed", &error);
1258 eprintln!("error: {error}");
1259 return 1;
1260 }
1261 };
1262
1263 let command_dir = match create_self_update_command_dir() {
1264 Ok(directory) => directory,
1265 Err(error) => {
1266 let message = format!("could not create an isolated Cargo directory: {error}");
1267 let _ = write_windows_update_status(&status_file, "failed", &message);
1268 eprintln!("error: {message}");
1269 return 1;
1270 }
1271 };
1272 let mut cargo = match cargo_install_command(Some(&staging_dir), command_dir.path()) {
1273 Ok(command) => command,
1274 Err(error) => {
1275 let message = format!("invalid isolated Cargo directory: {error}");
1276 let _ = write_windows_update_status(&status_file, "failed", &message);
1277 eprintln!("error: {message}");
1278 return 1;
1279 }
1280 };
1281 let status = cargo.status();
1282 match status {
1283 Ok(status) if status.success() => {}
1284 Ok(status) => {
1285 let message = format!("cargo install exited with {status}");
1286 let _ = write_windows_update_status(&status_file, "failed", &message);
1287 eprintln!("error: {message}");
1288 return 1;
1289 }
1290 Err(error) => {
1291 let message = format!("could not run cargo (install Rust/Cargo first): {error}");
1292 let _ = write_windows_update_status(&status_file, "failed", &message);
1293 eprintln!("error: {message}");
1294 return 1;
1295 }
1296 }
1297
1298 let plan = match build_windows_update_plan(&staging_dir, ¤t_exe, &status_file) {
1299 Ok(plan) => plan,
1300 Err(error) => {
1301 let _ = write_windows_update_status(&status_file, "failed", &error);
1302 eprintln!("error: {error}");
1303 return 1;
1304 }
1305 };
1306 let staged_version = match validate_staged_rpi(&plan) {
1307 Ok(version) => version,
1308 Err(error) => {
1309 let _ = write_windows_update_status(&plan.status_file, "failed", &error);
1310 eprintln!("error: {error}");
1311 return 1;
1312 }
1313 };
1314 if let Err(error) = preflight_windows_replacement(&plan) {
1315 let _ = write_windows_update_status(&plan.status_file, "failed", &error);
1316 eprintln!("error: {error}");
1317 return 1;
1318 }
1319 if let Err(error) = std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER.as_bytes()) {
1320 let message = format!("could not write the self-update helper: {error}");
1321 let _ = write_windows_update_status(&plan.status_file, "failed", &message);
1322 eprintln!("error: {message}");
1323 return 1;
1324 }
1325 if validated_regular_file(&plan.helper_script, "self-update helper").is_err() {
1326 let message = "self-update helper did not pass regular-file validation";
1327 let _ = write_windows_update_status(&plan.status_file, "failed", message);
1328 eprintln!("error: {message}");
1329 return 1;
1330 }
1331 let _ = write_windows_update_status(
1332 &plan.status_file,
1333 "scheduled",
1334 &format!("validated rpi {staged_version}; waiting to replace rpi after this process exits"),
1335 );
1336 let mut command = windows_update_helper_command(
1337 &powershell,
1338 &plan,
1339 std::process::id(),
1340 &staged_version.to_string(),
1341 );
1342 use std::os::windows::process::CommandExt;
1343 command.creation_flags(0x0800_0000); match command.spawn() {
1345 Ok(_) => {
1346 let persisted_staging = staging.directory.keep();
1347 println!(
1348 "rpi update staged; it will be applied after this process exits\nStatus: {}",
1349 plan.status_file.display()
1350 );
1351 debug_assert!(windows_paths_equal(&persisted_staging, &plan.staging_dir));
1352 0
1353 }
1354 Err(error) => {
1355 let message = format!("could not start the self-update helper: {error}");
1356 let _ = write_windows_update_status(&plan.status_file, "failed", &message);
1357 eprintln!("error: {message}");
1358 1
1359 }
1360 }
1361}
1362
1363#[cfg(windows)]
1364fn create_windows_update_staging() -> Result<WindowsUpdateStaging, String> {
1365 let staging = create_self_update_staging_directory()?;
1366 let update_root = staging
1367 .path()
1368 .parent()
1369 .ok_or_else(|| "self-update staging directory has no parent".to_string())?;
1370 let status_file = update_root.join(format!("status-{}.json", uuid::Uuid::new_v4().simple()));
1371 if status_file.exists() {
1372 return Err("self-update status path collision".to_string());
1373 }
1374 Ok(WindowsUpdateStaging {
1375 directory: staging,
1376 status_file,
1377 })
1378}
1379
1380#[cfg(windows)]
1381fn build_windows_update_plan(
1382 staging_dir: &Path,
1383 target_exe: &Path,
1384 status_file: &Path,
1385) -> Result<WindowsUpdatePlan, String> {
1386 let staging_dir = validated_real_directory(staging_dir, "self-update staging directory")?;
1387 if !status_file.is_absolute() {
1388 return Err("self-update status path must be absolute".to_string());
1389 }
1390 let status_parent = validated_real_directory(
1391 status_file
1392 .parent()
1393 .ok_or_else(|| "self-update status path has no parent".to_string())?,
1394 "self-update status directory",
1395 )?;
1396 if staging_dir.parent() != Some(status_parent.as_path()) {
1397 return Err("self-update status file is outside the staging parent".to_string());
1398 }
1399 let status_name = status_file
1400 .file_name()
1401 .ok_or_else(|| "self-update status file has no name".to_string())?;
1402 if !is_self_update_status_name(status_name) {
1403 return Err("self-update status file has an invalid name".to_string());
1404 }
1405 if status_file.exists() {
1406 validated_regular_file(status_file, "self-update status file")?;
1407 }
1408 let staged_exe = validated_windows_executable(
1409 &staging_dir.join("bin").join("rpi.exe"),
1410 "staged rpi executable",
1411 )?;
1412 let target_exe = validated_windows_executable(target_exe, "current rpi executable")?;
1413 let target_dir = validated_real_directory(
1414 target_exe
1415 .parent()
1416 .ok_or_else(|| "current executable has no parent directory".to_string())?,
1417 "current executable directory",
1418 )?;
1419 if target_exe.parent() != Some(target_dir.as_path()) {
1420 return Err("current executable is not a direct child of its validated directory".into());
1421 }
1422 let token = uuid::Uuid::new_v4().simple().to_string();
1423 let replacement_temp = target_dir.join(format!(".rpi-update-{token}.new.exe"));
1424 let backup_file = target_dir.join(format!(".rpi-update-{token}.old.exe"));
1425 let rollback_displaced_file = target_dir.join(format!(".rpi-update-{token}.rollback-new.exe"));
1426 if replacement_temp.exists() || backup_file.exists() || rollback_displaced_file.exists() {
1427 return Err("self-update replacement path collision".to_string());
1428 }
1429
1430 Ok(WindowsUpdatePlan {
1431 staging_dir: staging_dir.clone(),
1432 staged_exe,
1433 target_exe,
1434 helper_script: staging_dir.join("apply-update.ps1"),
1435 status_file: status_file.to_path_buf(),
1436 replacement_temp,
1437 backup_file,
1438 rollback_displaced_file,
1439 })
1440}
1441
1442#[cfg(windows)]
1443fn preflight_windows_replacement(plan: &WindowsUpdatePlan) -> Result<(), String> {
1444 for path in [
1445 &plan.replacement_temp,
1446 &plan.backup_file,
1447 &plan.rollback_displaced_file,
1448 ] {
1449 if path.exists() {
1450 return Err(format!(
1451 "self-update path already exists: {}",
1452 path.display()
1453 ));
1454 }
1455 let file = std::fs::OpenOptions::new()
1456 .write(true)
1457 .create_new(true)
1458 .open(path)
1459 .map_err(|error| format!("cannot write beside the current executable: {error}"))?;
1460 drop(file);
1461 std::fs::remove_file(path)
1462 .map_err(|error| format!("could not remove self-update probe: {error}"))?;
1463 }
1464 Ok(())
1465}
1466
1467#[cfg(windows)]
1468fn validate_staged_rpi(plan: &WindowsUpdatePlan) -> Result<semver::Version, String> {
1469 let cwd = plan
1470 .helper_script
1471 .parent()
1472 .ok_or_else(|| "self-update staging directory is missing".to_string())?;
1473 validate_rpi_executable_version(&plan.staged_exe, cwd, crate::VERSION, "staged rpi")
1474}
1475
1476struct BoundedProcessOutput {
1478 status: std::process::ExitStatus,
1479 stdout: Vec<u8>,
1480 stderr: Vec<u8>,
1481}
1482
1483fn run_bounded_process_blocking(
1490 program: &str,
1491 args: &[String],
1492 cwd: &Path,
1493 timeout: std::time::Duration,
1494 max_output_bytes: usize,
1495) -> Result<BoundedProcessOutput, String> {
1496 let program = program.to_string();
1497 let args = args.to_vec();
1498 let cwd = cwd.to_path_buf();
1499 std::thread::Builder::new()
1500 .name("rpi-bounded-version-probe".to_string())
1501 .spawn(move || {
1502 let runtime = tokio::runtime::Builder::new_current_thread()
1503 .enable_all()
1504 .build()
1505 .map_err(|error| format!("probe runtime failed: {error}"))?;
1506 runtime.block_on(async move {
1507 let child = tokio::process::Command::new(&program)
1508 .args(&args)
1509 .current_dir(&cwd)
1510 .stdin(std::process::Stdio::null())
1511 .stdout(std::process::Stdio::piped())
1512 .stderr(std::process::Stdio::piped())
1513 .kill_on_drop(true)
1514 .spawn()
1515 .map_err(|error| format!("spawn failed: {error}"))?;
1516 let output = match tokio::time::timeout(timeout, child.wait_with_output()).await {
1517 Ok(result) => result.map_err(|error| format!("wait failed: {error}"))?,
1518 Err(_) => return Err(format!("timed out after {timeout:?}")),
1521 };
1522 Ok(BoundedProcessOutput {
1523 status: output.status,
1524 stdout: cap_bytes(output.stdout, max_output_bytes),
1525 stderr: cap_bytes(output.stderr, max_output_bytes),
1526 })
1527 })
1528 })
1529 .map_err(|error| format!("probe thread failed: {error}"))?
1530 .join()
1531 .map_err(|_| "probe worker panicked".to_string())?
1532}
1533
1534fn cap_bytes(bytes: Vec<u8>, limit: usize) -> Vec<u8> {
1535 if bytes.len() <= limit {
1536 bytes
1537 } else {
1538 bytes[..limit].to_vec()
1539 }
1540}
1541
1542fn validate_rpi_executable_version(
1543 executable: &Path,
1544 cwd: &Path,
1545 current: &str,
1546 label: &str,
1547) -> Result<semver::Version, String> {
1548 let program = executable
1549 .to_str()
1550 .ok_or_else(|| format!("{label} path cannot be represented as Unicode"))?;
1551 let args = vec!["--version".to_string()];
1552 let output = run_bounded_process_blocking(
1553 program,
1554 &args,
1555 cwd,
1556 STAGED_RPI_CHECK_TIMEOUT,
1557 MAX_STAGED_RPI_OUTPUT_BYTES,
1558 )
1559 .map_err(|error| format!("{label} version check failed: {error}"))?;
1560 if !output.status.success() {
1561 return Err(format!("{label} --version exited with {}", output.status));
1562 }
1563 validate_staged_rpi_version_output(&output.stdout, &output.stderr, current)
1564}
1565
1566fn validate_staged_rpi_version_output(
1567 stdout: &[u8],
1568 stderr: &[u8],
1569 current: &str,
1570) -> Result<semver::Version, String> {
1571 if !stderr.is_empty() {
1572 return Err("staged rpi --version wrote unexpected stderr output".to_string());
1573 }
1574 let staged = parse_rpi_version_output(stdout)
1575 .ok_or_else(|| "staged rpi returned an invalid --version response".to_string())?;
1576 let current = semver::Version::parse(current)
1577 .map_err(|_| "the running rpi version is not valid semantic versioning".to_string())?;
1578 if staged < current {
1579 return Err(format!(
1580 "refusing to replace rpi {current} with older version {staged}"
1581 ));
1582 }
1583 Ok(staged)
1584}
1585
1586fn parse_rpi_version_output(output: &[u8]) -> Option<semver::Version> {
1587 let output = std::str::from_utf8(output).ok()?;
1588 let line = output
1589 .strip_suffix("\r\n")
1590 .or_else(|| output.strip_suffix('\n'))
1591 .unwrap_or(output);
1592 if line.contains(['\r', '\n']) {
1593 return None;
1594 }
1595 let version = line.strip_prefix(crate::APP_NAME)?.strip_prefix(' ')?;
1596 if version.is_empty() || version.chars().any(char::is_whitespace) {
1597 return None;
1598 }
1599 semver::Version::parse(version).ok()
1600}
1601
1602fn validated_real_directory(path: &Path, label: &str) -> Result<PathBuf, String> {
1603 if !path.is_absolute() {
1604 return Err(format!("{label} must be absolute"));
1605 }
1606 let metadata = std::fs::symlink_metadata(path)
1607 .map_err(|error| format!("could not inspect {label}: {error}"))?;
1608 if !metadata.is_dir() || metadata.file_type().is_symlink() {
1609 return Err(format!("{label} is not a real directory"));
1610 }
1611 let canonical = std::fs::canonicalize(path)
1612 .map(normalize_windows_path)
1613 .map_err(|error| format!("could not canonicalize {label}: {error}"))?;
1614 if !windows_paths_equal(&canonical, path) {
1615 return Err(format!("{label} resolves through a link or junction"));
1616 }
1617 Ok(canonical)
1618}
1619
1620fn validated_regular_file(path: &Path, label: &str) -> Result<PathBuf, String> {
1621 if !path.is_absolute() {
1622 return Err(format!("{label} must be absolute"));
1623 }
1624 let metadata = std::fs::symlink_metadata(path)
1625 .map_err(|error| format!("could not inspect {label}: {error}"))?;
1626 if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() == 0 {
1627 return Err(format!("{label} is not a non-empty regular file"));
1628 }
1629 let canonical = std::fs::canonicalize(path)
1630 .map(normalize_windows_path)
1631 .map_err(|error| format!("could not canonicalize {label}: {error}"))?;
1632 if !windows_paths_equal(&canonical, path) {
1633 return Err(format!("{label} resolves through a link or junction"));
1634 }
1635 Ok(canonical)
1636}
1637
1638fn is_self_update_status_name(name: &std::ffi::OsStr) -> bool {
1639 let Some(name) = name.to_str() else {
1640 return false;
1641 };
1642 let Some(token) = name
1643 .strip_prefix("status-")
1644 .and_then(|name| name.strip_suffix(".json"))
1645 else {
1646 return false;
1647 };
1648 token.len() == 32 && token.bytes().all(|byte| byte.is_ascii_hexdigit())
1649}
1650
1651fn validated_self_update_status_file(update_root: &Path, path: &Path) -> Result<PathBuf, String> {
1652 if !path.is_absolute() || path.parent() != Some(update_root) {
1653 return Err("self-update status file is outside the update root".to_string());
1654 }
1655 if !path.file_name().is_some_and(is_self_update_status_name) {
1656 return Err("self-update status file has an invalid name".to_string());
1657 }
1658 let path = validated_regular_file(path, "self-update status file")?;
1659 let length = std::fs::metadata(&path)
1660 .map_err(|error| format!("could not inspect self-update status file: {error}"))?
1661 .len();
1662 if length > MAX_SELF_UPDATE_STATUS_BYTES {
1663 return Err("self-update status file is too large".to_string());
1664 }
1665 Ok(path)
1666}
1667
1668fn read_self_update_status(path: &Path) -> Option<SelfUpdateStatus> {
1669 use std::io::Read;
1670
1671 let file = std::fs::File::open(path).ok()?;
1672 let mut data = Vec::with_capacity(
1673 usize::try_from(MAX_SELF_UPDATE_STATUS_BYTES)
1674 .unwrap_or_default()
1675 .min(8 * 1024),
1676 );
1677 file.take(MAX_SELF_UPDATE_STATUS_BYTES.saturating_add(1))
1678 .read_to_end(&mut data)
1679 .ok()?;
1680 if data.len() as u64 > MAX_SELF_UPDATE_STATUS_BYTES {
1681 return None;
1682 }
1683 serde_json::from_slice(&data).ok()
1684}
1685
1686fn sanitized_self_update_status_message(message: &str) -> String {
1687 let mut sanitized = String::new();
1688 let mut pending_space = false;
1689 let mut truncated = false;
1690 for character in message.trim().chars() {
1691 if character.is_control() || character.is_whitespace() {
1692 pending_space = !sanitized.is_empty();
1693 continue;
1694 }
1695 if sanitized.chars().count() >= MAX_SELF_UPDATE_STATUS_MESSAGE_CHARS {
1696 truncated = true;
1697 break;
1698 }
1699 if pending_space {
1700 sanitized.push(' ');
1701 pending_space = false;
1702 }
1703 sanitized.push(character);
1704 }
1705 if sanitized.is_empty() {
1706 return "no diagnostic message was recorded".to_string();
1707 }
1708 if truncated {
1709 sanitized.push_str("...");
1710 }
1711 sanitized
1712}
1713
1714fn consume_self_update_statuses(agent_dir: &Path) -> Vec<UpdateWarning> {
1715 let _guard = SELF_UPDATE_STATUS_LOCK
1716 .lock()
1717 .unwrap_or_else(std::sync::PoisonError::into_inner);
1718 let agent_dir = match validated_real_directory(agent_dir, "agent directory") {
1719 Ok(path) => path,
1720 Err(_) => return Vec::new(),
1721 };
1722 let update_root = match validated_real_directory(
1723 &agent_dir.join("self-update"),
1724 "self-update status directory",
1725 ) {
1726 Ok(path) if path.parent() == Some(agent_dir.as_path()) => path,
1727 _ => return Vec::new(),
1728 };
1729 let entries = match std::fs::read_dir(&update_root) {
1730 Ok(entries) => entries,
1731 Err(_) => return Vec::new(),
1732 };
1733
1734 let mut warnings = Vec::new();
1735 for entry in entries.flatten() {
1736 if !is_self_update_status_name(&entry.file_name()) {
1737 continue;
1738 }
1739 let path = match validated_self_update_status_file(&update_root, &entry.path()) {
1740 Ok(path) => path,
1741 Err(_) => continue,
1742 };
1743 let Some(status) = read_self_update_status(&path) else {
1744 continue;
1745 };
1746 if !matches!(status.state.as_str(), "failed" | "succeeded") {
1747 continue;
1748 }
1749
1750 match std::fs::remove_file(&path) {
1751 Ok(()) => {}
1752 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1753 Err(_) if status.state == "failed" => {}
1756 Err(_) => continue,
1757 }
1758 if status.state == "failed" {
1759 warnings.push(UpdateWarning {
1760 message: format!(
1761 "The previously scheduled rpi update failed: {}",
1762 sanitized_self_update_status_message(&status.message)
1763 ),
1764 command: "rpi update".to_string(),
1765 });
1766 }
1767 }
1768 warnings
1769}
1770
1771#[cfg(windows)]
1772fn validated_windows_executable(path: &Path, label: &str) -> Result<PathBuf, String> {
1773 use std::io::Read;
1774
1775 let path = validated_regular_file(path, label)?;
1776 let mut file =
1777 std::fs::File::open(&path).map_err(|error| format!("could not open {label}: {error}"))?;
1778 let mut magic = [0u8; 2];
1779 file.read_exact(&mut magic)
1780 .map_err(|error| format!("could not read {label}: {error}"))?;
1781 if magic != *b"MZ" {
1782 return Err(format!("{label} is not a Windows executable"));
1783 }
1784 Ok(path)
1785}
1786
1787#[cfg(windows)]
1788fn system_powershell_path() -> Result<PathBuf, String> {
1789 use std::ffi::OsString;
1790 use std::os::windows::ffi::OsStringExt;
1791 use windows_sys::Win32::System::SystemInformation::GetWindowsDirectoryW;
1792
1793 let mut buffer = vec![0u16; 260];
1794 let system_root = loop {
1795 let length = unsafe {
1796 GetWindowsDirectoryW(
1797 buffer.as_mut_ptr(),
1798 u32::try_from(buffer.len()).unwrap_or(u32::MAX),
1799 )
1800 };
1801 if length == 0 {
1802 return Err(format!(
1803 "could not locate the Windows system directory: {}",
1804 std::io::Error::last_os_error()
1805 ));
1806 }
1807 let length = usize::try_from(length)
1808 .map_err(|error| format!("invalid Windows system directory length: {error}"))?;
1809 if length < buffer.len() {
1810 buffer.truncate(length);
1811 break PathBuf::from(OsString::from_wide(&buffer));
1812 }
1813 buffer.resize(length.saturating_add(1), 0);
1814 };
1815 let system_root = validated_real_directory(&system_root, "Windows system root")?;
1816 let powershell = validated_windows_executable(
1817 &system_root.join("System32/WindowsPowerShell/v1.0/powershell.exe"),
1818 "Windows PowerShell",
1819 )?;
1820 powershell
1821 .strip_prefix(&system_root)
1822 .map_err(|_| "Windows PowerShell is outside SystemRoot".to_string())?;
1823 Ok(powershell)
1824}
1825
1826#[cfg(windows)]
1827fn windows_update_helper_command(
1828 powershell: &Path,
1829 plan: &WindowsUpdatePlan,
1830 parent_pid: u32,
1831 staged_version: &str,
1832) -> std::process::Command {
1833 let mut command = std::process::Command::new(powershell);
1834 let powershell_modules = powershell
1835 .parent()
1836 .expect("validated PowerShell path has a parent")
1837 .join("Modules");
1838 command
1839 .args([
1840 "-NoLogo",
1841 "-NoProfile",
1842 "-NonInteractive",
1843 "-ExecutionPolicy",
1844 "Bypass",
1845 "-File",
1846 ])
1847 .arg(&plan.helper_script)
1848 .arg("-ParentPid")
1849 .arg(parent_pid.to_string())
1850 .arg("-StagingPath")
1851 .arg(&plan.staging_dir)
1852 .arg("-StagedPath")
1853 .arg(&plan.staged_exe)
1854 .arg("-StagedVersion")
1855 .arg(staged_version)
1856 .arg("-TargetPath")
1857 .arg(&plan.target_exe)
1858 .arg("-TempPath")
1859 .arg(&plan.replacement_temp)
1860 .arg("-BackupPath")
1861 .arg(&plan.backup_file)
1862 .arg("-RollbackDisplacedPath")
1863 .arg(&plan.rollback_displaced_file)
1864 .arg("-StatusPath")
1865 .arg(&plan.status_file)
1866 .current_dir(
1867 plan.status_file
1868 .parent()
1869 .expect("validated status path has an update-root parent"),
1870 )
1871 .env("PSModulePath", powershell_modules)
1872 .stdin(Stdio::null())
1873 .stdout(Stdio::null())
1874 .stderr(Stdio::null());
1875 command
1876}
1877
1878#[cfg(windows)]
1879fn write_windows_update_status(path: &Path, state: &str, message: &str) -> Result<(), String> {
1880 let data = serde_json::to_vec_pretty(&serde_json::json!({
1881 "state": state,
1882 "message": message,
1883 "parentPid": std::process::id(),
1884 "currentVersion": crate::VERSION,
1885 "updatedAtMs": now_ms(),
1886 }))
1887 .map_err(|error| error.to_string())?;
1888 std::fs::write(path, data)
1889 .map_err(|error| format!("could not write update status {}: {error}", path.display()))
1890}
1891
1892#[cfg(test)]
1893mod tests {
1894 fn real_tempdir() -> tempfile::TempDir {
1899 let base = std::fs::canonicalize(std::env::temp_dir())
1904 .map(super::normalize_windows_path)
1905 .unwrap_or_else(|_| std::env::temp_dir());
1906 tempfile::Builder::new()
1907 .prefix("rpi-update-test-")
1908 .tempdir_in(base)
1909 .unwrap()
1910 }
1911
1912 use std::collections::BTreeMap;
1913 use std::ffi::OsString;
1914 use std::path::Path;
1915 use std::sync::atomic::{AtomicUsize, Ordering};
1916 use std::sync::Arc;
1917
1918 #[cfg(unix)]
1919 use super::{activate_direct_update, build_direct_update_plan};
1920 #[cfg(windows)]
1921 use super::{
1922 build_windows_update_plan, preflight_windows_replacement, system_powershell_path,
1923 windows_update_helper_command, write_windows_update_status, WINDOWS_UPDATE_HELPER,
1924 };
1925 use super::{
1926 cargo_home_is_project_local, cargo_install_command, collect_bounded,
1927 create_self_update_command_dir, fetch_crates_url, is_newer, parse_rpi_version_output,
1928 path_is_within, results_with_individual_cache_fallback, validate_staged_rpi_version_output,
1929 RegistryLookup, CACHE_FALLBACK_MAX_AGE_MS, UPDATE_CHECK_CONCURRENCY,
1930 };
1931
1932 struct RestoreConfigDir(Option<OsString>);
1933
1934 impl Drop for RestoreConfigDir {
1935 fn drop(&mut self) {
1936 match self.0.take() {
1937 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
1938 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
1939 }
1940 }
1941 }
1942
1943 struct RestoreEnv {
1944 name: &'static str,
1945 value: Option<OsString>,
1946 }
1947
1948 impl RestoreEnv {
1949 fn capture(name: &'static str) -> Self {
1950 Self {
1951 name,
1952 value: std::env::var_os(name),
1953 }
1954 }
1955 }
1956
1957 impl Drop for RestoreEnv {
1958 fn drop(&mut self) {
1959 match self.value.take() {
1960 Some(value) => std::env::set_var(self.name, value),
1961 None => std::env::remove_var(self.name),
1962 }
1963 }
1964 }
1965
1966 #[cfg(windows)]
1967 fn fake_windows_executable(path: &Path, marker: &[u8]) {
1968 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1969 let mut bytes = b"MZ".to_vec();
1970 bytes.extend_from_slice(marker);
1971 std::fs::write(path, bytes).unwrap();
1972 }
1973
1974 #[cfg(unix)]
1975 fn fake_unix_rpi(path: &Path, version: &str, fail_after_rename_to: Option<&str>) {
1976 use std::os::unix::fs::PermissionsExt;
1977
1978 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1979 let failure = fail_after_rename_to.map_or_else(String::new, |name| {
1980 format!(
1981 "case \"$0\" in\n */{name}) echo 'post-replace failure' >&2; exit 1 ;;\nesac\n"
1982 )
1983 });
1984 std::fs::write(
1985 path,
1986 format!("#!/bin/sh\n{failure}printf 'rpi {version}\\n'\n"),
1987 )
1988 .unwrap();
1989 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
1990 }
1991
1992 fn write_test_update_status(path: &Path, state: &str, message: &str) {
1993 std::fs::write(
1994 path,
1995 serde_json::to_vec(&serde_json::json!({
1996 "state": state,
1997 "message": message,
1998 }))
1999 .unwrap(),
2000 )
2001 .unwrap();
2002 }
2003
2004 #[test]
2005 fn cargo_self_update_staging_is_passed_as_a_structured_argument() {
2006 let temp = real_tempdir();
2007 let staging = temp.path().join("staging");
2008 let command_dir = temp.path().join("command");
2009 std::fs::create_dir_all(&staging).unwrap();
2010 std::fs::create_dir_all(&command_dir).unwrap();
2011 let command = cargo_install_command(Some(&staging), &command_dir).unwrap();
2012 let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2013
2014 assert_eq!(command.get_program(), "cargo");
2015 assert_eq!(args.len(), 6);
2016 assert_eq!(args[0], "install");
2017 assert_eq!(args[1], "--root");
2018 assert_eq!(args[2], staging.as_os_str());
2019 assert_eq!(args[3], "rpi-cli");
2020 assert_eq!(args[4], "--locked");
2021 assert_eq!(args[5], "--force");
2022 assert_eq!(command.get_current_dir(), Some(command_dir.as_path()));
2023 }
2024
2025 #[cfg(unix)]
2026 #[test]
2027 fn direct_update_replaces_the_selected_target_and_rejects_downgrades() {
2028 let temp = real_tempdir();
2029 let staging = temp.path().join("staging");
2030 let staged = staging.join("bin/rpi");
2031 let target = temp.path().join("target/rpi-current");
2032 fake_unix_rpi(&staged, "9.9.9", None);
2033 fake_unix_rpi(&target, crate::VERSION, None);
2034 let expected = std::fs::read(&staged).unwrap();
2035
2036 let plan = build_direct_update_plan(&staging, &target).unwrap();
2037 assert_eq!(activate_direct_update(&plan).unwrap().to_string(), "9.9.9");
2038 assert_eq!(std::fs::read(&target).unwrap(), expected);
2039 assert!(!plan.replacement_temp.exists());
2040 assert!(!plan.backup_file.exists());
2041
2042 fake_unix_rpi(&staged, "0.0.1", None);
2043 let plan = build_direct_update_plan(&staging, &target).unwrap();
2044 let error = activate_direct_update(&plan).unwrap_err();
2045 assert!(error.contains("older version"), "{error}");
2046 assert_eq!(std::fs::read(&target).unwrap(), expected);
2047 }
2048
2049 #[cfg(unix)]
2050 #[test]
2051 fn direct_update_rolls_back_when_post_replace_validation_fails() {
2052 let temp = real_tempdir();
2053 let staging = temp.path().join("staging");
2054 let staged = staging.join("bin/rpi");
2055 let target = temp.path().join("target/rpi-current");
2056 fake_unix_rpi(&staged, "9.9.9", Some("rpi-current"));
2057 fake_unix_rpi(&target, crate::VERSION, None);
2058 let original = std::fs::read(&target).unwrap();
2059
2060 let plan = build_direct_update_plan(&staging, &target).unwrap();
2061 let error = activate_direct_update(&plan).unwrap_err();
2062
2063 assert!(error.contains("restored the previous"), "{error}");
2064 assert_eq!(std::fs::read(&target).unwrap(), original);
2065 assert!(!plan.replacement_temp.exists());
2066 assert!(!plan.backup_file.exists());
2067 }
2068
2069 #[test]
2070 fn self_update_honors_env_and_early_dispatched_offline_flag() {
2071 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2072 let _restore = RestoreEnv::capture(crate::args::RPI_OFFLINE_ENV);
2073
2074 std::env::set_var(crate::args::RPI_OFFLINE_ENV, "tRuE");
2075 assert_eq!(super::run_self_update(&[]), 0);
2076
2077 std::env::set_var(crate::args::RPI_OFFLINE_ENV, "0");
2078 assert_eq!(super::run_self_update(&["--offline".into()]), 0);
2079 assert_eq!(
2080 std::env::var(crate::args::RPI_OFFLINE_ENV).as_deref(),
2081 Ok("1")
2082 );
2083 }
2084
2085 #[tokio::test]
2086 async fn offline_skips_rpi_startup_checks_before_cache_io() {
2087 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2088 let _restore_config = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2089 let _restore_offline = RestoreEnv::capture(crate::args::RPI_OFFLINE_ENV);
2090 let _restore_disabled = RestoreEnv::capture("RPI_DISABLE_UPDATE_CHECK");
2091 let temp = real_tempdir();
2092 let agent = temp.path().join("offline-agent");
2093 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2094 std::env::set_var(crate::args::RPI_OFFLINE_ENV, "YES");
2095 std::env::remove_var("RPI_DISABLE_UPDATE_CHECK");
2096
2097 let report = super::check_rpi_startup().await;
2098
2099 assert_eq!(report, super::UpdateReport::default());
2100 assert!(
2101 !agent.exists(),
2102 "offline checks must not create the update cache directory"
2103 );
2104 }
2105
2106 #[test]
2107 fn completed_self_update_statuses_are_consumed_once() {
2108 let temp = real_tempdir();
2109 let agent = temp.path().join("agent");
2110 let update_root = agent.join("self-update");
2111 std::fs::create_dir_all(&update_root).unwrap();
2112 let failed = update_root.join("status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json");
2113 let succeeded = update_root.join("status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.json");
2114 let waiting = update_root.join("status-cccccccccccccccccccccccccccccccc.json");
2115 let temporary = update_root.join("status-dddddddddddddddddddddddddddddddd.json.tmp");
2116 let malformed = update_root.join("status-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.json");
2117 let invalid_name = update_root.join("status-not-a-uuid.json");
2118 write_test_update_status(&failed, "failed", "permission\u{1b}[31m\r\n denied\u{7}");
2119 write_test_update_status(&succeeded, "succeeded", "done");
2120 write_test_update_status(&waiting, "waiting", "still running");
2121 write_test_update_status(&temporary, "failed", "not published");
2122 std::fs::write(&malformed, b"{not-json").unwrap();
2123 write_test_update_status(&invalid_name, "failed", "invalid name");
2124
2125 let warnings = super::consume_self_update_statuses(&agent);
2126
2127 assert_eq!(warnings.len(), 1);
2128 assert!(warnings[0]
2129 .message
2130 .contains("rpi update failed: permission [31m denied"));
2131 assert!(!warnings[0].message.chars().any(char::is_control));
2132 assert_eq!(warnings[0].command, "rpi update");
2133 assert!(!failed.exists());
2134 assert!(!succeeded.exists());
2135 assert!(waiting.exists());
2136 assert!(temporary.exists());
2137 assert!(malformed.exists());
2138 assert!(invalid_name.exists());
2139 assert!(super::consume_self_update_statuses(&agent).is_empty());
2140 }
2141
2142 #[test]
2143 fn self_update_status_validation_rejects_escape_and_links() {
2144 let temp = real_tempdir();
2145 let agent = temp.path().join("agent");
2146 let update_root = agent.join("self-update");
2147 let outside_root = temp.path().join("outside");
2148 std::fs::create_dir_all(&update_root).unwrap();
2149 std::fs::create_dir_all(&outside_root).unwrap();
2150 let name = "status-ffffffffffffffffffffffffffffffff.json";
2151 let outside = outside_root.join(name);
2152 write_test_update_status(&outside, "failed", "outside");
2153
2154 assert!(super::validated_self_update_status_file(&update_root, &outside).is_err());
2155
2156 let linked = update_root.join(name);
2157 #[cfg(unix)]
2158 std::os::unix::fs::symlink(&outside, &linked).unwrap();
2159 #[cfg(windows)]
2160 let link_created = std::os::windows::fs::symlink_file(&outside, &linked).is_ok();
2161 #[cfg(unix)]
2162 let link_created = true;
2163 if link_created {
2164 assert!(super::validated_self_update_status_file(&update_root, &linked).is_err());
2165 assert!(super::consume_self_update_statuses(&agent).is_empty());
2166 assert!(outside.exists());
2167 }
2168 }
2169
2170 #[test]
2171 fn cargo_self_update_without_staging_never_inherits_the_callers_cwd() {
2172 let temp = real_tempdir();
2173 let command_dir = temp.path().join("isolated");
2174 std::fs::create_dir_all(&command_dir).unwrap();
2175
2176 let command = cargo_install_command(None, &command_dir).unwrap();
2177 let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2178
2179 assert_eq!(
2180 args,
2181 ["install", "rpi-cli", "--locked", "--force"]
2182 .into_iter()
2183 .map(OsString::from)
2184 .collect::<Vec<_>>()
2185 );
2186 assert_eq!(command.get_current_dir(), Some(command_dir.as_path()));
2187 assert_ne!(
2188 command.get_current_dir(),
2189 std::env::current_dir().ok().as_deref()
2190 );
2191 for variable in [
2192 "RUSTC_WRAPPER",
2193 "RUSTC_WORKSPACE_WRAPPER",
2194 "CARGO_BUILD_RUSTC_WRAPPER",
2195 "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
2196 ] {
2197 assert!(command
2198 .get_envs()
2199 .any(|(name, value)| name == variable && value.is_none()));
2200 }
2201 assert!(cargo_install_command(None, Path::new("relative-command-dir")).is_err());
2202
2203 let generated = create_self_update_command_dir().unwrap();
2204 let caller = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap();
2205 assert!(generated.path().is_absolute());
2206 assert!(!path_is_within(generated.path(), &caller));
2207 }
2208
2209 #[test]
2210 fn project_local_cargo_home_is_rejected_even_when_its_leaf_is_missing() {
2211 let temp = real_tempdir();
2212 let project = temp.path().join("project");
2213 let global = temp.path().join("global-cargo");
2214 std::fs::create_dir_all(&project).unwrap();
2215 std::fs::create_dir_all(&global).unwrap();
2216 let project = std::fs::canonicalize(project).unwrap();
2217
2218 assert!(cargo_home_is_project_local(&project.join(".cargo-home"), &project).unwrap());
2219 assert!(cargo_home_is_project_local(&project, &project).unwrap());
2220 assert!(!cargo_home_is_project_local(&global, &project).unwrap());
2221 assert!(cargo_home_is_project_local(Path::new("relative"), &project).is_err());
2222 }
2223
2224 #[cfg(windows)]
2225 #[test]
2226 fn windows_update_helper_atomically_replaces_a_valid_target() {
2227 let temp = real_tempdir();
2228 let staging = temp.path().join("staging 'semi;& with spaces");
2229 let staged = staging.join("bin/rpi.exe");
2230 let target = temp.path().join("target 'semi;& with spaces/rpi-copy.exe");
2231 fake_windows_executable(&staged, b"new-version");
2232 fake_windows_executable(&target, b"old-version");
2233 let expected = std::fs::read(&staged).unwrap();
2234 let plan = build_windows_update_plan(
2235 &staging,
2236 &target,
2237 &temp
2238 .path()
2239 .join("status-00000000000000000000000000000000.json"),
2240 )
2241 .unwrap();
2242 preflight_windows_replacement(&plan).unwrap();
2243 std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER).unwrap();
2244 write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
2245
2246 let powershell = system_powershell_path().unwrap();
2247 let mut command =
2248 windows_update_helper_command(&powershell, &plan, i32::MAX as u32, "9.9.9");
2249 let args = command.get_args().map(OsString::from).collect::<Vec<_>>();
2250 for (flag, expected) in [
2251 ("-StagingPath", &plan.staging_dir),
2252 ("-StagedPath", &plan.staged_exe),
2253 ("-TargetPath", &plan.target_exe),
2254 ("-TempPath", &plan.replacement_temp),
2255 ("-BackupPath", &plan.backup_file),
2256 ("-RollbackDisplacedPath", &plan.rollback_displaced_file),
2257 ("-StatusPath", &plan.status_file),
2258 ] {
2259 let index = args.iter().position(|arg| arg == flag).unwrap();
2260 assert_eq!(args[index + 1], expected.as_os_str());
2261 }
2262 let version_index = args.iter().position(|arg| arg == "-StagedVersion").unwrap();
2263 assert_eq!(args[version_index + 1], "9.9.9");
2264 assert_eq!(command.get_current_dir(), plan.status_file.parent());
2265 assert!(command.get_envs().any(|(name, value)| {
2266 name == "PSModulePath"
2267 && value == Some(powershell.parent().unwrap().join("Modules").as_os_str())
2268 }));
2269 let status = command.status().unwrap();
2270
2271 let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
2272 assert!(
2273 status.success(),
2274 "helper exited with {status}: {status_text}"
2275 );
2276 assert_eq!(std::fs::read(&target).unwrap(), expected);
2277 assert!(!staging.exists());
2278 assert!(!plan.replacement_temp.exists());
2279 assert!(!plan.backup_file.exists());
2280 assert!(!plan.rollback_displaced_file.exists());
2281 let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
2282 assert_eq!(status["state"], "succeeded");
2283 assert_eq!(status["stagedVersion"], "9.9.9");
2284 }
2285
2286 #[cfg(windows)]
2287 #[test]
2288 fn windows_update_helper_failure_preserves_the_old_executable() {
2289 let temp = real_tempdir();
2290 let staging = temp.path().join("staging with spaces");
2291 let staged = staging.join("bin/rpi.exe");
2292 let target = temp.path().join("target with spaces/rpi-copy.exe");
2293 fake_windows_executable(&staged, b"new-version");
2294 fake_windows_executable(&target, b"old-version");
2295 let expected = std::fs::read(&target).unwrap();
2296 let plan = build_windows_update_plan(
2297 &staging,
2298 &target,
2299 &temp
2300 .path()
2301 .join("status-00000000000000000000000000000000.json"),
2302 )
2303 .unwrap();
2304 preflight_windows_replacement(&plan).unwrap();
2305 std::fs::write(&plan.helper_script, WINDOWS_UPDATE_HELPER).unwrap();
2306 write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
2307 std::fs::remove_file(&plan.staged_exe).unwrap();
2308
2309 let status = windows_update_helper_command(
2310 &system_powershell_path().unwrap(),
2311 &plan,
2312 i32::MAX as u32,
2313 "9.9.9",
2314 )
2315 .status()
2316 .unwrap();
2317
2318 let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
2319 assert!(!status.success());
2320 assert_eq!(
2321 std::fs::read(&target).unwrap(),
2322 expected,
2323 "status={status_text}; backup_exists={}",
2324 plan.backup_file.exists()
2325 );
2326 assert!(!staging.exists());
2327 assert!(!plan.replacement_temp.exists());
2328 assert!(!plan.backup_file.exists());
2329 assert!(!plan.rollback_displaced_file.exists());
2330 let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
2331 assert_eq!(status["state"], "failed");
2332 }
2333
2334 #[cfg(windows)]
2335 #[test]
2336 fn windows_update_helper_rolls_back_after_post_replace_validation_failure() {
2337 let temp = real_tempdir();
2338 let staging = temp.path().join("staging");
2339 let staged = staging.join("bin/rpi.exe");
2340 let target = temp.path().join("target/rpi-copy.exe");
2341 fake_windows_executable(&staged, b"new-version");
2342 fake_windows_executable(&target, b"old-version");
2343 let expected = std::fs::read(&target).unwrap();
2344 let plan = build_windows_update_plan(
2345 &staging,
2346 &target,
2347 &temp
2348 .path()
2349 .join("status-00000000000000000000000000000000.json"),
2350 )
2351 .unwrap();
2352 preflight_windows_replacement(&plan).unwrap();
2353 let forced_failure = WINDOWS_UPDATE_HELPER.replace(
2354 "(Get-FileSha256 -Path $targetFull) -ne $expectedHash",
2355 "$true",
2356 );
2357 assert_ne!(forced_failure, WINDOWS_UPDATE_HELPER);
2358 std::fs::write(&plan.helper_script, forced_failure).unwrap();
2359 write_windows_update_status(&plan.status_file, "scheduled", "test").unwrap();
2360
2361 let status = windows_update_helper_command(
2362 &system_powershell_path().unwrap(),
2363 &plan,
2364 i32::MAX as u32,
2365 "9.9.9",
2366 )
2367 .status()
2368 .unwrap();
2369
2370 let status_text = std::fs::read_to_string(&plan.status_file).unwrap();
2371 assert!(!status.success());
2372 assert_eq!(
2373 std::fs::read(&target).unwrap(),
2374 expected,
2375 "status={status_text}; backup_exists={}",
2376 plan.backup_file.exists()
2377 );
2378 assert!(!staging.exists());
2379 assert!(!plan.replacement_temp.exists());
2380 assert!(!plan.backup_file.exists());
2381 assert!(!plan.rollback_displaced_file.exists());
2382 let status: serde_json::Value = serde_json::from_str(&status_text).unwrap();
2383 assert_eq!(status["state"], "failed");
2384 assert!(status["message"]
2385 .as_str()
2386 .unwrap()
2387 .contains("updated executable hash mismatch"));
2388 }
2389
2390 #[cfg(windows)]
2391 #[test]
2392 fn windows_update_plan_rejects_a_non_executable_staged_file() {
2393 let temp = real_tempdir();
2394 let staging = temp.path().join("staging");
2395 let staged = staging.join("bin/rpi.exe");
2396 let target = temp.path().join("target/rpi.exe");
2397 std::fs::create_dir_all(staged.parent().unwrap()).unwrap();
2398 std::fs::write(&staged, b"not-a-pe").unwrap();
2399 fake_windows_executable(&target, b"old-version");
2400 let expected = std::fs::read(&target).unwrap();
2401
2402 let error = build_windows_update_plan(
2403 &staging,
2404 &target,
2405 &temp
2406 .path()
2407 .join("status-00000000000000000000000000000000.json"),
2408 )
2409 .unwrap_err();
2410
2411 assert!(error.contains("not a Windows executable"));
2412 assert_eq!(std::fs::read(&target).unwrap(), expected);
2413 }
2414
2415 #[test]
2416 fn staged_rpi_version_output_is_exact_and_cannot_downgrade() {
2417 assert_eq!(
2418 parse_rpi_version_output(b"rpi 1.2.3\r\n").unwrap(),
2419 semver::Version::new(1, 2, 3)
2420 );
2421 assert_eq!(
2422 validate_staged_rpi_version_output(b"rpi 1.2.3\n", b"", "1.2.3").unwrap(),
2423 semver::Version::new(1, 2, 3)
2424 );
2425 assert!(
2426 validate_staged_rpi_version_output(b"rpi 1.2.2\n", b"", "1.2.3")
2427 .unwrap_err()
2428 .contains("older version")
2429 );
2430
2431 for output in [
2432 &b"pi 1.2.3\n"[..],
2433 &b"rpi v1.2.3\n"[..],
2434 &b"rpi 1.2.3 extra\n"[..],
2435 &b"rpi 1.2.3\nsecond line\n"[..],
2436 &b" rpi 1.2.3\n"[..],
2437 ] {
2438 assert!(
2439 parse_rpi_version_output(output).is_none(),
2440 "output={output:?}"
2441 );
2442 }
2443 assert!(
2444 validate_staged_rpi_version_output(b"rpi 1.2.3\n", b"warning", "1.2.3")
2445 .unwrap_err()
2446 .contains("stderr")
2447 );
2448 }
2449
2450 #[test]
2451 fn compares_release_versions_conservatively() {
2452 assert!(is_newer("0.1.9", "0.1.10"));
2453 assert!(is_newer("v1.2.3", "1.3.0"));
2454 assert!(is_newer("1.2.3-beta.1", "1.2.3-beta.2"));
2455 assert!(is_newer("1.2.3-beta.2", "1.2.3"));
2456 assert!(!is_newer("1.2.3", "1.2.3"));
2457 assert!(!is_newer("nightly", "1.0.0"));
2458 }
2459
2460 #[test]
2461 fn failed_item_checks_keep_only_corresponding_cached_values() {
2462 let cached = BTreeMap::from([
2463 ("fresh".to_string(), "1.0.0".to_string()),
2464 ("failed".to_string(), "2.0.0".to_string()),
2465 ("removed".to_string(), "3.0.0".to_string()),
2466 ]);
2467 let results = vec![
2468 (
2469 "fresh".to_string(),
2470 RegistryLookup::Found("1.1.0".to_string()),
2471 ),
2472 ("failed".to_string(), RegistryLookup::TransientFailure),
2473 ("uncached".to_string(), RegistryLookup::TransientFailure),
2474 ("missing".to_string(), RegistryLookup::Missing),
2475 ];
2476
2477 let (merged, checked_at_values) =
2478 results_with_individual_cache_fallback(results, Some(&cached), None, 100, 100);
2479 assert_eq!(merged.get("fresh").map(String::as_str), Some("1.1.0"));
2480 assert_eq!(merged.get("failed").map(String::as_str), Some("2.0.0"));
2481 assert!(!merged.contains_key("removed"));
2482 assert!(!merged.contains_key("uncached"));
2483 assert!(!merged.contains_key("missing"));
2484 assert_eq!(checked_at_values.get("fresh").copied(), Some(100));
2488 assert_eq!(checked_at_values.get("failed").copied(), Some(100));
2489 assert!(!checked_at_values.contains_key("uncached"));
2490 assert!(!checked_at_values.contains_key("missing"));
2491 }
2492
2493 #[tokio::test]
2494 async fn package_update_checks_have_a_shared_concurrency_limit() {
2495 let in_flight = Arc::new(AtomicUsize::new(0));
2496 let maximum = Arc::new(AtomicUsize::new(0));
2497 let checks = (0..(UPDATE_CHECK_CONCURRENCY * 3)).map(|index| {
2498 let in_flight = Arc::clone(&in_flight);
2499 let maximum = Arc::clone(&maximum);
2500 async move {
2501 let active = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
2502 maximum.fetch_max(active, Ordering::SeqCst);
2503 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2504 in_flight.fetch_sub(1, Ordering::SeqCst);
2505 index
2506 }
2507 });
2508
2509 let mut results = collect_bounded(checks).await;
2510 results.sort_unstable();
2511
2512 assert_eq!(
2513 results,
2514 (0..(UPDATE_CHECK_CONCURRENCY * 3)).collect::<Vec<_>>()
2515 );
2516 assert_eq!(maximum.load(Ordering::SeqCst), UPDATE_CHECK_CONCURRENCY);
2517 assert_eq!(in_flight.load(Ordering::SeqCst), 0);
2518 }
2519
2520 #[tokio::test]
2521 async fn bounded_checks_do_not_head_of_line_block_new_work() {
2522 let release_first = Arc::new(tokio::sync::Notify::new());
2523 let checks = (0..(UPDATE_CHECK_CONCURRENCY + 1)).map(|index| {
2524 let release_first = Arc::clone(&release_first);
2525 async move {
2526 if index == 0 {
2527 release_first.notified().await;
2528 } else if index == UPDATE_CHECK_CONCURRENCY {
2529 release_first.notify_one();
2530 }
2531 index
2532 }
2533 });
2534
2535 let mut results =
2536 tokio::time::timeout(std::time::Duration::from_secs(1), collect_bounded(checks))
2537 .await
2538 .expect("a completed later check must free a concurrency slot");
2539 results.sort_unstable();
2540 assert_eq!(results, (0..=UPDATE_CHECK_CONCURRENCY).collect::<Vec<_>>());
2541 }
2542
2543 #[test]
2544 fn fresh_results_keep_individual_timestamps_when_a_peer_uses_fallback() {
2545 let now = 10 * CACHE_FALLBACK_MAX_AGE_MS;
2546 let nearly_stale = now - CACHE_FALLBACK_MAX_AGE_MS + 60_000;
2547 let cached = BTreeMap::from([
2548 ("fresh".to_string(), "1.0.0".to_string()),
2549 ("failed".to_string(), "2.0.0".to_string()),
2550 ]);
2551 let freshness = BTreeMap::from([
2552 ("fresh".to_string(), nearly_stale),
2553 ("failed".to_string(), nearly_stale),
2554 ]);
2555 let (values, timestamps) = results_with_individual_cache_fallback(
2556 vec![
2557 (
2558 "fresh".to_string(),
2559 RegistryLookup::Found("1.1.0".to_string()),
2560 ),
2561 ("failed".to_string(), RegistryLookup::TransientFailure),
2562 ],
2563 Some(&cached),
2564 Some(&freshness),
2565 nearly_stale,
2566 now,
2567 );
2568
2569 assert_eq!(timestamps.get("fresh"), Some(&now));
2570 assert_eq!(timestamps.get("failed"), Some(&nearly_stale));
2571 let after_old_entry_expires = now + 120_000;
2572 let (fallback, _) = results_with_individual_cache_fallback(
2573 vec![
2574 ("fresh".to_string(), RegistryLookup::TransientFailure),
2575 ("failed".to_string(), RegistryLookup::TransientFailure),
2576 ],
2577 Some(&values),
2578 Some(×tamps),
2579 nearly_stale,
2580 after_old_entry_expires,
2581 );
2582 assert_eq!(fallback.get("fresh").map(String::as_str), Some("1.1.0"));
2583 assert!(!fallback.contains_key("failed"));
2584 }
2585
2586 #[tokio::test]
2587 async fn crates_rate_limits_and_request_timeouts_are_transient() {
2588 async fn lookup(status: &str, body: &str) -> RegistryLookup {
2589 use std::io::{BufRead, BufReader, Write};
2590
2591 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2592 let address = listener.local_addr().unwrap();
2593 let status = status.to_string();
2594 let body = body.to_string();
2595 let server = std::thread::spawn(move || {
2596 let (mut stream, _) = listener.accept().unwrap();
2597 {
2598 let mut reader = BufReader::new(&mut stream);
2599 let mut line = String::new();
2600 loop {
2601 line.clear();
2602 if reader.read_line(&mut line).unwrap() == 0 || line == "\r\n" {
2603 break;
2604 }
2605 }
2606 }
2607 write!(
2608 stream,
2609 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2610 body.len()
2611 )
2612 .unwrap();
2613 });
2614 let client = reqwest::Client::builder().build().unwrap();
2615 let result = fetch_crates_url(&client, &format!("http://{address}/crate")).await;
2616 server.join().unwrap();
2617 result
2618 }
2619
2620 assert_eq!(
2621 lookup("408 Request Timeout", "").await,
2622 RegistryLookup::TransientFailure
2623 );
2624 assert_eq!(
2625 lookup("429 Too Many Requests", "").await,
2626 RegistryLookup::TransientFailure
2627 );
2628 let malformed = lookup("200 OK", "{malformed-json").await;
2629 assert_eq!(malformed, RegistryLookup::TransientFailure);
2630 assert_eq!(
2631 super::lookup_with_cache_fallback(malformed, Some("9.9.9"), true),
2632 (Some("9.9.9".to_string()), true)
2633 );
2634 assert_eq!(lookup("404 Not Found", "").await, RegistryLookup::Missing);
2635 }
2636}