decdn_cli/commands/self_update/
mod.rs1pub(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
29const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
31const MAX_MANIFEST_BYTES: usize = 64 * 1024;
34const MAX_SIGNATURE_BYTES: usize = 4 * 1024;
36const MAX_ARCHIVE_BYTES: usize = 256 * 1024 * 1024;
38
39pub 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 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
97pub(crate) struct Request<'a> {
99 pub(crate) keys: &'a [PublicKey],
101 pub(crate) target: Target,
103 pub(crate) running: &'a Version,
105 pub(crate) exe: &'a Path,
107 pub(crate) clients: &'a Clients,
109 pub(crate) base: &'a str,
111 pub(crate) check: bool,
113}
114
115#[derive(Debug, PartialEq, Eq)]
117pub(crate) enum Outcome {
118 UpToDate,
120 Downgrade {
122 latest: Version,
124 },
125 PreRelease {
127 latest: Version,
129 },
130 Available {
132 latest: Version,
134 },
135 Updated {
137 latest: Version,
139 },
140}
141
142pub(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)] mod tests;