1use std::io;
12use std::path::{Path, PathBuf};
13
14use data_encoding::HEXLOWER;
15use sha2::{Digest, Sha256};
16use thiserror::Error;
17use tokio::fs as afs;
18use tokio::io::AsyncWriteExt;
19
20use super::manifest::{Extension, Lib};
21
22const RELEASE_BASE: &str = "https://github.com/developerinlondon/assay/releases/download";
23
24#[derive(Debug, Error)]
25pub enum FetchError {
26 #[error("{name}: no sha256 declared for arch `{arch}` (have: {available:?})")]
27 NoArchHash {
28 name: String,
29 arch: String,
30 available: Vec<String>,
31 },
32
33 #[error("{name}: HTTP request to {url} failed: {source}")]
34 Http {
35 name: String,
36 url: String,
37 #[source]
38 source: reqwest::Error,
39 },
40
41 #[error("{name}: HTTP {status} from {url}")]
42 Status {
43 name: String,
44 url: String,
45 status: u16,
46 },
47
48 #[error("{name}: I/O error: {source}")]
49 Io {
50 name: String,
51 #[source]
52 source: io::Error,
53 },
54
55 #[error("{name}: sha256 mismatch (expected {expected}, got {actual})")]
56 Sha256Mismatch {
57 name: String,
58 expected: String,
59 actual: String,
60 },
61
62 #[error("{name}: not in cache and --offline mode (expected at {})", cache_path.display())]
63 OfflineMissing { name: String, cache_path: PathBuf },
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct FetchPlan {
70 pub display_name: String,
72 pub url: String,
74 pub cache_path: PathBuf,
76 pub expected_sha256: String,
78}
79
80impl FetchPlan {
81 pub fn for_extension(
83 ext: &Extension,
84 arch: &str,
85 cache_dir: &Path,
86 ) -> Result<Self, FetchError> {
87 let display_name = format!("{} {}", ext.name, ext.version);
88 let expected_sha256 = ext
89 .sha256
90 .get(arch)
91 .ok_or_else(|| FetchError::NoArchHash {
92 name: display_name.clone(),
93 arch: arch.to_string(),
94 available: ext.sha256.keys().cloned().collect(),
95 })?
96 .clone();
97
98 let filename = format!("{}-{}-{}.tar.gz", ext.name, ext.version, arch);
99 let url = ext
100 .source
101 .clone()
102 .unwrap_or_else(|| default_extension_url(&ext.name, &ext.version, arch));
103 let cache_path = cache_dir.join(&filename);
104 Ok(FetchPlan {
105 display_name,
106 url,
107 cache_path,
108 expected_sha256,
109 })
110 }
111
112 pub fn for_lib(lib: &Lib, cache_dir: &Path) -> Self {
114 let display_name = format!("{} {}", lib.name, lib.version);
115 let filename = format!("assay-lib-{}-{}.tar.gz", lib.name, lib.version);
116 let url = lib
117 .source
118 .clone()
119 .unwrap_or_else(|| default_lib_url(&lib.name, &lib.version));
120 let cache_path = cache_dir.join(&filename);
121 FetchPlan {
122 display_name,
123 url,
124 cache_path,
125 expected_sha256: lib.sha256.clone(),
126 }
127 }
128}
129
130fn default_extension_url(name: &str, version: &str, arch: &str) -> String {
131 format!("{RELEASE_BASE}/v{version}/{name}-{version}-{arch}.tar.gz")
134}
135
136fn default_lib_url(name: &str, version: &str) -> String {
137 format!("{RELEASE_BASE}/assay-lib-{name}-v{version}/assay-lib-{name}-{version}.tar.gz")
138}
139
140pub async fn fetch(
142 plan: &FetchPlan,
143 client: &reqwest::Client,
144 offline: bool,
145) -> Result<(), FetchError> {
146 if afs::try_exists(&plan.cache_path).await.unwrap_or(false) {
148 let bytes = afs::read(&plan.cache_path).await.map_err(io_err(plan))?;
149 let actual = sha256_hex(&bytes);
150 if actual == plan.expected_sha256 {
151 return Ok(());
152 }
153 let _ = afs::remove_file(&plan.cache_path).await;
156 }
157
158 if offline {
159 return Err(FetchError::OfflineMissing {
160 name: plan.display_name.clone(),
161 cache_path: plan.cache_path.clone(),
162 });
163 }
164
165 let parent = plan
167 .cache_path
168 .parent()
169 .expect("cache_path always has a parent (it's <cache-dir>/<filename>)");
170 afs::create_dir_all(parent).await.map_err(io_err(plan))?;
171
172 let resp = client
173 .get(&plan.url)
174 .send()
175 .await
176 .map_err(|e| FetchError::Http {
177 name: plan.display_name.clone(),
178 url: plan.url.clone(),
179 source: e,
180 })?;
181 if !resp.status().is_success() {
182 return Err(FetchError::Status {
183 name: plan.display_name.clone(),
184 url: plan.url.clone(),
185 status: resp.status().as_u16(),
186 });
187 }
188 let bytes = resp.bytes().await.map_err(|e| FetchError::Http {
189 name: plan.display_name.clone(),
190 url: plan.url.clone(),
191 source: e,
192 })?;
193
194 let actual = sha256_hex(&bytes);
195 if actual != plan.expected_sha256 {
196 return Err(FetchError::Sha256Mismatch {
197 name: plan.display_name.clone(),
198 expected: plan.expected_sha256.clone(),
199 actual,
200 });
201 }
202
203 let mut tmp_path = plan.cache_path.clone();
204 let mut tmp_name = plan
205 .cache_path
206 .file_name()
207 .expect("cache_path has a filename")
208 .to_os_string();
209 tmp_name.push(".tmp");
210 tmp_path.set_file_name(tmp_name);
211
212 let mut tmp = afs::File::create(&tmp_path).await.map_err(io_err(plan))?;
213 tmp.write_all(&bytes).await.map_err(io_err(plan))?;
214 tmp.flush().await.map_err(io_err(plan))?;
215 drop(tmp);
216 afs::rename(&tmp_path, &plan.cache_path)
217 .await
218 .map_err(io_err(plan))?;
219
220 Ok(())
221}
222
223fn io_err(plan: &FetchPlan) -> impl Fn(io::Error) -> FetchError + '_ {
224 let name = plan.display_name.clone();
225 move |source| FetchError::Io {
226 name: name.clone(),
227 source,
228 }
229}
230
231fn sha256_hex(bytes: &[u8]) -> String {
232 let mut hasher = Sha256::new();
233 hasher.update(bytes);
234 HEXLOWER.encode(&hasher.finalize())
235}