1use std::collections::HashMap;
17use std::io::Read;
18use std::path::{Path, PathBuf};
19
20use anyhow::{bail, Context, Result};
21
22pub const RELEASE_REPO: &str = "leidarljos/ljos";
24
25const RELEASE_OWNER: &str = "leidarljos";
27
28pub const BINARIES: &[&str] = &["ljos", "ljos-mcp", "ljos-hud"];
31
32pub struct Component {
34 pub name: &'static str,
36 pub repo: &'static str,
38 pub asset: &'static str,
41}
42
43pub const COMPONENTS: &[Component] = &[
50 Component {
53 name: "packset",
54 repo: "packset",
55 asset: "packset-v{version}-{target}.tar.gz",
56 },
57 Component {
58 name: "packsetd",
59 repo: "packset",
60 asset: "packset-v{version}-{target}.tar.gz",
61 },
62 Component {
63 name: "packset-mcp",
64 repo: "packset",
65 asset: "packset-v{version}-{target}.tar.gz",
66 },
67 Component {
68 name: "packset-embed",
69 repo: "packset",
70 asset: "packset-v{version}-{target}.tar.gz",
71 },
72 Component {
77 name: "vissue",
78 repo: "vissue",
79 asset: "vissue-v{version}-{target}.tar.gz",
80 },
81 Component {
82 name: "vissue-mcp",
83 repo: "vissue",
84 asset: "vissue-v{version}-{target}.tar.gz",
85 },
86 Component {
87 name: "deedar",
88 repo: "deedar",
89 asset: "deedar-v{version}-{target}.tar.gz",
90 },
91 Component {
92 name: "claimdag",
93 repo: "claimdag",
94 asset: "claimdag-v{version}-{target}.tar.gz",
95 },
96 Component {
97 name: "ljos-policyd",
98 repo: "ljos-policyd",
99 asset: "ljos-policyd-v{version}-{target}.tar.gz",
100 },
101 Component {
104 name: "ljos-consensus",
105 repo: "consensus",
106 asset: "consensus-v{version}-{target}.tar.gz",
107 },
108];
109
110#[must_use]
114pub fn target_for(arch: &str, os: &str) -> Option<&'static str> {
115 match (arch, os) {
116 ("x86_64", "linux") => Some("x86_64-unknown-linux-gnu"),
117 ("aarch64", "linux") => Some("aarch64-unknown-linux-gnu"),
118 ("x86_64", "macos") => Some("x86_64-apple-darwin"),
119 ("aarch64", "macos") => Some("aarch64-apple-darwin"),
120 _ => None,
121 }
122}
123
124#[must_use]
126pub fn target() -> Option<&'static str> {
127 target_for(std::env::consts::ARCH, std::env::consts::OS)
128}
129
130#[must_use]
133pub fn asset_name(version: &str, target: &str) -> String {
134 format!("ljos-v{}-{target}.tar.gz", version.trim_start_matches('v'))
135}
136
137fn asset_of(pattern: &str, version: &str, target: &str) -> String {
139 pattern
140 .replace("{version}", version.trim_start_matches('v'))
141 .replace("{target}", target)
142}
143
144#[must_use]
147pub fn digest_of(line: &str) -> Option<String> {
148 let d = line.split_whitespace().next()?.to_ascii_lowercase();
149 (d.len() == 64 && d.chars().all(|c| c.is_ascii_hexdigit())).then_some(d)
150}
151
152#[must_use]
158pub fn names_version(output: &str, binary: &str, version: &str) -> bool {
159 let want = format!("{binary} {}", version.trim_start_matches('v'));
160 output.lines().next().is_some_and(|line| {
161 let line = line.trim();
162 match line.strip_prefix(&want) {
163 Some(rest) => rest.is_empty() || rest.starts_with(char::is_whitespace),
164 None => false,
165 }
166 })
167}
168
169fn is_ljos_name(name: &str) -> bool {
170 BINARIES.contains(&name)
171}
172
173fn is_seat_component(name: &str) -> bool {
174 COMPONENTS.iter().any(|c| c.name == name)
175}
176
177fn known_names() -> String {
178 BINARIES
179 .iter()
180 .copied()
181 .chain(COMPONENTS.iter().map(|c| c.name))
182 .collect::<Vec<_>>()
183 .join(", ")
184}
185
186fn already_installed(dir: &Path, version: &str) -> bool {
188 let mut saw = false;
189 for name in BINARIES {
190 let installed = dir.join(name);
191 if *name == "ljos-hud" && !installed.exists() {
192 continue;
193 }
194 if !installed.is_file() {
195 return false;
196 }
197 if !reports(&installed, name, version) {
198 return false;
199 }
200 saw = true;
201 }
202 saw
203}
204
205fn keep_rollback(name: &str, installed: &Path, dir: &Path, version: &str) -> Result<()> {
209 if !installed.exists() {
210 return Ok(());
211 }
212 let keep = dir.join(format!("{name}.bak-pre-{version}"));
213 if keep.is_file() {
214 if let Ok(said) = run_ok(&keep.display().to_string(), &["--version"]) {
215 if !names_version(&said, name, version) {
216 return Ok(());
217 }
218 }
219 }
220 std::fs::copy(installed, &keep)?;
221 Ok(())
222}
223
224fn release_dir() -> Option<PathBuf> {
225 std::env::var_os("LJOS_RELEASE_DIR")
226 .filter(|d| !d.is_empty())
227 .map(PathBuf::from)
228}
229
230fn obtain(name: &str, to: &Path, url: &str) -> Result<()> {
233 if let Some(dir) = release_dir() {
234 let src = dir.join(name);
235 std::fs::copy(&src, to).with_context(|| format!("upgrade: copy {}", src.display()))?;
236 return Ok(());
237 }
238 fetch(url, to)
239}
240
241fn fetch(url: &str, to: &Path) -> Result<()> {
242 let resp = ureq::get(url)
243 .timeout(std::time::Duration::from_secs(120))
244 .call()
245 .with_context(|| format!("upgrade: GET {url}"))?;
246 let mut body = Vec::new();
247 resp.into_reader()
248 .take(256 * 1024 * 1024)
249 .read_to_end(&mut body)
250 .with_context(|| format!("upgrade: reading {url}"))?;
251 std::fs::write(to, body)?;
252 Ok(())
253}
254
255fn latest_release(repo: &str) -> Result<Option<String>> {
259 if let Some(dir) = release_dir() {
260 let path = dir.join(format!("{repo}.version"));
261 if !path.is_file() {
262 return Ok(None);
263 }
264 let text = std::fs::read_to_string(&path)
265 .with_context(|| format!("upgrade: reading {}", path.display()))?;
266 let v = text.trim().trim_start_matches('v');
267 return Ok((!v.is_empty()).then(|| v.to_string()));
268 }
269 let url = format!("https://api.github.com/repos/{RELEASE_OWNER}/{repo}/releases/latest");
270 let resp = ureq::get(&url)
271 .set("Accept", "application/vnd.github+json")
272 .timeout(std::time::Duration::from_secs(30))
273 .call();
274 let resp = match resp {
275 Ok(resp) => resp,
276 Err(ureq::Error::Status(404, _)) => return Ok(None),
277 Err(err) => {
278 return Err(err).context(format!("upgrade: asking {repo} for its latest release"));
279 }
280 };
281 let body: serde_json::Value = resp
282 .into_json()
283 .with_context(|| format!("upgrade: reading {repo}'s latest release"))?;
284 Ok(body["tag_name"]
285 .as_str()
286 .map(|t| t.trim().trim_start_matches('v').to_string())
287 .filter(|t| !t.is_empty()))
288}
289
290fn latest() -> Result<String> {
292 latest_release("ljos")?.context("upgrade: the latest release has no tag")
293}
294
295fn run_ok(cmd: &str, args: &[&str]) -> Result<String> {
296 let out = std::process::Command::new(cmd)
297 .args(args)
298 .stdin(std::process::Stdio::null())
299 .output()
300 .with_context(|| format!("upgrade: running {cmd}"))?;
301 if !out.status.success() {
302 bail!(
303 "upgrade: {cmd} failed: {}",
304 String::from_utf8_lossy(&out.stderr).trim()
305 );
306 }
307 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
308}
309
310fn reports(path: &Path, name: &str, version: &str) -> bool {
311 run_ok(&path.display().to_string(), &["--version"])
312 .ok()
313 .is_some_and(|said| names_version(&said, name, version))
314}
315
316fn old_version(path: &Path) -> String {
319 if !path.is_file() {
320 return "absent".to_string();
321 }
322 run_ok(&path.display().to_string(), &["--version"])
323 .ok()
324 .and_then(|said| crate::parse_semver(&said).map(str::to_string))
325 .unwrap_or_else(|| "unknown".to_string())
326}
327
328fn executable_image(bytes: &[u8]) -> bool {
329 bytes.starts_with(b"\x7fELF") || bytes.starts_with(&[0xcf, 0xfa, 0xed, 0xfe])
330}
331
332fn one_line(err: &anyhow::Error) -> String {
333 format!("{err:#}")
334 .split_whitespace()
335 .collect::<Vec<_>>()
336 .join(" ")
337}
338
339fn resolve_dir(dir: Option<&Path>) -> Result<PathBuf> {
340 match dir {
341 Some(d) => Ok(d.to_path_buf()),
342 None => Ok(std::env::current_exe()?
343 .canonicalize()?
344 .parent()
345 .context("upgrade: the running ljos has no directory")?
346 .to_path_buf()),
347 }
348}
349
350fn work_dir(label: &str) -> Result<PathBuf> {
351 let work = std::env::var_os("XDG_CACHE_HOME")
354 .filter(|v| !v.is_empty())
355 .map(PathBuf::from)
356 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
357 .unwrap_or_else(std::env::temp_dir)
358 .join("ljos")
359 .join(format!("upgrade-{label}-{}", std::process::id()));
360 let _ = std::fs::remove_dir_all(&work);
361 std::fs::create_dir_all(&work)?;
362 Ok(work)
363}
364
365fn upgrade_ljos(version: Option<&str>, dir: &Path, only: Option<&str>) -> Result<String> {
368 let version = match version {
369 Some(v) => v.trim_start_matches('v').to_string(),
370 None => latest()?,
371 };
372 let target = target().context("upgrade: no published archive for this machine")?;
373 if let Some(name) = only {
374 if dir.join(name).is_file() && reports(&dir.join(name), name, &version) {
375 return Ok(format!("{name} {version} current\n"));
376 }
377 } else if already_installed(dir, &version) {
378 return Ok(format!(
379 "ljos {version} is already installed at {}\n",
380 dir.display()
381 ));
382 }
383 let asset = asset_name(&version, target);
384 let base = format!("https://github.com/{RELEASE_REPO}/releases/download/v{version}");
385 let work = work_dir("ljos")?;
386 let archive = work.join(&asset);
387 let sum = work.join(format!("{asset}.sha256"));
388 obtain(&asset, &archive, &format!("{base}/{asset}"))?;
389 obtain(
390 &format!("{asset}.sha256"),
391 &sum,
392 &format!("{base}/{asset}.sha256"),
393 )?;
394 let want = digest_of(&std::fs::read_to_string(&sum)?)
395 .context("upgrade: the published checksum is not a sha256 line")?;
396 let have = digest_of(&run_ok("sha256sum", &[&archive.display().to_string()])?)
397 .context("upgrade: sha256sum gave no digest")?;
398 if want != have {
399 bail!(
400 "upgrade: {asset} has sha256 {have}, the release publishes {want}; nothing installed"
401 );
402 }
403 run_ok(
404 "tar",
405 &[
406 "-xzf",
407 &archive.display().to_string(),
408 "-C",
409 &work.display().to_string(),
410 ],
411 )?;
412 let unpacked = work.join(asset.trim_end_matches(".tar.gz"));
413 let mut ready = Vec::new();
415 for name in BINARIES {
416 if only.is_some_and(|want| want != *name) {
417 continue;
418 }
419 let new = unpacked.join(name);
420 let installed = dir.join(name);
421 if !new.is_file() {
422 if *name == "ljos-hud" && only != Some("ljos-hud") {
423 continue;
424 }
425 bail!("upgrade: {asset} carries no {name}; nothing installed");
426 }
427 if *name == "ljos-hud" && !installed.exists() && only.is_none() {
428 continue;
429 }
430 let bytes = std::fs::read(&new)?;
431 if !executable_image(&bytes) {
432 bail!("upgrade: {name} in {asset} is not an executable; nothing installed");
433 }
434 let said = run_ok(&new.display().to_string(), &["--version"])?;
435 if !names_version(&said, name, &version) {
436 bail!(
437 "upgrade: {name} in {asset} says {:?}, not {name} {version}; nothing installed",
438 said.lines().next().unwrap_or("")
439 );
440 }
441 ready.push((*name, new, installed));
442 }
443 let mut out = String::new();
444 for (name, new, installed) in ready {
445 let old = old_version(&installed);
446 keep_rollback(name, &installed, dir, &version)?;
447 let staged = dir.join(format!("{name}.new"));
448 std::fs::copy(&new, &staged)?;
449 #[cfg(unix)]
450 {
451 use std::os::unix::fs::PermissionsExt;
452 std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?;
453 }
454 std::fs::rename(&staged, &installed)?;
455 if only.is_some() {
456 out.push_str(&format!("{name} {old} {version}\n"));
457 } else {
458 out.push_str(&format!(
459 "installed {name} {version} at {}\n",
460 installed.display()
461 ));
462 }
463 }
464 let _ = std::fs::remove_dir_all(&work);
465 if only.is_none() {
466 out.push_str(&format!(
467 "from {base}/{asset}, sha256 {have}; the old binaries are kept as NAME.bak-pre-{version}\n"
468 ));
469 }
470 Ok(out)
471}
472
473fn find_named(root: &Path, name: &str) -> Option<PathBuf> {
475 let mut stack = vec![root.to_path_buf()];
476 while let Some(dir) = stack.pop() {
477 let entries = std::fs::read_dir(&dir).ok()?;
478 for entry in entries.flatten() {
479 let path = entry.path();
480 let Ok(kind) = entry.file_type() else {
481 continue;
482 };
483 if kind.is_symlink() {
484 continue;
485 }
486 if kind.is_dir() {
487 stack.push(path);
488 } else if entry.file_name() == name {
489 return Some(path);
490 }
491 }
492 }
493 None
494}
495
496fn tar_flag(asset: &str) -> Result<&'static str, String> {
497 if asset.ends_with(".tar.gz") {
498 Ok("-xzf")
499 } else if asset.ends_with(".tar.xz") {
500 Ok("-xJf")
501 } else {
502 Err(format!("{asset} is not a tar.gz or tar.xz archive"))
503 }
504}
505
506fn fetch_tree(repo: &str, pattern: &str, version: &str, work: &Path) -> Result<PathBuf, String> {
508 let target = target().ok_or_else(|| "no published archive for this machine".to_string())?;
509 let asset = asset_of(pattern, version, target);
510 let archive = work.join(&asset);
511 let sum = work.join(format!("{asset}.sha256"));
512 let base = format!("https://github.com/{RELEASE_OWNER}/{repo}/releases/download/v{version}");
513 obtain(&asset, &archive, &format!("{base}/{asset}")).map_err(|e| one_line(&e))?;
514 obtain(
515 &format!("{asset}.sha256"),
516 &sum,
517 &format!("{base}/{asset}.sha256"),
518 )
519 .map_err(|e| one_line(&e))?;
520 let text =
521 std::fs::read_to_string(&sum).map_err(|e| format!("reading {}: {e}", sum.display()))?;
522 let want = digest_of(&text).ok_or_else(|| format!("{asset}.sha256 is not a sha256 line"))?;
523 let summed =
524 run_ok("sha256sum", &[&archive.display().to_string()]).map_err(|e| one_line(&e))?;
525 let have = digest_of(&summed).ok_or_else(|| "sha256sum gave no digest".to_string())?;
526 if want != have {
527 return Err(format!(
528 "checksum mismatch (archive {have}, release {want})"
529 ));
530 }
531 let flag = tar_flag(&asset)?;
532 let unpack = work.join(format!("x-{asset}"));
533 let _ = std::fs::remove_dir_all(&unpack);
534 std::fs::create_dir_all(&unpack).map_err(|e| e.to_string())?;
535 run_ok(
536 "tar",
537 &[
538 flag,
539 &archive.display().to_string(),
540 "-C",
541 &unpack.display().to_string(),
542 ],
543 )
544 .map_err(|e| one_line(&e))?;
545 Ok(unpack)
546}
547
548fn place_component(dir: &Path, name: &str, version: &str, tree: &Path) -> Result<String, String> {
549 let new = find_named(tree, name).ok_or_else(|| format!("the archive holds no {name}"))?;
550 let bytes = std::fs::read(&new).map_err(|e| e.to_string())?;
551 if !executable_image(&bytes) {
552 return Err(format!("{name} in the archive is not an executable"));
553 }
554 let said = run_ok(&new.display().to_string(), &["--version"]).map_err(|e| one_line(&e))?;
555 if !names_version(&said, name, version) {
556 let got = said.lines().next().unwrap_or("").trim();
557 return Err(format!(
558 "{name} in the archive says {got:?}, not {name} {version}"
559 ));
560 }
561 let installed = dir.join(name);
562 let old = old_version(&installed);
563 keep_rollback(name, &installed, dir, version).map_err(|e| one_line(&e))?;
564 let staged = dir.join(format!("{name}.new"));
565 std::fs::copy(&new, &staged).map_err(|e| e.to_string())?;
566 #[cfg(unix)]
567 {
568 use std::os::unix::fs::PermissionsExt;
569 std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
570 .map_err(|e| e.to_string())?;
571 }
572 std::fs::rename(&staged, &installed).map_err(|e| e.to_string())?;
573 Ok(format!("{name} {old} {version}\n"))
574}
575
576fn upgrade_components(dir: &Path, only: Option<&str>) -> Result<String> {
578 let mut out = String::new();
579 let mut versions: HashMap<String, Result<Option<String>, String>> = HashMap::new();
580 let mut trees: HashMap<(String, String), Result<PathBuf, String>> = HashMap::new();
581 let mut work: Option<PathBuf> = None;
582 for component in COMPONENTS {
583 if only.is_some_and(|name| name != component.name) {
584 continue;
585 }
586 if only.is_none() && !dir.join(component.name).is_file() {
587 continue;
588 }
589 let published = if let Some(hit) = versions.get(component.repo) {
590 hit.clone()
591 } else {
592 let got = match latest_release(component.repo) {
593 Ok(v) => Ok(v),
594 Err(err) => Err(one_line(&err)),
595 };
596 versions.insert(component.repo.to_string(), got.clone());
597 got
598 };
599 let version = match published {
600 Ok(Some(v)) => v,
601 Ok(None) => {
602 out.push_str(&format!("warning: {}: no release\n", component.name));
603 continue;
604 }
605 Err(reason) => {
606 out.push_str(&format!("warning: {}: {reason}\n", component.name));
607 continue;
608 }
609 };
610 let installed = dir.join(component.name);
611 if installed.is_file() && reports(&installed, component.name, &version) {
612 out.push_str(&format!("{} {version} current\n", component.name));
613 continue;
614 }
615 let key = (component.repo.to_string(), component.asset.to_string());
616 if !trees.contains_key(&key) {
617 let fetched = (|| {
618 let slot = match work {
619 Some(ref dir) => dir.clone(),
620 None => {
621 let made = work_dir("components").map_err(|e| one_line(&e))?;
622 work = Some(made.clone());
623 made
624 }
625 };
626 fetch_tree(component.repo, component.asset, &version, &slot)
627 })();
628 trees.insert(key.clone(), fetched);
629 }
630 match trees.get(&key) {
631 Some(Ok(tree)) => match place_component(dir, component.name, &version, tree) {
632 Ok(line) => out.push_str(&line),
633 Err(reason) => {
634 let old = old_version(&installed);
635 out.push_str(&format!(
636 "warning: {}: {reason}; kept {old}\n",
637 component.name
638 ));
639 }
640 },
641 Some(Err(reason)) => {
642 let old = old_version(&installed);
643 out.push_str(&format!(
644 "warning: {}: {reason}; kept {old}\n",
645 component.name
646 ));
647 }
648 None => {}
649 }
650 }
651 if let Some(dir) = &work {
652 let _ = std::fs::remove_dir_all(dir);
653 }
654 Ok(out)
655}
656
657pub fn upgrade(version: Option<&str>, dir: Option<&Path>, only: Option<&str>) -> Result<String> {
674 if let Some(name) = only {
675 if !is_ljos_name(name) && !is_seat_component(name) {
676 bail!(
677 "upgrade: --only {name} is not a component: {}",
678 known_names()
679 );
680 }
681 }
682 let dir = resolve_dir(dir)?;
683 let mut out = String::new();
684 if only.is_none_or(is_ljos_name) {
685 out.push_str(&upgrade_ljos(version, &dir, only)?);
686 }
687 if only.is_none_or(is_seat_component) {
688 out.push_str(&upgrade_components(&dir, only)?);
689 }
690 Ok(out)
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 #[test]
698 fn each_published_target_has_its_archive_triple() {
699 assert_eq!(
700 target_for("x86_64", "linux"),
701 Some("x86_64-unknown-linux-gnu")
702 );
703 assert_eq!(
704 target_for("aarch64", "linux"),
705 Some("aarch64-unknown-linux-gnu")
706 );
707 assert_eq!(target_for("x86_64", "macos"), Some("x86_64-apple-darwin"));
708 assert_eq!(target_for("aarch64", "macos"), Some("aarch64-apple-darwin"));
709 }
710
711 #[test]
712 fn the_archive_name_and_its_checks_are_the_release_workflow_s() {
713 assert_eq!(
714 asset_name("0.22.3", "x86_64-unknown-linux-gnu"),
715 "ljos-v0.22.3-x86_64-unknown-linux-gnu.tar.gz"
716 );
717 assert_eq!(
718 asset_name("v0.22.3", "aarch64-apple-darwin"),
719 "ljos-v0.22.3-aarch64-apple-darwin.tar.gz"
720 );
721 let d = "d6e305288c898a34ec65e8f7c067bb680e77f49eeb041692be2e6be2e68d0f85";
722 assert_eq!(
723 digest_of(&format!("{d} ljos-v0.22.3.tar.gz\n")).as_deref(),
724 Some(d)
725 );
726 assert_eq!(digest_of("not a digest"), None);
727 assert_eq!(digest_of(&d[..63]), None, "a short digest is no digest");
728 assert!(names_version("ljos 0.22.3\n", "ljos", "0.22.3"));
729 assert!(names_version("ljos-mcp 0.22.3", "ljos-mcp", "v0.22.3"));
730 assert!(!names_version("ljos 0.22.2", "ljos", "0.22.3"));
731 assert!(
732 !names_version("#!/usr/bin/env python3", "ljos", "0.22.3"),
733 "a script is not the release"
734 );
735 assert!(
736 names_version("ljos-policyd 0.7.0 (phronesis)\n", "ljos-policyd", "0.7.0"),
737 "a build note after the version is still that version"
738 );
739 assert!(names_version(
740 "packset 0.17.3 (abc123)\n",
741 "packset",
742 "v0.17.3"
743 ));
744 assert!(
745 !names_version("ljos 0.22.30\n", "ljos", "0.22.3"),
746 "a longer version is a different version"
747 );
748 let deedar = COMPONENTS.iter().find(|c| c.name == "deedar").unwrap();
749 assert_eq!(
750 asset_of(deedar.asset, "0.6.1", "x86_64-unknown-linux-gnu"),
751 "deedar-v0.6.1-x86_64-unknown-linux-gnu.tar.gz"
752 );
753 let vissue = COMPONENTS.iter().find(|c| c.name == "vissue").unwrap();
754 assert_eq!(
755 asset_of(vissue.asset, "0.23.1", "x86_64-unknown-linux-gnu"),
756 "vissue-v0.23.1-x86_64-unknown-linux-gnu.tar.gz"
757 );
758 let consensus = COMPONENTS
759 .iter()
760 .find(|c| c.name == "ljos-consensus")
761 .unwrap();
762 assert_eq!(
763 asset_of(consensus.asset, "v0.9.0", "aarch64-apple-darwin"),
764 "consensus-v0.9.0-aarch64-apple-darwin.tar.gz"
765 );
766 }
767}