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