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