Skip to main content

mira/
update.rs

1//! `mira update` — replace this binary with a release from GitHub.
2//!
3//! It does that by re-running the installer, not by re-implementing it.
4//! `scripts/get-mira.sh` already resolves the latest tag, picks the Rust target
5//! triple, verifies `SHA256SUMS`, verifies the build attestation when `gh` is
6//! present, and elevates only for the final move. Doing any of that from Rust
7//! needs an HTTPS client, and the smallest one worth trusting costs `rustls`
8//! plus `ring` plus their trees — against a dependency count the README
9//! publishes as a product property. Shelling out keeps that number where it is
10//! and keeps the installer singular: the same bytes that installed mira update
11//! it, so there is no second code path to be wrong by the next release.
12//!
13//! There is no `--check`. The installer prints `mira vX is already installed`
14//! and stops when the version matches, so running it *is* the check, and a
15//! second network path that only prints would be a second thing to keep true.
16//!
17//! The one thing this adds on top of the script is `MIRA_INSTALL_DIR`. The
18//! script defaults to `/usr/local/bin`; someone who put mira in `~/.local/bin`
19//! and typed `mira update` means *this* mira, not a second copy that then
20//! shadows it depending on `PATH` order.
21
22use std::path::{Path, PathBuf};
23
24/// Where the installer is published. `docs/install.sh` is a symlink to
25/// `scripts/get-mira.sh`, so this URL and the one in the install docs are the
26/// same file.
27const INSTALLER: &str = "https://miradb.dev/install.sh";
28
29/// Where to go when there is no shell to run the installer with.
30const RELEASES: &str = "https://github.com/TrianaLab/mira/releases";
31
32pub const USAGE: &str = "mira update [--version VERSION] [--dry-run]
33
34Downloads the latest release from GitHub and replaces this binary with it,
35by running the same installer as
36  curl -fsSL https://miradb.dev/install.sh | bash
37
38  --version VERSION  install this tag instead of the latest (e.g. v0.1.0)
39  --dry-run          print the command that would run, and stop
40
41Installs over this binary's own directory, not /usr/local/bin, unless
42MIRA_INSTALL_DIR says otherwise. Nothing happens if the running version is
43already the one that would be installed.
44
45Needs bash and either curl or wget, because it runs the installer rather
46than carrying an HTTPS client. The container image has none of them; upgrade
47that by pulling a newer tag.";
48
49/// What `--version` was given, if anything.
50///
51/// A tag goes into a shell command, so it is checked against a charset rather
52/// than quoted: quoting is a thing to get subtly wrong once, and no real Git
53/// tag needs a character outside this set. Unknown flags are refused instead of
54/// forwarded — the installer's own flag set is not this one's, and silently
55/// passing `--no-sudo` through would make its behaviour depend on a flag this
56/// usage does not document.
57pub fn parse(argv: &[String]) -> Result<(Option<String>, bool), String> {
58    let mut version = None;
59    let mut dry_run = false;
60    let mut it = argv.iter();
61    while let Some(flag) = it.next() {
62        match flag.as_str() {
63            "--version" | "-v" => {
64                let v = it.next().ok_or("--version needs a value")?;
65                if v.is_empty()
66                    || !v
67                        .chars()
68                        .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
69                {
70                    return Err(format!("--version: {v:?} is not a release tag"));
71                }
72                version = Some(v.clone());
73            }
74            "--dry-run" => dry_run = true,
75            other => return Err(format!("unknown flag {other:?}\n\n{USAGE}")),
76        }
77    }
78    Ok((version, dry_run))
79}
80
81/// The shell line that does the update.
82///
83/// `curl` or `wget` is chosen inside the shell rather than out here: the script
84/// itself already has to make that choice for every download it does, and one
85/// `command -v` in a string is smaller than a probe, an enum and a match.
86pub fn command(version: Option<&str>) -> String {
87    let tag = match version {
88        Some(v) => format!(" --version {v}"),
89        None => String::new(),
90    };
91    format!(
92        "if command -v curl >/dev/null 2>&1; then curl -fsSL {INSTALLER}; \
93         elif command -v wget >/dev/null 2>&1; then wget -qO- {INSTALLER}; \
94         else echo 'mira update needs curl or wget' >&2; exit 1; fi \
95         | bash -s --{tag}"
96    )
97}
98
99/// The directory to install into, given this process's own executable path.
100///
101/// `None` when it cannot be resolved or has no parent, which leaves
102/// `MIRA_INSTALL_DIR` unset and the script on its `/usr/local/bin` default —
103/// the right fallback, because a mira that cannot find itself is more likely to
104/// be a test harness than a real install.
105pub fn install_dir(exe: Option<&Path>) -> Option<PathBuf> {
106    let real = std::fs::canonicalize(exe?).ok()?;
107    real.parent().map(Path::to_path_buf)
108}
109
110/// Run it.
111///
112/// The child inherits stdio, so the installer's own progress and its `sudo`
113/// prompt reach the terminal directly. Its exit status becomes this command's:
114/// a failed download must not look like a successful update to whatever ran
115/// `mira update` in a script.
116pub fn run(argv: &[String]) -> Result<(), String> {
117    if argv.iter().any(|a| a == "-h" || a == "--help") {
118        println!("{USAGE}");
119        return Ok(());
120    }
121    let (version, dry_run) = parse(argv)?;
122    let line = command(version.as_deref());
123    if dry_run {
124        println!("{line}");
125        return Ok(());
126    }
127
128    spawn(&mut installer(&line))
129}
130
131/// The child, configured but not started.
132///
133/// Separate from [`spawn`] so a test can assert what would run — the program,
134/// the shell line, the install directory — without a test run downloading a
135/// release over the binary that is running the test.
136fn installer(line: &str) -> std::process::Command {
137    let mut cmd = std::process::Command::new("bash");
138    cmd.arg("-c").arg(line);
139    if std::env::var_os("MIRA_INSTALL_DIR").is_none() {
140        if let Some(dir) = install_dir(std::env::current_exe().ok().as_deref()) {
141            cmd.env("MIRA_INSTALL_DIR", dir);
142        }
143    }
144    cmd
145}
146
147/// Start it and turn its exit into this command's.
148///
149/// Taking a `Command` rather than the line means both failure arms — the child
150/// that never started and the child that started and failed — are reachable
151/// from a test with `true`, `false` and a path that does not exist.
152fn spawn(cmd: &mut std::process::Command) -> Result<(), String> {
153    let status = cmd
154        .status()
155        .map_err(|e| start_failed(&cmd.get_program().to_string_lossy(), &e))?;
156    if !status.success() {
157        return Err(format!("installer exited with {status}"));
158    }
159    Ok(())
160}
161
162/// What to say about a child that never started.
163///
164/// `NotFound` is not an unusual system here, it is the shipped one: the image
165/// is distroless, so it has no bash, no curl and no writable install directory,
166/// and the raw "No such file or directory (os error 2)" names the symptom while
167/// hiding the answer — a container upgrades by pulling a newer tag, not by
168/// rewriting its own rootfs. Every other tool the installer needs already
169/// refuses by name (curl-or-wget in [`command`], sha256sum in the script), so
170/// this is the last case that did not.
171fn start_failed(program: &str, e: &std::io::Error) -> String {
172    if e.kind() == std::io::ErrorKind::NotFound {
173        return format!(
174            "`{program}` is not on PATH, so there is nothing here to run the \
175             installer with. In a container, upgrade by pulling a newer image \
176             tag. Otherwise install {program}, or take the tarball for this \
177             platform straight from {RELEASES}."
178        );
179    }
180    format!("could not run the installer: {e}")
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn args(a: &[&str]) -> Vec<String> {
188        a.iter().map(|s| s.to_string()).collect()
189    }
190
191    #[test]
192    fn no_flags_installs_the_latest_release() {
193        assert_eq!(parse(&[]).unwrap(), (None, false));
194        let line = command(None);
195        assert!(line.ends_with("| bash -s --"), "{line}");
196        assert!(line.contains(INSTALLER), "{line}");
197    }
198
199    #[test]
200    fn a_tag_is_forwarded_to_the_installer() {
201        let (v, dry) = parse(&args(&["--version", "v0.1.0"])).unwrap();
202        assert_eq!(v.as_deref(), Some("v0.1.0"));
203        assert!(!dry);
204        assert!(command(v.as_deref()).ends_with("--version v0.1.0"));
205    }
206
207    #[test]
208    fn a_tag_that_could_be_a_shell_command_is_refused_rather_than_quoted() {
209        for bad in ["v1; rm -rf /", "$(id)", "`id`", "v1 --no-sudo", ""] {
210            let e = parse(&args(&["--version", bad])).unwrap_err();
211            assert!(e.starts_with("--version:"), "{bad:?} was accepted: {e}");
212        }
213        assert_eq!(
214            parse(&args(&["--version"])).unwrap_err(),
215            "--version needs a value"
216        );
217    }
218
219    #[test]
220    fn an_installer_flag_this_command_does_not_document_is_refused() {
221        let e = parse(&args(&["--no-sudo"])).unwrap_err();
222        assert!(e.starts_with("unknown flag \"--no-sudo\""), "{e}");
223        assert!(e.contains(USAGE), "the usage is part of the message");
224    }
225
226    #[test]
227    fn the_downloader_is_chosen_by_the_shell_and_not_assumed() {
228        let line = command(None);
229        assert!(line.contains("command -v curl"), "{line}");
230        assert!(line.contains("command -v wget"), "{line}");
231        assert!(line.contains("needs curl or wget"), "{line}");
232    }
233
234    #[test]
235    fn the_install_directory_is_this_binarys_own() {
236        let exe = std::env::current_exe().unwrap();
237        assert_eq!(
238            install_dir(Some(&exe)).unwrap(),
239            std::fs::canonicalize(&exe).unwrap().parent().unwrap()
240        );
241        assert_eq!(install_dir(None), None);
242        assert_eq!(install_dir(Some(Path::new("/no/such/mira"))), None);
243    }
244
245    #[test]
246    fn dry_run_prints_the_command_instead_of_running_it() {
247        run(&args(&["--dry-run", "--version", "v9.9.9"])).unwrap();
248        run(&args(&["--help"])).unwrap();
249        assert!(run(&args(&["--nope"])).is_err());
250    }
251
252    /// Asserts the child rather than running it: the real one would replace the
253    /// binary under test with a download.
254    #[test]
255    fn the_child_is_the_installer_pointed_at_this_binarys_directory() {
256        let line = command(None);
257        let cmd = installer(&line);
258        assert_eq!(cmd.get_program(), "bash");
259        let argv: Vec<_> = cmd.get_args().collect();
260        assert_eq!(argv, ["-c", line.as_str()]);
261        // The harness runs with `MIRA_INSTALL_DIR` unset, so the override is the
262        // one this command adds, and it points at wherever the test binary is.
263        let dir = cmd
264            .get_envs()
265            .find(|(k, _)| *k == "MIRA_INSTALL_DIR")
266            .and_then(|(_, v)| v)
267            .expect("an install directory");
268        let exe = std::env::current_exe().unwrap();
269        assert_eq!(Path::new(dir), install_dir(Some(&exe)).unwrap());
270    }
271
272    #[test]
273    fn a_failed_installer_is_a_failed_update() {
274        spawn(&mut std::process::Command::new("true")).unwrap();
275
276        let e = spawn(&mut std::process::Command::new("false")).unwrap_err();
277        assert!(e.starts_with("installer exited with"), "{e}");
278
279        let e = spawn(&mut std::process::Command::new("/no/such/installer")).unwrap_err();
280        assert!(e.starts_with("`/no/such/installer` is not on PATH"), "{e}");
281    }
282
283    /// The distroless image has no bash, and that has to read as an answer
284    /// rather than as an errno.
285    #[test]
286    fn no_shell_says_so_and_says_what_to_do_instead() {
287        let e = start_failed("bash", &std::io::ErrorKind::NotFound.into());
288        assert!(e.starts_with("`bash` is not on PATH"), "{e}");
289        assert!(e.contains("pulling a newer image tag"), "{e}");
290        assert!(e.contains(RELEASES), "{e}");
291
292        // Anything else is a real error and is reported as one, rather than
293        // being explained away as a missing shell.
294        let e = start_failed("bash", &std::io::ErrorKind::PermissionDenied.into());
295        assert!(e.starts_with("could not run the installer:"), "{e}");
296    }
297
298    #[test]
299    fn the_usage_names_what_it_needs_on_the_host() {
300        assert!(
301            USAGE.contains("Needs bash and either curl or wget"),
302            "{USAGE}"
303        );
304    }
305}