1use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use sha2::{Digest, Sha256};
18
19use super::catalogue::{ALLOWED_HOSTS, CataloguePack, RemoteFile};
20use super::{PackError, PackReport, PackSource, ResolvedPack, validate};
21
22const HEADROOM_BYTES: u64 = 16 * 1024 * 1024;
24
25#[derive(Debug)]
27pub enum InstallError {
28 NotInCatalogue { language: String },
30 UntrustedSource { url: String, detail: String },
32 Transport { url: String, detail: String },
34 ContentMismatch {
39 url: String,
40 expected: String,
41 actual: String,
42 },
43 NoRoom {
45 path: PathBuf,
46 needed: u64,
47 available: u64,
48 },
49 Destination { path: PathBuf, detail: String },
51 Unusable(PackError),
53}
54
55impl std::fmt::Display for InstallError {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 match self {
58 Self::NotInCatalogue { language } => write!(
59 f,
60 "no download is published for \"{language}\"; install a pack yourself and name it \
61 under engines.hunspell.dictionary_paths"
62 ),
63 Self::UntrustedSource { url, detail } => {
64 write!(f, "refusing to fetch {url}: {detail}")
65 }
66 Self::Transport { url, detail } => write!(f, "could not fetch {url}: {detail}"),
67 Self::ContentMismatch {
68 url,
69 expected,
70 actual,
71 } => write!(
72 f,
73 "{url} did not match what this version of lang-check expects \
74 (wanted {expected}, got {actual}). The published dictionary may have been \
75 updated, or the download may have been tampered with; nothing was installed"
76 ),
77 Self::NoRoom {
78 path,
79 needed,
80 available,
81 } => write!(
82 f,
83 "not enough room in {}: {needed} bytes needed, {available} free",
84 path.display()
85 ),
86 Self::Destination { path, detail } => {
87 write!(f, "cannot write to {}: {detail}", path.display())
88 }
89 Self::Unusable(e) => write!(f, "the downloaded pack is not usable: {e}"),
90 }
91 }
92}
93
94impl std::error::Error for InstallError {}
95
96fn vet(url: &str) -> Result<(), InstallError> {
98 let Some(rest) = url.strip_prefix("https://") else {
99 return Err(InstallError::UntrustedSource {
100 url: url.to_string(),
101 detail: "only https is allowed".to_string(),
102 });
103 };
104 let host = rest.split('/').next().unwrap_or_default();
105 if !ALLOWED_HOSTS.contains(&host) {
106 return Err(InstallError::UntrustedSource {
107 url: url.to_string(),
108 detail: format!("{host} is not an allowed download host"),
109 });
110 }
111 Ok(())
112}
113
114fn room_for(path: &Path, needed: u64) -> Result<(), InstallError> {
120 let Ok(available) = fs4::available_space(path) else {
121 return Ok(());
122 };
123 if available < needed.saturating_add(HEADROOM_BYTES) {
124 return Err(InstallError::NoRoom {
125 path: path.to_path_buf(),
126 needed,
127 available,
128 });
129 }
130 Ok(())
131}
132
133async fn fetch(client: &reqwest::Client, file: &RemoteFile) -> Result<Vec<u8>, InstallError> {
135 vet(file.url)?;
136
137 let response = client
138 .get(file.url)
139 .send()
140 .await
141 .map_err(|e| InstallError::Transport {
142 url: file.url.to_string(),
143 detail: e.to_string(),
144 })?;
145
146 if !response.status().is_success() {
147 return Err(InstallError::Transport {
148 url: file.url.to_string(),
149 detail: format!("HTTP {}", response.status()),
150 });
151 }
152
153 if let Some(advertised) = response.content_length()
156 && advertised != file.bytes
157 {
158 return Err(InstallError::ContentMismatch {
159 url: file.url.to_string(),
160 expected: format!("{} bytes", file.bytes),
161 actual: format!("{advertised} bytes"),
162 });
163 }
164
165 let body = response
166 .bytes()
167 .await
168 .map_err(|e| InstallError::Transport {
169 url: file.url.to_string(),
170 detail: e.to_string(),
171 })?;
172
173 if body.len() as u64 != file.bytes {
174 return Err(InstallError::ContentMismatch {
175 url: file.url.to_string(),
176 expected: format!("{} bytes", file.bytes),
177 actual: format!("{} bytes", body.len()),
178 });
179 }
180
181 let mut digest = String::with_capacity(64);
182 for byte in Sha256::digest(&body) {
183 use std::fmt::Write as _;
184 let _ = write!(digest, "{byte:02x}");
185 }
186 if digest != file.sha256 {
187 return Err(InstallError::ContentMismatch {
188 url: file.url.to_string(),
189 expected: file.sha256.to_string(),
190 actual: digest,
191 });
192 }
193
194 Ok(body.to_vec())
195}
196
197pub async fn install(pack: &CataloguePack, dir: &Path) -> Result<PackReport, InstallError> {
210 std::fs::create_dir_all(dir).map_err(|e| InstallError::Destination {
211 path: dir.to_path_buf(),
212 detail: e.to_string(),
213 })?;
214 room_for(dir, pack.aff.bytes + pack.dic.bytes)?;
215
216 let client = reqwest::Client::builder()
217 .connect_timeout(std::time::Duration::from_secs(10))
218 .timeout(std::time::Duration::from_secs(300))
219 .build()
220 .map_err(|e| InstallError::Transport {
221 url: pack.aff.url.to_string(),
222 detail: e.to_string(),
223 })?;
224
225 let aff = fetch(&client, &pack.aff).await?;
226 let dic = fetch(&client, &pack.dic).await?;
227
228 let staging = dir.join(format!(".{}.incoming", pack.stem));
231 let _ = std::fs::remove_dir_all(&staging);
232 std::fs::create_dir_all(&staging).map_err(|e| InstallError::Destination {
233 path: staging.clone(),
234 detail: e.to_string(),
235 })?;
236
237 let staged = Staged(staging.clone());
238 let staged_aff = staging.join(format!("{}.aff", pack.stem));
239 let staged_dic = staging.join(format!("{}.dic", pack.stem));
240 write_file(&staged_aff, &aff)?;
241 write_file(&staged_dic, &dic)?;
242
243 let report = validate(&ResolvedPack {
246 language: pack.language.to_string(),
247 stem: pack.stem.to_string(),
248 aff: staged_aff.clone(),
249 dic: staged_dic.clone(),
250 source: PackSource::Managed,
251 })
252 .map_err(InstallError::Unusable)?;
253
254 let final_aff = dir.join(format!("{}.aff", pack.stem));
255 let final_dic = dir.join(format!("{}.dic", pack.stem));
256 for (from, to) in [(&staged_aff, &final_aff), (&staged_dic, &final_dic)] {
257 std::fs::rename(from, to).map_err(|e| InstallError::Destination {
258 path: to.clone(),
259 detail: e.to_string(),
260 })?;
261 }
262 drop(staged);
263
264 Ok(PackReport {
265 pack: ResolvedPack {
266 language: pack.language.to_string(),
267 stem: pack.stem.to_string(),
268 aff: final_aff,
269 dic: final_dic,
270 source: PackSource::Managed,
271 },
272 ..report
273 })
274}
275
276struct Staged(PathBuf);
278
279impl Drop for Staged {
280 fn drop(&mut self) {
281 let _ = std::fs::remove_dir_all(&self.0);
282 }
283}
284
285fn write_file(path: &Path, bytes: &[u8]) -> Result<(), InstallError> {
286 let mut file = std::fs::File::create(path).map_err(|e| InstallError::Destination {
287 path: path.to_path_buf(),
288 detail: e.to_string(),
289 })?;
290 file.write_all(bytes)
291 .and_then(|()| file.sync_all())
292 .map_err(|e| InstallError::Destination {
293 path: path.to_path_buf(),
294 detail: if e.kind() == std::io::ErrorKind::StorageFull {
295 "the disk is full".to_string()
296 } else {
297 e.to_string()
298 },
299 })
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::packs::catalogue::CATALOGUE;
306
307 fn file(url: &'static str) -> RemoteFile {
308 RemoteFile {
309 url,
310 sha256: "0".repeat(64).leak(),
311 bytes: 1,
312 }
313 }
314
315 #[test]
316 fn plain_http_is_refused_before_a_connection_is_opened() {
317 let err = vet("http://raw.githubusercontent.com/x").unwrap_err();
318 assert!(matches!(err, InstallError::UntrustedSource { .. }), "{err}");
319 assert!(err.to_string().contains("only https"), "{err}");
320 }
321
322 #[test]
323 fn a_host_off_the_allowlist_is_refused() {
324 let err = vet("https://example.invalid/he_IL.dic").unwrap_err();
327 assert!(matches!(err, InstallError::UntrustedSource { .. }), "{err}");
328 assert!(err.to_string().contains("example.invalid"), "{err}");
329 }
330
331 #[test]
332 fn a_lookalike_host_does_not_pass() {
333 for url in [
334 "https://raw.githubusercontent.com.evil.test/x",
335 "https://evil.test/raw.githubusercontent.com/x",
336 "https://notraw.githubusercontent.com/x",
337 ] {
338 assert!(vet(url).is_err(), "{url} was accepted");
339 }
340 }
341
342 #[test]
343 fn every_catalogue_url_passes_its_own_vetting() {
344 for pack in CATALOGUE {
345 vet(pack.aff.url).unwrap_or_else(|e| panic!("{}: {e}", pack.language));
346 vet(pack.dic.url).unwrap_or_else(|e| panic!("{}: {e}", pack.language));
347 }
348 }
349
350 #[test]
351 fn a_full_disk_is_reported_before_anything_is_fetched() {
352 let dir = tempfile::tempdir().unwrap();
353 let err = room_for(dir.path(), u64::MAX / 2).unwrap_err();
355 assert!(matches!(err, InstallError::NoRoom { .. }), "{err}");
356 assert!(err.to_string().contains("not enough room"), "{err}");
357 }
358
359 #[test]
360 fn a_pack_that_fits_is_not_refused() {
361 let dir = tempfile::tempdir().unwrap();
362 room_for(dir.path(), 7_875_142).expect("8 MB should fit");
364 }
365
366 #[test]
367 fn an_unwritable_destination_says_so() {
368 let err = InstallError::Destination {
369 path: PathBuf::from("/proc/nope/he_IL.dic"),
370 detail: "Read-only file system".to_string(),
371 };
372 assert!(err.to_string().contains("cannot write to"), "{err}");
373 }
374
375 #[test]
376 fn a_content_mismatch_says_what_it_wanted_and_what_it_got() {
377 let err = InstallError::ContentMismatch {
380 url: "https://raw.githubusercontent.com/x".to_string(),
381 expected: "abc".to_string(),
382 actual: "def".to_string(),
383 };
384 let message = err.to_string();
385 assert!(
386 message.contains("abc") && message.contains("def"),
387 "{message}"
388 );
389 assert!(message.contains("tampered"), "{message}");
390 assert!(message.contains("nothing was installed"), "{message}");
391 }
392
393 #[test]
394 fn a_language_with_no_published_download_points_at_the_override() {
395 let err = InstallError::NotInCatalogue {
396 language: "la".to_string(),
397 };
398 assert!(err.to_string().contains("dictionary_paths"), "{err}");
399 }
400
401 #[tokio::test]
402 async fn a_refused_url_never_touches_the_destination() {
403 let dir = tempfile::tempdir().unwrap();
404 let bogus = CataloguePack {
405 language: "xx",
406 stem: "xx",
407 aff: file("https://example.invalid/xx.aff"),
408 dic: file("https://example.invalid/xx.dic"),
409 licence: "test",
410 provenance: "test",
411 };
412 let err = install(&bogus, dir.path()).await.unwrap_err();
413 assert!(matches!(err, InstallError::UntrustedSource { .. }), "{err}");
414 let left: Vec<_> = std::fs::read_dir(dir.path())
415 .unwrap()
416 .flatten()
417 .map(|e| e.file_name())
418 .collect();
419 assert!(left.is_empty(), "a refused install left {left:?} behind");
420 }
421}