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}/v{version}/assay-lib-{name}-{version}.tar.gz")
140}
141
142pub async fn fetch(
144 plan: &FetchPlan,
145 client: &reqwest::Client,
146 offline: bool,
147) -> Result<(), FetchError> {
148 if afs::try_exists(&plan.cache_path).await.unwrap_or(false) {
150 let bytes = afs::read(&plan.cache_path).await.map_err(io_err(plan))?;
151 let actual = sha256_hex(&bytes);
152 if actual == plan.expected_sha256 {
153 return Ok(());
154 }
155 let _ = afs::remove_file(&plan.cache_path).await;
158 }
159
160 if offline {
161 return Err(FetchError::OfflineMissing {
162 name: plan.display_name.clone(),
163 cache_path: plan.cache_path.clone(),
164 });
165 }
166
167 let parent = plan
169 .cache_path
170 .parent()
171 .expect("cache_path always has a parent (it's <cache-dir>/<filename>)");
172 afs::create_dir_all(parent).await.map_err(io_err(plan))?;
173
174 let resp = client
175 .get(&plan.url)
176 .send()
177 .await
178 .map_err(|e| FetchError::Http {
179 name: plan.display_name.clone(),
180 url: plan.url.clone(),
181 source: e,
182 })?;
183 if !resp.status().is_success() {
184 return Err(FetchError::Status {
185 name: plan.display_name.clone(),
186 url: plan.url.clone(),
187 status: resp.status().as_u16(),
188 });
189 }
190 let bytes = resp.bytes().await.map_err(|e| FetchError::Http {
191 name: plan.display_name.clone(),
192 url: plan.url.clone(),
193 source: e,
194 })?;
195
196 let actual = sha256_hex(&bytes);
197 if actual != plan.expected_sha256 {
198 return Err(FetchError::Sha256Mismatch {
199 name: plan.display_name.clone(),
200 expected: plan.expected_sha256.clone(),
201 actual,
202 });
203 }
204
205 let mut tmp_path = plan.cache_path.clone();
206 let mut tmp_name = plan
207 .cache_path
208 .file_name()
209 .expect("cache_path has a filename")
210 .to_os_string();
211 tmp_name.push(".tmp");
212 tmp_path.set_file_name(tmp_name);
213
214 let mut tmp = afs::File::create(&tmp_path).await.map_err(io_err(plan))?;
215 tmp.write_all(&bytes).await.map_err(io_err(plan))?;
216 tmp.flush().await.map_err(io_err(plan))?;
217 drop(tmp);
218 afs::rename(&tmp_path, &plan.cache_path)
219 .await
220 .map_err(io_err(plan))?;
221
222 Ok(())
223}
224
225fn io_err(plan: &FetchPlan) -> impl Fn(io::Error) -> FetchError + '_ {
226 let name = plan.display_name.clone();
227 move |source| FetchError::Io {
228 name: name.clone(),
229 source,
230 }
231}
232
233fn sha256_hex(bytes: &[u8]) -> String {
234 let mut hasher = Sha256::new();
235 hasher.update(bytes);
236 HEXLOWER.encode(&hasher.finalize())
237}