1use geop_core_math::geop_error::{GeopError, GeopResult};
7use sha2::{Digest, Sha256};
8use std::io::Read;
9
10const RELEASES: &str = "https://github.com/geop-cad/geop/releases/download";
13
14const LARGEST: u64 = 512 << 20;
17
18pub fn url(base: &str, version: &str, file: &str) -> String {
20 format!("{}/v{version}/{file}", base.trim_end_matches('/'))
21}
22
23pub fn base() -> String {
25 std::env::var("GEOP_RELEASE_BASE_URL").unwrap_or_else(|_| RELEASES.to_string())
26}
27
28pub 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
42pub fn vsix_name(version: &str, target: &str) -> String {
45 format!("geop-vscode-{version}-{target}.vsix")
46}
47
48pub 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
60pub fn sum_of(bytes: &[u8]) -> String {
62 Sha256::digest(bytes)
63 .iter()
64 .map(|b| format!("{b:02x}"))
65 .collect()
66}
67
68pub 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
81pub 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
106pub 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 #[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 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}