Skip to main content

decdn_cli/commands/self_update/
mod.rs

1//! `decdn self-update`: replace the running binary with the latest signed
2//! release, and the once-a-day notice that a newer one exists.
3//!
4//! The order is deliberate: ownership first, offline, so a binary this
5//! command may not replace costs no download; then the latest tag; then the
6//! signed manifest, which must verify against an embedded minisign key
7//! before anything else is trusted; then the archive, checked against the
8//! manifest and unpacked; and only then the swap, staged beside the running
9//! binary and renamed over it.
10
11pub(crate) mod check;
12pub(crate) mod install;
13pub(crate) mod release;
14pub(crate) mod verify;
15
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use anyhow::{Context, bail};
20use decdn_common::cli::SelfUpdateArgs;
21use minisign_verify::PublicKey;
22use semver::Version;
23use tempfile::NamedTempFile;
24
25pub use check::{NudgePolicy, Pending, start as start_nudge};
26use install::Ownership;
27use release::{Clients, Target, Upgrade};
28
29/// Whole-request bound on each download; an archive is tens of megabytes.
30const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
31/// `SHA256SUMS` holds one short line per release archive; anything near
32/// this is not it.
33const MAX_MANIFEST_BYTES: usize = 64 * 1024;
34/// A minisign signature is four short lines.
35const MAX_SIGNATURE_BYTES: usize = 4 * 1024;
36/// Generous ceiling for a release archive and the binary inside it.
37const MAX_ARCHIVE_BYTES: usize = 256 * 1024 * 1024;
38
39/// Run `decdn self-update`.
40pub async fn self_update(args: &SelfUpdateArgs) -> anyhow::Result<()> {
41    let target = Target::current().context(
42        "no release archive is built for this platform; build from source or run \
43         `cargo install --locked decdn-cli`",
44    )?;
45    // `~/.decdn` is the data dir; its parent is the home the cargo bin
46    // directories hang off.
47    let data_dir =
48        decdn_common::cli::default_data_dir().context("cannot determine the home directory")?;
49    let home = data_dir.parent().unwrap_or(&data_dir).to_path_buf();
50    let exe = install::running_exe()?;
51    let cargo_home = std::env::var_os("CARGO_HOME").map(PathBuf::from);
52    let install_root = std::env::var_os("CARGO_INSTALL_ROOT").map(PathBuf::from);
53    let cargo_bins = install::cargo_bin_dirs(&home, cargo_home.as_deref(), install_root.as_deref());
54    match install::classify(&exe, &cargo_bins, install::probe_writable) {
55        Ownership::Replaceable => {}
56        Ownership::CargoBin => bail!(
57            "{} was installed by cargo; run `cargo install --locked decdn-cli` to update it",
58            exe.display()
59        ),
60        Ownership::NotWritable(reason) => bail!(
61            "cannot replace {}: {reason}. Update it the way it was installed (`docker pull`, \
62             the package manager, or the fleet tooling that placed it)",
63            exe.display()
64        ),
65    }
66
67    let running = release::running_version()?;
68    let request = Request {
69        keys: &verify::trusted_keys()?,
70        target,
71        running: &running,
72        exe: &exe,
73        clients: &Clients::build(DOWNLOAD_TIMEOUT)?,
74        base: &release::releases_base(),
75        check: args.check,
76    };
77    match apply(&request, |staged| install::swap(staged, &exe)).await? {
78        Outcome::UpToDate => println!("decdn {running} is up to date"),
79        Outcome::Downgrade { latest } => println!(
80            "the latest release ({latest}) is older than this build ({running}); nothing to do"
81        ),
82        Outcome::PreRelease { latest } => {
83            println!("the latest release ({latest}) is a pre-release; nothing to do");
84        }
85        Outcome::Available { latest } => println!(
86            "decdn {latest} is available (signed manifest ok); run `decdn self-update` to \
87             install it over {}",
88            exe.display()
89        ),
90        Outcome::Updated { latest } => {
91            println!("updated {}: {running} -> {latest}", exe.display());
92        }
93    }
94    Ok(())
95}
96
97/// Everything [`apply`] needs that the command resolves up front.
98pub(crate) struct Request<'a> {
99    /// The minisign keys a release manifest may be signed with.
100    pub(crate) keys: &'a [PublicKey],
101    /// The target whose archive to install.
102    pub(crate) target: Target,
103    /// The version of the running binary.
104    pub(crate) running: &'a Version,
105    /// The canonical path of the running binary.
106    pub(crate) exe: &'a Path,
107    /// The HTTP clients the lookup and downloads use.
108    pub(crate) clients: &'a Clients,
109    /// The release page, without a trailing slash.
110    pub(crate) base: &'a str,
111    /// Report only; download no archive and change nothing.
112    pub(crate) check: bool,
113}
114
115/// What [`apply`] did.
116#[derive(Debug, PartialEq, Eq)]
117pub(crate) enum Outcome {
118    /// The running binary is the latest release.
119    UpToDate,
120    /// The latest release is older than the running binary; nothing applied.
121    Downgrade {
122        /// The release's version.
123        latest: Version,
124    },
125    /// The latest release is a pre-release; nothing applied.
126    PreRelease {
127        /// The release's version.
128        latest: Version,
129    },
130    /// A newer release exists and its manifest verifies; `check` stopped here.
131    Available {
132        /// The release's version.
133        latest: Version,
134    },
135    /// The binary was replaced.
136    Updated {
137        /// The installed version.
138        latest: Version,
139    },
140}
141
142/// Resolve the latest release, verify it, and, unless `request.check`, hand
143/// the staged new binary to `swap`. Nothing reaches `swap` that a trusted key
144/// did not vouch for, through the manifest signature and the archive digest.
145pub(crate) async fn apply(
146    request: &Request<'_>,
147    swap: impl FnOnce(NamedTempFile) -> anyhow::Result<()>,
148) -> anyhow::Result<Outcome> {
149    let Request {
150        keys,
151        target,
152        running,
153        exe,
154        clients,
155        base,
156        check,
157    } = *request;
158    let tag = release::resolve_latest(clients, base).await?;
159    let latest = release::version_from_tag(&tag)?;
160    match release::decide(running, &latest) {
161        Upgrade::Newer => {}
162        Upgrade::UpToDate => return Ok(Outcome::UpToDate),
163        Upgrade::Downgrade => return Ok(Outcome::Downgrade { latest }),
164        Upgrade::PreRelease => return Ok(Outcome::PreRelease { latest }),
165    }
166
167    let sums = release::fetch_asset(clients, base, &tag, "SHA256SUMS", MAX_MANIFEST_BYTES).await?;
168    let minisig = release::fetch_asset(
169        clients,
170        base,
171        &tag,
172        "SHA256SUMS.minisig",
173        MAX_SIGNATURE_BYTES,
174    )
175    .await?;
176    let minisig = std::str::from_utf8(&minisig).context("SHA256SUMS.minisig is not UTF-8")?;
177    let manifest = verify::verify_manifest_with(keys, &sums, minisig)?;
178    let name = target.archive_name(&latest);
179    if manifest.digest_for(&name).is_none() {
180        bail!("release {tag} ships no {name}");
181    }
182    if check {
183        return Ok(Outcome::Available { latest });
184    }
185
186    eprintln!("Downloading {name}...");
187    let archive = release::fetch_asset(clients, base, &tag, &name, MAX_ARCHIVE_BYTES).await?;
188    verify::verify_archive(&manifest, &name, &archive)?;
189    let bytes = install::extract_binary(&archive, target.kind(), MAX_ARCHIVE_BYTES as u64)?;
190    let dir = exe
191        .parent()
192        .context("the running binary has no parent directory")?;
193    let staged = install::stage(dir, &bytes)?;
194    swap(staged)?;
195    Ok(Outcome::Updated { latest })
196}
197
198#[cfg(test)]
199#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // tests
200mod tests;