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