Skip to main content

varve_core/
update.rs

1//! Self-update (REQ-UPDATE-001) — updating the updater, without a flag day.
2//!
3//! The chain: the RUNNING varve verifies the candidate release against the
4//! pinned trust root before anything is replaced — old-verifies-new, the
5//! same shape as a TUF root rotation. Explicit invocation only: varve makes
6//! no network request the user did not command (no phone-home), and any
7//! verification failure refuses rather than warns. The one unavoidable TOFU
8//! moment is the very first install, established out-of-band (cosign +
9//! build provenance); every update after that rides this chain.
10
11use crate::install::VerifyError;
12use crate::selfverify::{SelfVerifyError, verify_release_file};
13
14/// What an update check found.
15#[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
49/// Strictly-parsed x.y.z (a leading `v` is tolerated).
50pub 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        // Unparseable versions never count as newer — fail closed.
63        _ => false,
64    }
65}
66
67/// Whether the running binary is already the latest release's binary, decided
68/// on ARTIFACT IDENTITY rather than self-reported version strings (varve#38).
69/// A binary that mis-reports its own version (as v0.14.0 did) would otherwise
70/// loop forever: `is_newer` stays true, every check re-installs the same bytes.
71/// Comparing digests makes a stale version string degrade to a no-op.
72pub 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
76/// Ask the release API for the latest tag and locate this platform's assets.
77/// `api_latest_url` is the GitHub "latest release" endpoint (or a mirror /
78/// test double — the URL changes availability, never acceptance).
79pub 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
127/// Extract one file from a gzipped tarball.
128pub 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
156/// Download and verify the successor binary WITHOUT installing it — the
157/// running varve verifies its successor against the trust root. Returns the
158/// verified binary bytes and the archive digest. Splitting this from the write
159/// lets the caller decide on artifact identity before touching disk (varve#38).
160pub 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
184/// Atomically install already-verified successor bytes at `dest`.
185pub 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    // Atomic on the same filesystem: write beside dest, then rename over it.
191    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/// The self-update decision, resolved on ARTIFACT IDENTITY (varve#38).
204#[derive(Debug)]
205pub enum UpdateDecision {
206    /// The API's latest is not newer by version — nothing fetched.
207    UpToDate,
208    /// The version string says newer, but the verified latest binary is
209    /// byte-identical to what is on disk. A no-op — this is what breaks the
210    /// mis-reported-version loop.
211    AlreadyCurrent { latest: String },
212    /// A genuine, verified update is available: the plan, the verified binary
213    /// bytes (ready to install), and the archive digest.
214    Available {
215        plan: UpdatePlan,
216        binary: Vec<u8>,
217        digest: String,
218    },
219}
220
221/// Resolve whether an update is needed, deciding on artifact identity rather
222/// than self-reported version strings (varve#38). `on_disk` is the current
223/// binary's bytes (None if the destination does not yet exist). Fetches and
224/// VERIFIES the candidate against the trust root before comparing or offering
225/// it, so a reported "available" is always a genuinely-verified update.
226pub 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
251/// Download, verify against the trust root, extract, and atomically install at
252/// `dest`. Returns the verified archive digest.
253pub 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    // rivet: verifies REQ-UPDATE-001
268    #[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        // Unparseable never counts as newer.
275        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    // rivet: verifies REQ-UPDATE-002
281    #[test]
282    fn a_wrong_version_string_does_not_force_an_update_when_the_bytes_match() {
283        // The varve#38 loop: a binary reporting "0.13.1" that is actually the
284        // latest release. Version strings alone say "update forever"; artifact
285        // identity says "already current" and the loop terminates.
286        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        // A genuine update has different bytes.
297        assert!(!already_current(running, b"a-newer-binary"));
298    }
299
300    // rivet: verifies REQ-UPDATE-001
301    #[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}