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