1use crate::install::VerifyError;
12use crate::selfverify::{SelfVerifyError, verify_release_file};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct UpdatePlan {
17 pub current: String,
18 pub latest: String,
19 pub archive_name: String,
20 pub archive_url: String,
21 pub envelope_url: String,
22}
23
24#[derive(Debug, thiserror::Error)]
25pub enum UpdateError {
26 #[error("release API error: {0}")]
27 Api(String),
28 #[error("release {tag} carries no asset for platform {platform}")]
29 NoAsset { tag: String, platform: String },
30 #[error(
31 "release {tag} carries no varve-native signed sums (SHA256SUMS.txt.dsse.json) — cannot \
32 self-update without it; verify and install manually (cosign) or wait for a signed release"
33 )]
34 NoEnvelope { tag: String },
35 #[error(transparent)]
36 Verify(#[from] SelfVerifyError),
37 #[error(transparent)]
38 Signature(#[from] VerifyError),
39 #[error("downloaded archive does not contain a 'varve' binary")]
40 NoBinaryInArchive,
41 #[error("io error at {path}: {source}")]
42 Io {
43 path: String,
44 #[source]
45 source: std::io::Error,
46 },
47}
48
49pub fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
51 let v = v.strip_prefix('v').unwrap_or(v);
52 let mut parts = v.split('.');
53 let major = parts.next()?.parse().ok()?;
54 let minor = parts.next()?.parse().ok()?;
55 let patch = parts.next()?.parse().ok()?;
56 parts.next().is_none().then_some((major, minor, patch))
57}
58
59pub fn is_newer(candidate: &str, current: &str) -> bool {
60 match (parse_version(candidate), parse_version(current)) {
61 (Some(c), Some(cur)) => c > cur,
62 _ => false,
64 }
65}
66
67pub fn already_current(running_binary: &[u8], latest_binary: &[u8]) -> bool {
73 crate::store::manifest_digest(running_binary) == crate::store::manifest_digest(latest_binary)
74}
75
76pub fn check_latest(
80 api_latest_url: &str,
81 current_version: &str,
82 platform: &str,
83) -> Result<Option<UpdatePlan>, UpdateError> {
84 let agent = ureq::Agent::new_with_defaults();
85 let body = agent
86 .get(api_latest_url)
87 .header("Accept", "application/vnd.github+json")
88 .header("User-Agent", "varve-self-update")
89 .call()
90 .map_err(|e| UpdateError::Api(e.to_string()))?
91 .body_mut()
92 .read_to_string()
93 .map_err(|e| UpdateError::Api(e.to_string()))?;
94 let json: serde_json::Value =
95 serde_json::from_str(&body).map_err(|e| UpdateError::Api(e.to_string()))?;
96 let tag = json["tag_name"]
97 .as_str()
98 .ok_or_else(|| UpdateError::Api("latest release has no tag_name".into()))?
99 .to_string();
100 if !is_newer(&tag, current_version) {
101 return Ok(None);
102 }
103 let assets = json["assets"].as_array().cloned().unwrap_or_default();
104 let find = |name: &str| -> Option<String> {
105 assets
106 .iter()
107 .find(|a| a["name"].as_str() == Some(name))
108 .and_then(|a| a["browser_download_url"].as_str())
109 .map(str::to_string)
110 };
111 let archive_name = format!("varve-{tag}-{platform}.tar.gz");
112 let archive_url = find(&archive_name).ok_or_else(|| UpdateError::NoAsset {
113 tag: tag.clone(),
114 platform: platform.to_string(),
115 })?;
116 let envelope_url = find("SHA256SUMS.txt.dsse.json")
117 .ok_or_else(|| UpdateError::NoEnvelope { tag: tag.clone() })?;
118 Ok(Some(UpdatePlan {
119 current: current_version.to_string(),
120 latest: tag,
121 archive_name,
122 archive_url,
123 envelope_url,
124 }))
125}
126
127pub fn extract_tool_from_targz(bytes: &[u8], tool: &str) -> Result<Vec<u8>, UpdateError> {
129 let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(bytes));
130 for entry in archive.entries().map_err(|e| UpdateError::Io {
131 path: "<archive>".into(),
132 source: e,
133 })? {
134 let mut entry = entry.map_err(|e| UpdateError::Io {
135 path: "<archive>".into(),
136 source: e,
137 })?;
138 let is_match = entry
139 .path()
140 .ok()
141 .and_then(|p| p.file_name().map(|n| n == tool))
142 .unwrap_or(false);
143 if is_match {
144 let mut out = Vec::new();
145 use std::io::Read;
146 entry.read_to_end(&mut out).map_err(|e| UpdateError::Io {
147 path: tool.into(),
148 source: e,
149 })?;
150 return Ok(out);
151 }
152 }
153 Err(UpdateError::NoBinaryInArchive)
154}
155
156pub fn fetch_verified_binary(
161 plan: &UpdatePlan,
162 root_public_key: &[u8],
163) -> Result<(Vec<u8>, String), UpdateError> {
164 let agent = ureq::Agent::new_with_defaults();
165 let fetch = |url: &str| -> Result<Vec<u8>, UpdateError> {
166 agent
167 .get(url)
168 .header("User-Agent", "varve-self-update")
169 .call()
170 .map_err(|e| UpdateError::Api(e.to_string()))?
171 .body_mut()
172 .with_config()
173 .limit(8 * 1024 * 1024 * 1024)
174 .read_to_vec()
175 .map_err(|e| UpdateError::Api(e.to_string()))
176 };
177 let envelope = fetch(&plan.envelope_url)?;
178 let archive = fetch(&plan.archive_url)?;
179 let digest = verify_release_file(&plan.archive_name, &archive, &envelope, root_public_key)?;
180 let binary = extract_tool_from_targz(&archive, "varve")?;
181 Ok((binary, digest))
182}
183
184pub fn install_binary(binary: &[u8], dest: &std::path::Path) -> Result<(), UpdateError> {
186 let io = |path: &std::path::Path, source: std::io::Error| UpdateError::Io {
187 path: path.display().to_string(),
188 source,
189 };
190 let tmp = dest.with_extension("varve-update-tmp");
192 std::fs::write(&tmp, binary).map_err(|e| io(&tmp, e))?;
193 #[cfg(unix)]
194 {
195 use std::os::unix::fs::PermissionsExt;
196 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
197 .map_err(|e| io(&tmp, e))?;
198 }
199 std::fs::rename(&tmp, dest).map_err(|e| io(dest, e))?;
200 Ok(())
201}
202
203#[derive(Debug)]
205pub enum UpdateDecision {
206 UpToDate,
208 AlreadyCurrent { latest: String },
212 Available {
215 plan: UpdatePlan,
216 binary: Vec<u8>,
217 digest: String,
218 },
219}
220
221pub fn resolve_update(
227 api_latest_url: &str,
228 current_version: &str,
229 platform: &str,
230 on_disk: Option<&[u8]>,
231 root_public_key: &[u8],
232) -> Result<UpdateDecision, UpdateError> {
233 let Some(plan) = check_latest(api_latest_url, current_version, platform)? else {
234 return Ok(UpdateDecision::UpToDate);
235 };
236 let (binary, digest) = fetch_verified_binary(&plan, root_public_key)?;
237 if let Some(current) = on_disk
238 && already_current(current, &binary)
239 {
240 return Ok(UpdateDecision::AlreadyCurrent {
241 latest: plan.latest,
242 });
243 }
244 Ok(UpdateDecision::Available {
245 plan,
246 binary,
247 digest,
248 })
249}
250
251pub fn perform(
254 plan: &UpdatePlan,
255 root_public_key: &[u8],
256 dest: &std::path::Path,
257) -> Result<String, UpdateError> {
258 let (binary, digest) = fetch_verified_binary(plan, root_public_key)?;
259 install_binary(&binary, dest)?;
260 Ok(digest)
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
269 fn version_comparison_is_strict_and_fails_closed() {
270 assert!(is_newer("v0.8.0", "0.7.0"));
271 assert!(is_newer("1.0.0", "0.99.99"));
272 assert!(!is_newer("v0.7.0", "0.7.0"));
273 assert!(!is_newer("0.6.9", "0.7.0"));
274 assert!(!is_newer("nightly", "0.7.0"));
276 assert!(!is_newer("v0.8", "0.7.0"));
277 assert!(!is_newer("0.8.0.1", "0.7.0"));
278 }
279
280 #[test]
282 fn a_wrong_version_string_does_not_force_an_update_when_the_bytes_match() {
283 let running = b"the-genuine-latest-binary";
287 let latest = b"the-genuine-latest-binary";
288 assert!(
289 is_newer("v0.14.0", "0.13.1"),
290 "version strings alone would loop"
291 );
292 assert!(
293 already_current(running, latest),
294 "identical verified bytes must read as already-current regardless of version"
295 );
296 assert!(!already_current(running, b"a-newer-binary"));
298 }
299
300 #[test]
302 fn the_binary_is_extracted_from_a_release_shaped_tarball() {
303 let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
304 Vec::new(),
305 flate2::Compression::default(),
306 ));
307 for (name, bytes) in [("README.md", b"docs".as_slice()), ("varve", b"the-binary")] {
308 let mut header = tar::Header::new_gnu();
309 header.set_size(bytes.len() as u64);
310 header.set_mode(0o755);
311 header.set_cksum();
312 builder.append_data(&mut header, name, bytes).unwrap();
313 }
314 let targz = builder.into_inner().unwrap().finish().unwrap();
315 assert_eq!(
316 extract_tool_from_targz(&targz, "varve").unwrap(),
317 b"the-binary"
318 );
319 let no_binary = {
320 let mut b = tar::Builder::new(flate2::write::GzEncoder::new(
321 Vec::new(),
322 flate2::Compression::default(),
323 ));
324 let mut h = tar::Header::new_gnu();
325 h.set_size(4);
326 h.set_cksum();
327 b.append_data(&mut h, "other", b"data".as_slice()).unwrap();
328 b.into_inner().unwrap().finish().unwrap()
329 };
330 assert!(matches!(
331 extract_tool_from_targz(&no_binary, "varve").unwrap_err(),
332 UpdateError::NoBinaryInArchive
333 ));
334 }
335}