1use std::io::{Read, Write};
2use std::path::{Path, PathBuf};
3
4#[cfg(test)]
5use fs2::FileExt;
6use serde::{Deserialize, Serialize};
7
8#[path = "updater/flow.rs"]
9mod flow;
10#[path = "updater/github_api.rs"]
11mod github_api;
12#[path = "updater/rewire.rs"]
13mod rewire;
14#[path = "updater/transaction.rs"]
15mod transaction;
16
17use transaction::{
18 acquire_update_lock, execute_prepared_transaction, prepare_update_transaction,
19 recover_pending_transaction, rollback_to_previous,
20};
21#[cfg(test)]
22use transaction::{
23 cleanup_orphaned_prepared_files, orphan_prepared_paths, rollback_installation,
24 write_update_receipt,
25};
26
27pub(crate) use github_api::github_api_json;
28#[cfg(test)]
29use github_api::{github_token, rate_limit_message};
30pub use rewire::SetupRefresh;
31pub use rewire::migrate_shadow_mode_default_public;
32use rewire::{post_update_rewire, print_rewire_plan};
33
34mod platform;
35#[cfg(any(windows, test))]
36#[path = "updater/windows_sidecar.rs"]
37pub mod windows_sidecar;
38use platform::{
39 gpu_build_already_installed, gpu_next_steps, gpu_platform_asset_name, platform_asset_name,
40};
41#[cfg(feature = "secure-update")]
42mod sigstore;
43
44const GITHUB_API_RELEASES: &str = "https://api.github.com/repos/yvgude/lean-ctx/releases/latest";
45const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
46const UPDATE_RECEIPT_SCHEMA: &str = "leanctx.update-receipt/v2";
47const LEGACY_UPDATE_RECEIPT_SCHEMA: &str = "leanctx.update-receipt/v1";
48const UPDATE_RECEIPT_FILE: &str = "update-receipt.json";
49const UPDATE_TRANSACTION_SCHEMA: &str = "leanctx.update-transaction/v1";
50const UPDATE_TRANSACTION_FILE: &str = "update-transaction.json";
51const UPDATE_LOCK_FILE: &str = "update.lock";
52
53pub fn run(args: &[String], setup: &dyn SetupRefresh) {
55 run_with_mode(args, UpdateMode::Normal, setup);
56}
57
58pub fn enable_gpu(args: &[String], setup: &dyn SetupRefresh) {
60 run_with_mode(args, UpdateMode::EnableGpu, setup);
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum UpdateMode {
65 Normal,
66 EnableGpu,
67}
68
69const KNOWN_FLAGS: &[&str] = &[
72 "--check",
73 "--dry-run",
74 "--quiet",
75 "--skip-rules",
76 "--scheduled",
77 "--schedule",
78 "--insecure", "--status",
80 "--rollback",
81 "--unpin",
82 "--pin",
83 "--recover-update",
84 "--lock-held",
85 "--force",
87 "--rewire",
88];
89
90#[derive(Debug, PartialEq, Eq)]
91enum FlagCheck<'a> {
92 Run,
93 Help,
94 Unknown(&'a str),
95}
96
97fn check_flags(args: &[String]) -> FlagCheck<'_> {
98 if args.iter().any(|arg| arg == "--help" || arg == "-h") {
99 return FlagCheck::Help;
100 }
101
102 args.iter()
103 .map(String::as_str)
104 .find(|arg| arg.starts_with('-') && !KNOWN_FLAGS.contains(arg))
105 .map_or(FlagCheck::Run, FlagCheck::Unknown)
106}
107
108fn command_name(mode: UpdateMode) -> &'static str {
109 match mode {
110 UpdateMode::Normal => "update",
111 UpdateMode::EnableGpu => "enable-gpu",
112 }
113}
114
115fn print_help(mode: UpdateMode) {
116 let command = command_name(mode);
117 println!("Usage: lean-ctx {command} [VERSION] [OPTIONS]");
118 println!();
119 println!(" VERSION install this release instead of the latest");
120 println!(" --check only report whether an update is available");
121 println!(" --dry-run show the release and the setup refresh; change nothing");
122 println!(" --quiet suppress output unless something changes");
123 println!(" --skip-rules do not refresh agent rules after updating");
124 println!(" The refresh touches only the tools chosen in `lean-ctx setup` (setup.agents),");
125 println!(" or, without a choice, tools already connected — never newly installed ones.");
126 if mode == UpdateMode::EnableGpu {
127 println!(" --force reinstall the CUDA build of the current release");
128 }
129 println!(" --pin VERSION pin and install this release");
130 println!(" --unpin clear the release pin");
131 println!(" --status show the pin and retained binary receipt");
132 println!(" --rollback restore the retained verified binary");
133 println!(" --schedule [N|Nh] install automatic updates every N hours (default 6)");
134 println!(" --schedule notify only notify about updates");
135 println!(" --schedule status show the update schedule");
136 println!(" --schedule off|disable disable automatic updates");
137 println!(" -h, --help show this help");
138 println!();
139 println!(" The schedule (LaunchAgent, systemd timer, crontab, scheduled task) and the");
140 println!(" managed proxy restart belong to the default install; with LEAN_CTX_DATA_DIR,");
141 println!(" another directory override or another HOME they are left alone.");
142}
143
144fn run_with_mode(args: &[String], mode: UpdateMode, setup: &dyn SetupRefresh) {
145 match check_flags(args) {
146 FlagCheck::Run => {}
147 FlagCheck::Help => {
148 print_help(mode);
149 return;
150 }
151 FlagCheck::Unknown(flag) => {
152 eprintln!(" \x1b[31m✗\x1b[0m Unknown option: {flag}");
153 eprintln!(
154 " \x1b[2mSee: lean-ctx {} --help\x1b[0m",
155 command_name(mode)
156 );
157 std::process::exit(2);
158 }
159 }
160
161 flow::run_with_mode(args, mode, setup);
162}
163
164#[cfg(test)]
165mod argument_guard_tests {
166 use super::{FlagCheck, UpdateMode, check_flags, command_name};
167
168 fn args(values: &[&str]) -> Vec<String> {
169 values.iter().map(ToString::to_string).collect()
170 }
171
172 #[test]
173 fn help_never_falls_through_to_an_update() {
174 assert_eq!(check_flags(&args(&["--help"])), FlagCheck::Help);
175 assert_eq!(check_flags(&args(&["-h"])), FlagCheck::Help);
176 assert_eq!(
177 check_flags(&args(&["--schedule", "off", "--broken", "--help"])),
178 FlagCheck::Help
179 );
180 assert_eq!(command_name(UpdateMode::Normal), "update");
181 assert_eq!(command_name(UpdateMode::EnableGpu), "enable-gpu");
182 }
183
184 #[test]
185 fn unknown_flags_are_refused() {
186 assert_eq!(
187 check_flags(&args(&["--chek"])),
188 FlagCheck::Unknown("--chek")
189 );
190 assert_eq!(
191 check_flags(&args(&["3.10.3", "--forse"])),
192 FlagCheck::Unknown("--forse")
193 );
194 }
195
196 #[test]
197 fn scheduler_and_known_invocations_still_run() {
198 for ok in [
199 &[][..],
200 &["--quiet", "--scheduled"][..],
201 &["--check"][..],
202 &["--dry-run"][..],
203 &["3.10.3", "--skip-rules"][..],
204 &["--schedule", "12h"][..],
205 &["--schedule", "off"][..],
206 &["--schedule", "disable"][..],
207 &["--status"][..],
208 &["--rollback"][..],
209 &["--pin", "3.10.3"][..],
210 &["--unpin"][..],
211 &["--recover-update", "--lock-held"][..],
212 &["--insecure"][..],
213 &["--force"][..],
214 &["--rewire"][..],
215 ] {
216 assert_eq!(check_flags(&args(ok)), FlagCheck::Run, "{ok:?}");
217 }
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223enum AutoUpdateGate {
224 Proceed,
226 Skip,
228 NotifyOnly,
230}
231
232fn automatic_update_gate(auto_update: bool, notify_only: bool) -> AutoUpdateGate {
235 if !auto_update {
236 AutoUpdateGate::Skip
237 } else if notify_only {
238 AutoUpdateGate::NotifyOnly
239 } else {
240 AutoUpdateGate::Proceed
241 }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
245struct UpdateReceipt {
246 schema_version: String,
247 active: BinaryReceipt,
248 previous: BinaryReceipt,
249 #[serde(default)]
250 receipt_sha256: Option<String>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254struct BinaryReceipt {
255 version: String,
256 asset: String,
257 sha256: String,
258 size: u64,
259 path: String,
260 manifest_sha256: Option<String>,
261 archive_sha256: Option<String>,
262 #[serde(default)]
263 release_commit: Option<String>,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267struct VerifiedArtifact {
268 archive_sha256: String,
269 manifest_sha256: String,
270 release_commit: String,
271 payload_sha256: String,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275struct PreparedTransaction {
276 schema_version: String,
277 operation: String,
278 current_path: String,
279 previous_path: String,
280 staged_path: String,
281 backup_path: String,
282 old_active: BinaryReceipt,
283 target_active: BinaryReceipt,
284 target_previous: BinaryReceipt,
285 transaction_sha256: Option<String>,
286}
287
288#[derive(Debug, Deserialize)]
289#[cfg(feature = "secure-update")]
290struct ReleaseManifest {
291 schema_version: String,
292 tag: String,
293 commit: String,
294 artifacts: std::collections::HashMap<String, ManifestArtifact>,
295 sbom_sha256: String,
296 checksums_sha256: String,
297}
298
299#[derive(Debug, Deserialize)]
300#[cfg(feature = "secure-update")]
301struct ManifestArtifact {
302 sha256: String,
303 size: u64,
304 #[serde(default)]
305 payload_sha256: Option<String>,
306}
307
308fn verify_download_integrity(
309 release: &serde_json::Value,
310 asset_name: &str,
311 bytes: &[u8],
312) -> Result<VerifiedArtifact, String> {
313 #[cfg(not(feature = "secure-update"))]
314 {
315 let _ = (release, asset_name, bytes);
316 Err("secure-update feature disabled (sha256 verification unavailable)".to_string())
317 }
318
319 #[cfg(feature = "secure-update")]
320 {
321 let release_tag = release["tag_name"]
322 .as_str()
323 .ok_or_else(|| "release metadata has no tag_name".to_string())?;
324 let manifest_url = find_asset_url(release, "release-manifest.json")
325 .ok_or_else(|| "release-manifest.json is required for binary updates".to_string())?;
326 let manifest_bytes = download_bytes(&manifest_url)?;
327 let manifest_signature_url = find_asset_url(release, "release-manifest.json.sig")
328 .ok_or_else(|| {
329 "release-manifest.json.sig is required for binary updates".to_string()
330 })?;
331 let manifest_certificate_url = find_asset_url(release, "release-manifest.json.pem")
332 .ok_or_else(|| {
333 "release-manifest.json.pem is required for binary updates".to_string()
334 })?;
335 sigstore::verify_release_signature(
336 &manifest_bytes,
337 &download_bytes(&manifest_signature_url)?,
338 &download_bytes(&manifest_certificate_url)?,
339 release_tag,
340 )?;
341 let manifest_sha256 = sha256_hex(&manifest_bytes);
342 let manifest: ReleaseManifest = serde_json::from_slice(&manifest_bytes)
343 .map_err(|e| format!("invalid release-manifest.json: {e}"))?;
344 validate_manifest(&manifest, release_tag, asset_name)?;
345 let artifact = manifest
346 .artifacts
347 .get(asset_name)
348 .ok_or_else(|| format!("manifest has no artifact entry for {asset_name}"))?;
349 let computed = sha256_hex(bytes);
350 if bytes.len() as u64 != artifact.size {
351 return Err(format!(
352 "size mismatch for {asset_name}: expected {}, got {}",
353 artifact.size,
354 bytes.len()
355 ));
356 }
357 if !constant_time_eq(computed.as_bytes(), artifact.sha256.as_bytes()) {
358 return Err(format!(
359 "sha256 mismatch for {asset_name}: expected {}, got {computed}",
360 artifact.sha256
361 ));
362 }
363
364 let checksum_url = find_asset_url(release, "SHA256SUMS")
365 .ok_or_else(|| "SHA256SUMS is required for binary updates".to_string())?;
366 let checksum_bytes = download_bytes(&checksum_url)?;
367 let signature_url = find_asset_url(release, "SHA256SUMS.sig")
368 .ok_or_else(|| "SHA256SUMS.sig is required for binary updates".to_string())?;
369 let signature_bytes = download_bytes(&signature_url)?;
370 let certificate_url = find_asset_url(release, "SHA256SUMS.pem")
371 .ok_or_else(|| "SHA256SUMS.pem is required for binary updates".to_string())?;
372 let certificate_bytes = download_bytes(&certificate_url)?;
373 sigstore::verify_release_signature(
374 &checksum_bytes,
375 &signature_bytes,
376 &certificate_bytes,
377 release_tag,
378 )?;
379 verify_release_commit(release_tag, &manifest.commit)?;
380 let checksum_sha256 = sha256_hex(&checksum_bytes);
381 if !constant_time_eq(
382 checksum_sha256.as_bytes(),
383 manifest.checksums_sha256.to_ascii_lowercase().as_bytes(),
384 ) {
385 return Err("SHA256SUMS digest does not match release manifest".to_string());
386 }
387 let sbom_url = find_asset_url(release, "SBOM.cdx.json")
388 .ok_or_else(|| "SBOM.cdx.json is required for binary updates".to_string())?;
389 let sbom_sha256 = sha256_hex(&download_bytes(&sbom_url)?);
390 if !constant_time_eq(
391 sbom_sha256.as_bytes(),
392 manifest.sbom_sha256.to_ascii_lowercase().as_bytes(),
393 ) {
394 return Err("SBOM digest does not match release manifest".to_string());
395 }
396 let checksum_text = String::from_utf8(checksum_bytes)
397 .map_err(|_| "SHA256SUMS is not valid UTF-8".to_string())?;
398 let expected = parse_sha256sums(&checksum_text, asset_name)
399 .ok_or_else(|| format!("SHA256SUMS has no unique entry for {asset_name}"))?;
400 if !constant_time_eq(expected.as_bytes(), computed.as_bytes())
401 || !constant_time_eq(expected.as_bytes(), artifact.sha256.as_bytes())
402 {
403 return Err(format!("SHA256SUMS digest mismatch for {asset_name}"));
404 }
405 Ok(VerifiedArtifact {
406 archive_sha256: computed,
407 manifest_sha256,
408 release_commit: manifest.commit,
409 payload_sha256: artifact.payload_sha256.clone().ok_or_else(|| {
410 format!("release manifest omits extracted payload digest for {asset_name}")
411 })?,
412 })
413 }
414}
415
416#[cfg(feature = "secure-update")]
417fn fetch_api_json(url: &str) -> Result<serde_json::Value, String> {
418 const API_PREFIX: &str = "https://api.github.com/repos/yvgude/lean-ctx/";
419 if !url.starts_with(API_PREFIX) {
420 return Err(format!(
421 "refusing GitHub API URL outside canonical repository: {url}"
422 ));
423 }
424 github_api_json(url)
425}
426
427fn offers_update(target: &str, current: &str, pinned: bool) -> bool {
434 let target = target.trim_start_matches('v');
435 if pinned {
436 target != current
437 } else {
438 crate::core::version_check::is_newer(target, current)
439 }
440}
441
442#[cfg(feature = "secure-update")]
443fn verify_release_commit(release_tag: &str, expected_commit: &str) -> Result<(), String> {
444 let tag = release_tag.trim_start_matches('v');
445 let reference = fetch_api_json(&format!(
446 "https://api.github.com/repos/yvgude/lean-ctx/git/ref/tags/v{tag}"
447 ))?;
448 let object = reference
449 .get("object")
450 .ok_or_else(|| "GitHub tag reference has no object".to_string())?;
451 let object_sha = object
452 .get("sha")
453 .and_then(serde_json::Value::as_str)
454 .ok_or_else(|| "GitHub tag reference has no object SHA".to_string())?;
455 let commit = match object.get("type").and_then(serde_json::Value::as_str) {
456 Some("commit") => object_sha.to_string(),
457 Some("tag") => {
458 let tag_object = fetch_api_json(&format!(
459 "https://api.github.com/repos/yvgude/lean-ctx/git/tags/{object_sha}"
460 ))?;
461 tag_object["object"]["sha"]
462 .as_str()
463 .ok_or_else(|| "annotated GitHub tag has no commit object".to_string())?
464 .to_string()
465 }
466 Some(other) => return Err(format!("unsupported GitHub tag object type `{other}`")),
467 None => return Err("GitHub tag reference has no object type".to_string()),
468 };
469 if commit.len() != 40
470 || !commit.chars().all(|c| c.is_ascii_hexdigit())
471 || !constant_time_eq(
472 commit.to_ascii_lowercase().as_bytes(),
473 expected_commit.to_ascii_lowercase().as_bytes(),
474 )
475 {
476 return Err("release manifest commit does not match the signed GitHub tag".to_string());
477 }
478 Ok(())
479}
480
481#[cfg(feature = "secure-update")]
482fn validate_manifest(
483 manifest: &ReleaseManifest,
484 release_tag: &str,
485 asset_name: &str,
486) -> Result<(), String> {
487 if manifest.schema_version != "leanctx.release-manifest/v1" {
488 return Err(format!(
489 "unsupported release manifest schema `{}`",
490 manifest.schema_version
491 ));
492 }
493 if manifest.tag.trim_start_matches('v') != release_tag.trim_start_matches('v') {
494 return Err(format!(
495 "manifest tag `{}` does not match release tag `{release_tag}`",
496 manifest.tag
497 ));
498 }
499 if manifest.commit.len() != 40 || !manifest.commit.chars().all(|c| c.is_ascii_hexdigit()) {
500 return Err("release manifest commit is not a 40-character hexadecimal SHA".to_string());
501 }
502 if manifest.checksums_sha256.len() != 64
503 || !manifest
504 .checksums_sha256
505 .chars()
506 .all(|c| c.is_ascii_hexdigit())
507 {
508 return Err("release manifest has an invalid checksums_sha256 digest".to_string());
509 }
510 if manifest.sbom_sha256.len() != 64
511 || !manifest.sbom_sha256.chars().all(|c| c.is_ascii_hexdigit())
512 {
513 return Err("release manifest has an invalid sbom_sha256 digest".to_string());
514 }
515 let artifact = manifest
516 .artifacts
517 .get(asset_name)
518 .ok_or_else(|| format!("release manifest omits {asset_name}"))?;
519 if artifact.sha256.len() != 64 || !artifact.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
520 return Err(format!(
521 "release manifest has invalid digest for {asset_name}"
522 ));
523 }
524 let payload_sha256 = artifact.payload_sha256.as_ref().ok_or_else(|| {
525 format!("release manifest omits extracted payload digest for {asset_name}")
526 })?;
527 if payload_sha256.len() != 64 || !payload_sha256.chars().all(|c| c.is_ascii_hexdigit()) {
528 return Err(format!(
529 "release manifest has invalid extracted payload digest for {asset_name}"
530 ));
531 }
532 Ok(())
533}
534
535#[cfg(feature = "secure-update")]
536fn parse_sha256sums(text: &str, asset_name: &str) -> Option<String> {
537 let mut found = None;
538 for line in text.lines() {
539 let l = line.trim();
540 if l.is_empty() || l.starts_with('#') {
541 continue;
542 }
543 let mut parts = l.split_whitespace();
544 let hash = parts.next().unwrap_or("");
545 let file = parts.next().unwrap_or("");
546 if file == asset_name {
547 if found.is_some() || hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
548 return None;
549 }
550 found = Some(hash.to_ascii_lowercase());
551 }
552 }
553 found
554}
555
556pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
557 use sha2::{Digest, Sha256};
558 let mut h = Sha256::new();
559 h.update(bytes);
560 let out = h.finalize();
561 hex_lower(&out)
562}
563
564fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
565 if a.len() != b.len() {
566 return false;
567 }
568 a.iter()
569 .zip(b.iter())
570 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
571 == 0
572}
573
574fn hex_lower(bytes: &[u8]) -> String {
575 const HEX: &[u8; 16] = b"0123456789abcdef";
576 let mut out = String::with_capacity(bytes.len() * 2);
577 for &b in bytes {
578 out.push(HEX[(b >> 4) as usize] as char);
579 out.push(HEX[(b & 0x0f) as usize] as char);
580 }
581 out
582}
583
584fn canonical_state_dir() -> Result<PathBuf, String> {
585 let state = crate::core::paths::state_dir()?;
586 std::fs::create_dir_all(&state).map_err(|e| {
587 format!(
588 "cannot create update state directory {}: {e}",
589 state.display()
590 )
591 })?;
592 reject_symlink(&state)?;
593 std::fs::canonicalize(&state).map_err(|e| format!("cannot resolve state directory: {e}"))
594}
595
596fn update_layout(state_dir: &Path) -> Result<(), String> {
597 let updates = state_dir.join("updates");
598 let previous = updates.join("previous");
599 let staged = updates.join("staged");
600 for directory in [&updates, &previous, &staged] {
601 std::fs::create_dir_all(directory).map_err(|e| {
602 format!(
603 "cannot create update directory {}: {e}",
604 directory.display()
605 )
606 })?;
607 reject_symlink(directory)?;
608 }
609 Ok(())
610}
611
612fn update_transaction_path() -> Result<PathBuf, String> {
613 let state = canonical_state_dir()?;
614 update_layout(&state)?;
615 let path = state.join(UPDATE_TRANSACTION_FILE);
616 reject_symlink_if_present(&path)?;
617 Ok(path)
618}
619
620fn update_lock_path() -> Result<PathBuf, String> {
621 let state = canonical_state_dir()?;
622 update_layout(&state)?;
623 let path = state.join(UPDATE_LOCK_FILE);
624 reject_symlink_if_present(&path)?;
625 Ok(path)
626}
627
628fn staged_update_path(operation: &str, backup: bool) -> Result<PathBuf, String> {
629 if operation != "update" && operation != "rollback" {
630 return Err(format!("unsupported update operation `{operation}`"));
631 }
632 let state = canonical_state_dir()?;
633 update_layout(&state)?;
634 let suffix = if backup { "backup" } else { "target" };
635 Ok(state
636 .join("updates")
637 .join("staged")
638 .join(format!("{operation}-{suffix}.bin")))
639}
640
641fn previous_binary_path(state_path: &Path, current_exe: &Path) -> Result<PathBuf, String> {
642 let name = current_exe
643 .file_name()
644 .ok_or_else(|| "current executable has no file name".to_string())?;
645 Ok(state_path.join("updates").join("previous").join(name))
646}
647
648fn canonical_current_exe(current_exe: &Path) -> Result<PathBuf, String> {
649 let metadata = std::fs::symlink_metadata(current_exe).map_err(|e| {
650 format!(
651 "cannot inspect current executable {}: {e}",
652 current_exe.display()
653 )
654 })?;
655 if metadata.file_type().is_symlink() {
656 return Err("current executable is a symlink; refusing update".to_string());
657 }
658 std::fs::canonicalize(current_exe).map_err(|e| {
659 format!(
660 "cannot resolve current executable {}: {e}",
661 current_exe.display()
662 )
663 })
664}
665
666fn reject_symlink(path: &Path) -> Result<(), String> {
667 let metadata = std::fs::symlink_metadata(path)
668 .map_err(|e| format!("cannot inspect update path {}: {e}", path.display()))?;
669 if metadata.file_type().is_symlink() {
670 return Err(format!("update path is a symlink: {}", path.display()));
671 }
672 Ok(())
673}
674
675fn reject_symlink_if_present(path: &Path) -> Result<(), String> {
676 match std::fs::symlink_metadata(path) {
677 Ok(metadata) if metadata.file_type().is_symlink() => {
678 Err(format!("update path is a symlink: {}", path.display()))
679 }
680 Ok(_) => Ok(()),
681 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
682 Err(e) => Err(format!(
683 "cannot inspect update path {}: {e}",
684 path.display()
685 )),
686 }
687}
688
689fn ensure_no_symlink_under(root: &Path, path: &Path) -> Result<(), String> {
690 let relative = path
691 .strip_prefix(root)
692 .map_err(|_| format!("update path escapes state directory: {}", path.display()))?;
693 let root = std::fs::canonicalize(root)
694 .map_err(|e| format!("cannot resolve update root {}: {e}", root.display()))?;
695 let mut cursor = root;
696 for component in relative.components() {
697 match component {
698 std::path::Component::Normal(component) => cursor.push(component),
699 std::path::Component::CurDir => continue,
700 _ => {
701 return Err(format!(
702 "update path contains unsafe component: {}",
703 path.display()
704 ));
705 }
706 }
707 reject_symlink_if_present(&cursor)?;
708 }
709 Ok(())
710}
711
712fn canonical_update_paths(current_exe: &Path) -> Result<(PathBuf, PathBuf, PathBuf), String> {
713 let state = canonical_state_dir()?;
714 update_layout(&state)?;
715 let current = canonical_current_exe(current_exe)?;
716 let receipt = state.join(UPDATE_RECEIPT_FILE);
717 let previous = previous_binary_path(&state, ¤t)?;
718 ensure_no_symlink_under(&state, &receipt)?;
719 ensure_no_symlink_under(&state, &previous)?;
720 Ok((state, receipt, previous))
721}
722
723fn validate_receipt_paths(
724 receipt: &UpdateReceipt,
725 state_dir: &Path,
726 current_exe: &Path,
727) -> Result<(), String> {
728 let current = canonical_current_exe(current_exe)?;
729 let previous = previous_binary_path(state_dir, ¤t)?;
730 if Path::new(&receipt.active.path) != current {
731 return Err("active receipt path is not the canonical executable".to_string());
732 }
733 if Path::new(&receipt.previous.path) != previous {
734 return Err("previous receipt path is not the canonical retained binary".to_string());
735 }
736 ensure_no_symlink_under(state_dir, &previous)?;
737 Ok(())
738}
739
740fn load_update_receipt() -> Result<Option<UpdateReceipt>, String> {
741 let current = std::env::current_exe().map_err(|e| e.to_string())?;
742 load_update_receipt_for(¤t)
743}
744
745fn load_update_receipt_for(current_exe: &Path) -> Result<Option<UpdateReceipt>, String> {
746 let (state, path, _) = canonical_update_paths(current_exe)?;
747 let raw = match std::fs::read_to_string(&path) {
748 Ok(raw) => raw,
749 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
750 Err(e) => return Err(format!("{}: {e}", path.display())),
751 };
752 let receipt: UpdateReceipt =
753 serde_json::from_str(&raw).map_err(|e| format!("invalid {}: {e}", path.display()))?;
754 validate_receipt(&receipt)?;
755 validate_receipt_integrity(&receipt)?;
756 validate_receipt_paths(&receipt, &state, current_exe)?;
757 Ok(Some(receipt))
758}
759
760fn validate_receipt(receipt: &UpdateReceipt) -> Result<(), String> {
761 if receipt.schema_version != UPDATE_RECEIPT_SCHEMA
762 && receipt.schema_version != LEGACY_UPDATE_RECEIPT_SCHEMA
763 {
764 return Err(format!(
765 "unsupported update receipt schema `{}`",
766 receipt.schema_version
767 ));
768 }
769 for (label, binary) in [("active", &receipt.active), ("previous", &receipt.previous)] {
770 if binary.sha256.len() != 64 || !binary.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
771 return Err(format!("{label} receipt has an invalid binary digest"));
772 }
773 if binary.size == 0 || binary.path.trim().is_empty() || binary.version.trim().is_empty() {
774 return Err(format!("{label} receipt has missing identity or size"));
775 }
776 if let Some(digest) = &binary.manifest_sha256
777 && (digest.len() != 64 || !digest.chars().all(|c| c.is_ascii_hexdigit()))
778 {
779 return Err(format!("{label} receipt has an invalid manifest digest"));
780 }
781 if let Some(digest) = &binary.archive_sha256
782 && (digest.len() != 64 || !digest.chars().all(|c| c.is_ascii_hexdigit()))
783 {
784 return Err(format!("{label} receipt has an invalid archive digest"));
785 }
786 if let Some(commit) = &binary.release_commit
787 && (commit.len() != 40 || !commit.chars().all(|c| c.is_ascii_hexdigit()))
788 {
789 return Err(format!("{label} receipt has an invalid release commit"));
790 }
791 }
792 if let Some(digest) = &receipt.receipt_sha256
793 && (digest.len() != 64 || !digest.chars().all(|c| c.is_ascii_hexdigit()))
794 {
795 return Err("update receipt has an invalid integrity digest".to_string());
796 }
797 Ok(())
798}
799
800fn receipt_digest(receipt: &UpdateReceipt) -> Result<String, String> {
801 let mut unsigned = receipt.clone();
802 unsigned.receipt_sha256 = None;
803 let bytes = serde_json::to_vec(&unsigned).map_err(|e| e.to_string())?;
804 Ok(sha256_hex(&bytes))
805}
806
807fn validate_receipt_integrity(receipt: &UpdateReceipt) -> Result<(), String> {
808 if receipt.schema_version == LEGACY_UPDATE_RECEIPT_SCHEMA {
809 return Ok(());
810 }
811 let actual = receipt
812 .receipt_sha256
813 .as_deref()
814 .ok_or_else(|| "v2 update receipt is missing its integrity digest".to_string())?;
815 let expected = receipt_digest(receipt)?;
816 if !constant_time_eq(actual.as_bytes(), expected.as_bytes()) {
817 return Err("update receipt integrity digest mismatch".to_string());
818 }
819 Ok(())
820}
821
822pub(crate) fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), String> {
823 let parent = path
824 .parent()
825 .ok_or_else(|| format!("{} has no parent directory", path.display()))?;
826 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
827 reject_symlink_if_present(parent)?;
828 reject_symlink_if_present(path)?;
829 let name = path
830 .file_name()
831 .and_then(|n| n.to_str())
832 .ok_or_else(|| format!("{} has no valid file name", path.display()))?;
833 let tmp = parent.join(format!(".{name}.tmp"));
834 let mut file = std::fs::OpenOptions::new()
835 .write(true)
836 .create_new(true)
837 .open(&tmp)
838 .map_err(|e| format!("{}: {e}", tmp.display()))?;
839 let result = (|| {
840 file.write_all(bytes).map_err(|e| e.to_string())?;
841 file.sync_all().map_err(|e| e.to_string())?;
842 drop(file);
843 std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
844 #[cfg(unix)]
845 std::fs::File::open(parent)
846 .and_then(|dir| dir.sync_all())
847 .map_err(|e| e.to_string())?;
848 Ok::<(), String>(())
849 })();
850 if result.is_err() {
851 let _ = std::fs::remove_file(&tmp);
852 }
853 result.map_err(|e| format!("{}: {e}", path.display()))
854}
855
856fn release_api_url(version: Option<&str>) -> String {
860 match version {
861 None => GITHUB_API_RELEASES.to_string(),
862 Some(v) => {
863 let core = v.trim_start_matches('v');
864 format!("https://api.github.com/repos/yvgude/lean-ctx/releases/tags/v{core}")
865 }
866 }
867}
868
869fn https_agent() -> ureq::Agent {
873 crate::core::http_client::ureq_agent_with_timeouts(
880 Some(std::time::Duration::from_secs(15)),
881 Some(std::time::Duration::from_secs(20)),
882 Some(std::time::Duration::from_secs(30)),
883 )
884}
885
886fn fetch_release(version: Option<&str>) -> Result<serde_json::Value, String> {
889 github_api_json(&release_api_url(version))
890}
891
892fn parse_target_version(args: &[String]) -> Option<&str> {
896 args.iter()
897 .map(String::as_str)
898 .find(|a| !a.starts_with('-'))
899}
900
901fn looks_like_version(s: &str) -> bool {
904 let core = s.strip_prefix('v').unwrap_or(s);
905 core.contains('.')
906 && core.starts_with(|c: char| c.is_ascii_digit())
907 && core
908 .chars()
909 .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c.is_ascii_alphabetic())
910}
911
912fn find_asset_url(release: &serde_json::Value, asset_name: &str) -> Option<String> {
913 release["assets"]
914 .as_array()?
915 .iter()
916 .find(|a| a["name"].as_str() == Some(asset_name))
917 .and_then(|a| a["browser_download_url"].as_str())
918 .map(std::string::ToString::to_string)
919}
920
921fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
922 if !url.starts_with("https://github.com/yvgude/lean-ctx/") {
923 return Err(format!("refusing release asset from unexpected URL: {url}"));
924 }
925 let response = https_agent()
926 .get(url)
927 .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
928 .call()
929 .map_err(|e| e.to_string())?;
930
931 let mut bytes = Vec::new();
932 response
933 .into_body()
934 .into_reader()
935 .read_to_end(&mut bytes)
936 .map_err(|e| e.to_string())?;
937 Ok(bytes)
938}
939
940fn extract_binary(archive_bytes: &[u8], asset_name: &str) -> Result<Vec<u8>, String> {
941 if Path::new(asset_name)
942 .extension()
943 .is_some_and(|e| e.eq_ignore_ascii_case("zip"))
944 {
945 extract_from_zip(archive_bytes)
946 } else {
947 extract_from_tar_gz(archive_bytes)
948 }
949}
950
951fn sign_staged_binary(staged_path: &std::path::Path, bytes: &[u8]) -> Result<Vec<u8>, String> {
957 #[cfg(target_os = "macos")]
958 let installed = {
959 let _ = bytes;
960 crate::core::codesign::sign_binary(staged_path)
964 .map_err(|stopped| stopped.to_string())
965 .and_then(|_| {
966 std::fs::read(staged_path)
967 .map_err(|e| format!("cannot read signed staged binary: {e}"))
968 })
969 };
970 #[cfg(not(target_os = "macos"))]
971 let installed = {
972 let _ = staged_path;
973 Ok(bytes.to_vec())
974 };
975 installed
976}
977
978fn replace_staged_binary(
979 staged_path: &std::path::Path,
980 current_exe: &std::path::Path,
981) -> Result<(), String> {
982 reject_symlink_if_present(staged_path)?;
983 let staged = std::fs::metadata(staged_path).map_err(|e| {
984 format!(
985 "cannot inspect staged binary {}: {e}",
986 staged_path.display()
987 )
988 })?;
989 if staged.len() == 0 {
990 return Err("refusing to install an empty staged binary".to_string());
991 }
992 #[cfg(unix)]
993 {
994 use std::os::unix::fs::PermissionsExt;
995 let _ = std::fs::set_permissions(staged_path, std::fs::Permissions::from_mode(0o755));
996 }
997
998 #[cfg(windows)]
1003 {
1004 let old_path = windows_sidecar::clear_sidecar(current_exe)?;
1007
1008 if let Ok(()) = std::fs::rename(current_exe, &old_path) {
1009 if let Err(e) = std::fs::rename(staged_path, current_exe) {
1010 let _ = std::fs::rename(&old_path, current_exe);
1011 return Err(format!("Cannot place new binary: {e}"));
1012 }
1013 Ok(())
1014 } else {
1015 eprintln!("\nBinary is locked. Stopping managed lean-ctx processes...");
1017 stop_managed_windows_processes();
1018
1019 std::thread::sleep(std::time::Duration::from_millis(1500));
1021
1022 if let Ok(()) = std::fs::rename(current_exe, &old_path) {
1024 if let Err(e) = std::fs::rename(staged_path, current_exe) {
1025 let _ = std::fs::rename(&old_path, current_exe);
1026 return Err(format!("Cannot place new binary: {e}"));
1027 }
1028 Ok(())
1029 } else {
1030 print_blocking_processes(current_exe);
1032 deferred_windows_update(staged_path, current_exe)
1033 }
1034 }
1035 }
1036
1037 #[cfg(not(windows))]
1038 {
1039 std::fs::rename(staged_path, current_exe).map_err(|e| {
1045 let _ = std::fs::remove_file(staged_path);
1046 format!("Cannot replace binary (permission denied?): {e}")
1047 })?;
1048 Ok(())
1049 }
1050}
1051
1052#[cfg(windows)]
1055fn stop_managed_windows_processes() {
1056 let stop_result = std::process::Command::new("lean-ctx").arg("stop").output();
1058
1059 match stop_result {
1064 Ok(out) if out.status.success() => {
1065 eprintln!(" Managed processes stopped.");
1066 }
1067 _ => {
1068 eprintln!(" Could not stop every lean-ctx service (see `lean-ctx stop`).");
1069 }
1070 }
1071}
1072
1073#[cfg(windows)]
1075fn print_blocking_processes(target_exe: &std::path::Path) {
1076 let target_name = target_exe
1077 .file_name()
1078 .and_then(|n| n.to_str())
1079 .unwrap_or("lean-ctx.exe");
1080
1081 let output = std::process::Command::new("tasklist")
1082 .args([
1083 "/FI",
1084 &format!("IMAGENAME eq {target_name}"),
1085 "/V",
1086 "/FO",
1087 "CSV",
1088 ])
1089 .output();
1090
1091 if let Ok(out) = output {
1092 let stdout = String::from_utf8_lossy(&out.stdout);
1093 let lines: Vec<&str> = stdout.lines().skip(1).collect(); if !lines.is_empty() {
1095 eprintln!("\n Blocking lean-ctx processes:");
1096 for line in &lines {
1097 let fields: Vec<&str> = line.split(',').collect();
1099 if fields.len() >= 2 {
1100 let pid = fields[1].trim_matches('"');
1101 eprintln!(" PID {pid}");
1102 }
1103 }
1104 eprintln!("\n To stop manually: taskkill /F /PID <pid> (or close your editor)");
1105 }
1106 }
1107}
1108
1109#[cfg(windows)]
1113fn deferred_windows_update(
1114 staged_path: &std::path::Path,
1115 target_exe: &std::path::Path,
1116) -> Result<(), String> {
1117 let target_str = target_exe.display().to_string();
1118 let staged_str = staged_path.display().to_string();
1119 let old_path = windows_sidecar::clear_sidecar(target_exe)?;
1120 let old_str = old_path.display().to_string();
1121 let transaction_str = update_transaction_path()?.display().to_string();
1122 let lock_str = update_lock_path()?.display().to_string();
1123 let max_retries = 60;
1124
1125 let script = generate_deferred_bat_script(
1126 &target_str,
1127 &staged_str,
1128 &old_str,
1129 &transaction_str,
1130 &lock_str,
1131 max_retries,
1132 );
1133
1134 let script_path = target_exe.with_file_name("lean-ctx-update.bat");
1135 reject_symlink_if_present(&script_path)?;
1136 std::fs::write(&script_path, &script)
1137 .map_err(|e| format!("Cannot write update script: {e}"))?;
1138
1139 let _ = std::process::Command::new("cmd")
1140 .args(["/C", "start", "/MIN", &script_path.display().to_string()])
1141 .spawn();
1142
1143 println!("\nThe binary is still in use (likely by your editor's MCP server).");
1144 println!("A background update has been scheduled (timeout: {max_retries}s).");
1145 println!("Close your editor and the update will complete automatically.");
1146 println!("\nIf it times out, run: lean-ctx update");
1147 println!("Update script: {}", script_path.display());
1148
1149 Err("update deferred until the running Windows binary is released".to_string())
1152}
1153
1154fn extract_from_tar_gz(data: &[u8]) -> Result<Vec<u8>, String> {
1155 use flate2::read::GzDecoder;
1156
1157 let gz = GzDecoder::new(data);
1158 let mut archive = tar::Archive::new(gz);
1159
1160 for entry in archive.entries().map_err(|e| e.to_string())? {
1161 let mut entry = entry.map_err(|e| e.to_string())?;
1162 let path = entry.path().map_err(|e| e.to_string())?;
1163 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1164
1165 if name == "lean-ctx" || name == "lean-ctx.exe" {
1166 let mut bytes = Vec::new();
1167 entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
1168 return Ok(bytes);
1169 }
1170 }
1171 Err("lean-ctx binary not found inside archive".to_string())
1172}
1173
1174fn extract_from_zip(data: &[u8]) -> Result<Vec<u8>, String> {
1175 use std::io::Cursor;
1176
1177 let cursor = Cursor::new(data);
1178 let mut zip = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
1179
1180 for i in 0..zip.len() {
1181 let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
1182 let name = file.name().map_err(|e| e.to_string())?.into_owned();
1183 if name == "lean-ctx.exe" || name == "lean-ctx" {
1184 let mut bytes = Vec::new();
1185 file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
1186 return Ok(bytes);
1187 }
1188 }
1189 Err("lean-ctx binary not found inside zip archive".to_string())
1190}
1191
1192#[cfg(any(windows, test))]
1194fn generate_deferred_bat_script(
1195 target: &str,
1196 staged: &str,
1197 old: &str,
1198 transaction: &str,
1199 lock: &str,
1200 max_retries: u32,
1201) -> String {
1202 format!(
1203 r#"@echo off
1204setlocal
1205set "RETRIES=0"
1206set "MAX_RETRIES={max_retries}"
1207set "LEANCTX_UPDATE_LOCK={lock}"
1208set "LEANCTX_UPDATE_TARGET={target}"
1209set "LEANCTX_UPDATE_STAGED={staged}"
1210set "LEANCTX_UPDATE_OLD={old}"
1211set "LEANCTX_UPDATE_TRANSACTION={transaction}"
1212
1213echo lean-ctx update: waiting for binary to be released (timeout: %MAX_RETRIES%s)...
1214echo.
1215echo Blocking processes:
1216tasklist /FI "IMAGENAME eq lean-ctx.exe" /V /NH 2>nul
1217echo.
1218echo Close your editor (Cursor, VS Code, etc.) to release the binary,
1219echo or stop manually: lean-ctx stop
1220echo.
1221
1222:retry
1223if %RETRIES% GEQ %MAX_RETRIES% goto timeout
1224set /a RETRIES+=1
1225timeout /t 1 /nobreak >nul
1226powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ^
1227 "$ErrorActionPreference = 'Stop'; $lock = $null; $targetMoved = $false; $stagedMoved = $false; try {{ $lock = [System.IO.File]::Open($env:LEANCTX_UPDATE_LOCK, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None); Move-Item -LiteralPath $env:LEANCTX_UPDATE_TARGET -Destination $env:LEANCTX_UPDATE_OLD -Force; $targetMoved = $true; Move-Item -LiteralPath $env:LEANCTX_UPDATE_STAGED -Destination $env:LEANCTX_UPDATE_TARGET -Force; $stagedMoved = $true; & $env:LEANCTX_UPDATE_TARGET update --recover-update --lock-held; if ($LASTEXITCODE -ne 0) {{ throw 'receipt recovery failed' }}; if (Test-Path -LiteralPath $env:LEANCTX_UPDATE_OLD) {{ Remove-Item -LiteralPath $env:LEANCTX_UPDATE_OLD -Force -ErrorAction SilentlyContinue }} }} catch {{ if ($stagedMoved -and (Test-Path -LiteralPath $env:LEANCTX_UPDATE_TARGET) -and -not (Test-Path -LiteralPath $env:LEANCTX_UPDATE_STAGED)) {{ Move-Item -LiteralPath $env:LEANCTX_UPDATE_TARGET -Destination $env:LEANCTX_UPDATE_STAGED -Force }}; if ($targetMoved -and (Test-Path -LiteralPath $env:LEANCTX_UPDATE_OLD) -and -not (Test-Path -LiteralPath $env:LEANCTX_UPDATE_TARGET)) {{ Move-Item -LiteralPath $env:LEANCTX_UPDATE_OLD -Destination $env:LEANCTX_UPDATE_TARGET -Force }}; exit 1 }} finally {{ if ($null -ne $lock) {{ $lock.Dispose() }} }}"
1228if errorlevel 1 (
1229 if %RETRIES% EQU 10 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s)
1230 if %RETRIES% EQU 30 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s) — try closing your editor
1231 if %RETRIES% EQU 50 echo Still waiting... (%RETRIES%/%MAX_RETRIES%s) — timeout approaching
1232 goto retry
1233)
1234
1235echo.
1236echo Updated successfully!
1237goto cleanup
1238
1239:timeout
1240echo.
1241echo Update timed out after %MAX_RETRIES% seconds.
1242echo The new binary is staged at: {staged}
1243echo.
1244echo To complete the update manually:
1245echo 1. Close your editor (Cursor, VS Code, etc.)
1246echo 2. Run: lean-ctx update
1247echo.
1248echo Or run: lean-ctx update --force
1249echo.
1250pause
1251exit /b 1
1252
1253:cleanup
1254del "%~f0" >nul 2>&1
1255"#
1256 )
1257}
1258
1259#[cfg(test)]
1260#[path = "updater/tests.rs"]
1261mod tests;