Skip to main content

geop/
release.rs

1//! The GitHub release this build of `geop` belongs to, and its files, as
2//! `.github/workflows/release.yml` names them and `homepage/install.sh`
3//! fetches them: every file listed with its SHA-256 in `SHA256SUMS`, and
4//! nothing downloaded is used before its sum is checked.
5
6use geop_core_math::geop_error::{GeopError, GeopResult};
7use sha2::{Digest, Sha256};
8use std::io::Read;
9
10/// Where releases are downloaded from: `<base>/v<version>/<file>`.
11/// `GEOP_RELEASE_BASE_URL` replaces it, for a mirror or a test.
12const RELEASES: &str = "https://github.com/geop-cad/geop/releases/download";
13
14/// The largest file a release holds that is read: an extension, with the
15/// kernel in it, is some tens of megabytes.
16const LARGEST: u64 = 512 << 20;
17
18/// The URL of the file `file` of the release `version`, under `base`.
19pub fn url(base: &str, version: &str, file: &str) -> String {
20    format!("{}/v{version}/{file}", base.trim_end_matches('/'))
21}
22
23/// The base URL releases are downloaded from (see [`RELEASES`]).
24pub fn base() -> String {
25    std::env::var("GEOP_RELEASE_BASE_URL").unwrap_or_else(|_| RELEASES.to_string())
26}
27
28/// VS Code's name of the platform `os`/`arch` (as `std::env::consts`
29/// names them), which an extension with a native program in it is built
30/// for: `vsce package --target`.
31pub fn vscode_target(os: &str, arch: &str) -> Option<&'static str> {
32    Some(match (os, arch) {
33        ("linux", "x86_64") => "linux-x64",
34        ("linux", "aarch64") => "linux-arm64",
35        ("macos", "x86_64") => "darwin-x64",
36        ("macos", "aarch64") => "darwin-arm64",
37        ("windows", "x86_64") => "win32-x64",
38        _ => return None,
39    })
40}
41
42/// The VS Code extension of the release `version` for the VS Code platform
43/// `target`: it carries the kernel built for that platform.
44pub fn vsix_name(version: &str, target: &str) -> String {
45    format!("geop-vscode-{version}-{target}.vsix")
46}
47
48/// The SHA-256 `sums` (a `SHA256SUMS` file, as `sha256sum` writes it:
49/// `<hex>  <name>`, or `<hex> *<name>` for one read as binary) gives
50/// `name`, in lower case.
51pub fn listed_sum(sums: &str, name: &str) -> Option<String> {
52    sums.lines().find_map(|line| {
53        let (sum, file) = line.trim_end().split_once(' ')?;
54        let file = file.strip_prefix([' ', '*'])?;
55        let valid = sum.len() == 64 && sum.bytes().all(|b| b.is_ascii_hexdigit());
56        (valid && file == name).then(|| sum.to_ascii_lowercase())
57    })
58}
59
60/// The SHA-256 of `bytes`, in lower-case hex.
61pub fn sum_of(bytes: &[u8]) -> String {
62    Sha256::digest(bytes)
63        .iter()
64        .map(|b| format!("{b:02x}"))
65        .collect()
66}
67
68/// `bytes`, the file `name`, if `sums` lists it with their SHA-256.
69pub fn verified(bytes: Vec<u8>, sums: &str, name: &str) -> GeopResult<Vec<u8>> {
70    let listed = listed_sum(sums, name)
71        .ok_or_else(|| GeopError::new(format!("SHA256SUMS lists no {name}")))?;
72    let actual = sum_of(&bytes);
73    if actual != listed {
74        return Err(GeopError::new(format!(
75            "{name} is not the file released: its SHA-256 is {actual}, SHA256SUMS says {listed}"
76        )));
77    }
78    Ok(bytes)
79}
80
81/// The file at `url`, downloaded whole.
82pub fn fetch(url: &str) -> GeopResult<Vec<u8>> {
83    let failed = |e: ureq::Error| match e {
84        ureq::Error::StatusCode(404) => GeopError::new(format!("{url} does not exist")),
85        e => GeopError::new(format!("downloading {url}: {e}")),
86    };
87    let response = ureq::get(url)
88        .header("User-Agent", concat!("geop/", env!("CARGO_PKG_VERSION")))
89        .call()
90        .map_err(failed)?;
91    let mut bytes = Vec::new();
92    response
93        .into_body()
94        .into_reader()
95        .take(LARGEST + 1)
96        .read_to_end(&mut bytes)
97        .map_err(|e| GeopError::new(format!("downloading {url}: {e}")))?;
98    if bytes.len() as u64 > LARGEST {
99        return Err(GeopError::new(format!(
100            "{url} is larger than {LARGEST} bytes"
101        )));
102    }
103    Ok(bytes)
104}
105
106/// The file `file` of the release `version`, checked against the
107/// release's `SHA256SUMS`.
108pub fn download(version: &str, file: &str) -> GeopResult<Vec<u8>> {
109    let base = base();
110    let sums = fetch(&url(&base, version, "SHA256SUMS")).map_err(|e| {
111        e.with_context(format!(
112            "download(version={version}, file={file}): no release v{version} to download from \
113             (is this a development build? a release is cut with scripts/release.sh)"
114        ))
115    })?;
116    let sums = String::from_utf8_lossy(&sums);
117    verified(fetch(&url(&base, version, file))?, &sums, file)
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn a_release_file_is_under_its_tag() {
126        assert_eq!(
127            url("https://example.org/dl/", "0.2.0", "SHA256SUMS"),
128            "https://example.org/dl/v0.2.0/SHA256SUMS"
129        );
130        assert_eq!(
131            url(RELEASES, "0.2.0-rc1", &vsix_name("0.2.0-rc1", "linux-x64")),
132            "https://github.com/geop-cad/geop/releases/download/v0.2.0-rc1/geop-vscode-0.2.0-rc1-linux-x64.vsix"
133        );
134    }
135
136    /// Every platform the release builds `geop` for has an extension, and
137    /// no other one is guessed at.
138    #[test]
139    fn every_released_platform_has_a_vscode_target() {
140        for (os, arch, target) in [
141            ("linux", "x86_64", "linux-x64"),
142            ("linux", "aarch64", "linux-arm64"),
143            ("macos", "x86_64", "darwin-x64"),
144            ("macos", "aarch64", "darwin-arm64"),
145            ("windows", "x86_64", "win32-x64"),
146        ] {
147            assert_eq!(vscode_target(os, arch), Some(target), "{os} {arch}");
148        }
149        assert_eq!(vscode_target("windows", "aarch64"), None);
150        assert_eq!(vscode_target("freebsd", "x86_64"), None);
151        assert!(vscode_target(std::env::consts::OS, std::env::consts::ARCH).is_some());
152    }
153
154    #[test]
155    fn sums_are_read_as_sha256sum_writes_them() {
156        let a = "a".repeat(64);
157        let b = "B".repeat(64);
158        let sums = format!(
159            "{a}  geop-0.2.0-x86_64-unknown-linux-gnu.tar.gz\n{b} *geop-vscode-0.2.0-linux-x64.vsix\r\n"
160        );
161        assert_eq!(
162            listed_sum(&sums, "geop-0.2.0-x86_64-unknown-linux-gnu.tar.gz"),
163            Some(a)
164        );
165        assert_eq!(
166            listed_sum(&sums, "geop-vscode-0.2.0-linux-x64.vsix"),
167            Some("b".repeat(64))
168        );
169        // Not a prefix, not a suffix: the name itself.
170        assert_eq!(listed_sum(&sums, "geop-vscode-0.2.0-linux-x64"), None);
171        assert_eq!(listed_sum(&sums, "x64.vsix"), None);
172        assert_eq!(listed_sum("nothex  file\n", "file"), None);
173    }
174
175    #[test]
176    fn a_file_is_used_only_if_its_sum_is_listed_and_matches() {
177        let bytes = b"the extension".to_vec();
178        let sums = format!("{}  geop.vsix\n", sum_of(&bytes));
179        assert_eq!(verified(bytes.clone(), &sums, "geop.vsix").unwrap(), bytes);
180        let err = verified(b"something else".to_vec(), &sums, "geop.vsix").unwrap_err();
181        assert!(err.to_string().contains("not the file released"), "{err}");
182        let err = verified(bytes, &sums, "other.vsix").unwrap_err();
183        assert!(err.to_string().contains("lists no other.vsix"), "{err}");
184    }
185
186    #[test]
187    fn sha256_is_the_standard_one() {
188        assert_eq!(
189            sum_of(b"abc"),
190            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
191        );
192    }
193}