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