Skip to main content

ljos_cli/
upgrade.rs

1//! `ljos upgrade` replaces the seat binaries with published releases.
2//!
3//! The seat guard refuses an agent write to its own binaries, since an
4//! agent that may rewrite them may rewrite the law.
5//! The binaries still have to move forward, so this verb installs only what
6//! a release workflow built and published.
7//! The ljos archive comes from this repository.
8//! Each other program comes from the repository that publishes it.
9//! The archive sha256 is checked against the checksum published beside it.
10//! Each binary must be an executable that names the release version, and
11//! the old binary is kept beside the new one.
12//! A component whose release is missing, or whose download or checksum
13//! fails, prints a warning.
14//! The others still install.
15
16use std::collections::HashMap;
17use std::io::Read;
18use std::path::{Path, PathBuf};
19
20use anyhow::{bail, Context, Result};
21
22/// The repository whose releases the seat installs ljos from.
23pub const RELEASE_REPO: &str = "leidarljos/ljos";
24
25/// Every component repository lives under this owner.
26const RELEASE_OWNER: &str = "leidarljos";
27
28/// The binaries a ljos release carries that the seat installs, when present
29/// beside the running `ljos`: `ljos` and `ljos-mcp` always.
30pub const BINARIES: &[&str] = &["ljos", "ljos-mcp", "ljos-hud"];
31
32/// One program installed from another repository's release.
33pub struct Component {
34    /// The file name in the install directory, and the `--only` name.
35    pub name: &'static str,
36    /// The repository under [`RELEASE_OWNER`] that publishes the archive.
37    pub repo: &'static str,
38    /// The archive file on that release. `{version}` is the tag without a
39    /// leading `v`. `{target}` is this machine's rustc triple.
40    pub asset: &'static str,
41}
42
43/// The programs ljos runs that are published outside this repository.
44///
45/// Each asset name is the file that repository's release workflow uploads.
46/// `install.sh` installs the same programs. A program that is not in the
47/// install directory is left alone, so an upgrade does not add one the seat
48/// never installed. `--only` names one of these, or one of [`BINARIES`].
49pub const COMPONENTS: &[Component] = &[
50    // packset-v{version}-{target}.tar.gz. The top directory has that
51    // name and holds these four.
52    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    // vissue-v{version}-{target}.tar.gz.
73    // The top directory has that name and holds vissue, vissue-mcp and
74    // vissue-hud.
75    // ljos runs vissue. vissue-hud is left out.
76    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    // consensus-v{version}-{target}.tar.gz. The top directory has
102    // that name and holds ljos-consensus.
103    Component {
104        name: "ljos-consensus",
105        repo: "consensus",
106        asset: "consensus-v{version}-{target}.tar.gz",
107    },
108];
109
110/// The archive triple for this `arch` and `os`.
111///
112/// The release workflows publish these four triples.
113#[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/// The target triple of the published archive for this machine.
125#[must_use]
126pub fn target() -> Option<&'static str> {
127    target_for(std::env::consts::ARCH, std::env::consts::OS)
128}
129
130/// The archive name the release workflow publishes for `version` on
131/// `target`: `ljos-v0.22.3-x86_64-unknown-linux-gnu.tar.gz`.
132#[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
137/// [`Component::asset`] with `{version}` and `{target}` filled in.
138fn 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/// The digest in a `sha256sum` line: its first field, when it is 64 hex
145/// characters.
146#[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/// Whether `--version` output names this binary at `version`.
153///
154/// A line may carry a build note after the version, as `ljos-policyd`
155/// prints `ljos-policyd 0.7.0 (phronesis)` and `packset` prints its commit.
156/// The note is separated by whitespace, so `0.22.3` does not match `0.22.30`.
157#[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
186/// Every binary this install would replace already reports `version`.
187fn 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
205/// Copy `installed` aside as `NAME.bak-pre-VERSION`, unless that file
206/// already holds some other version. That copy is the rollback, and a
207/// second install of the same release must not replace it with itself.
208fn 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
230/// The published asset, from `LJOS_RELEASE_DIR` when a test or a mirror
231/// has the files on disk.
232fn 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
255/// The version a fixture or the forge publishes for `repo`. `None` when
256/// that repository has no release. `LJOS_RELEASE_DIR` supplies
257/// `{repo}.version` and stands in for the forge, so a test makes no request.
258fn 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
290/// The newest published ljos release's version, from the forge.
291fn 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
316/// The semver `--version` printed, or a stand-in when the file is missing
317/// or the line has no version.
318fn 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    // The cache directory, on the home filesystem: a temporary directory
352    // can be a quota'd tmpfs that is already full.
353    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
365/// Install release `version` (the newest when `None`) into `dir`.
366/// `only` limits the ljos archive to that one binary.
367fn 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    // Check every binary before replacing any, so a bad archive changes nothing.
414    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
473/// A file named `name` anywhere under `root`, not reached through a symlink.
474fn 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
506/// Download and unpack one archive. The error is one line a warning can carry.
507fn 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
576/// Each installed component, one line. A failure is a warning line.
577fn 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
657/// Install the ljos release `version` (the newest when `None`) and each
658/// other seat program from its own latest release, into the directory of
659/// the running `ljos`, or `dir`.
660/// `only` moves that one program.
661///
662/// One line a program: `name old new` when it moved, `name version current`
663/// when it already matches.
664/// A component that cannot be installed prints `warning:` and the rest
665/// still run.
666///
667/// # Errors
668///
669/// `--only` names nothing this installs.
670/// The ljos archive has no build for this machine, or its download,
671/// checksum or version does not hold.
672/// A component failure is a warning, not an error.
673pub 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}