Skip to main content

lang_check/packs/
install.rs

1//! Fetching a dictionary pack, and refusing anything that is not the pack.
2//!
3//! The threat is not hypothetical for a tool of this shape: it downloads a
4//! file from the internet and hands it to a parser, on a schedule the user
5//! does not control. So the bytes are pinned. [`catalogue`] records a SHA-256
6//! and a length for every file, and a download that differs in either is
7//! discarded without ever reaching the parser or the destination directory.
8//!
9//! The checks run in the order that fails cheapest first: the URL before a
10//! connection, the advertised length before the body, the length again while
11//! reading, then the digest, then the dictionary's own structure, and only
12//! then is anything moved into place.
13
14use 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
22/// Room to leave free after writing, so an install does not fill a disk.
23const HEADROOM_BYTES: u64 = 16 * 1024 * 1024;
24
25/// Why an install did not happen.
26#[derive(Debug)]
27pub enum InstallError {
28    /// The catalogue has no entry, so there is no pinned source to trust.
29    NotInCatalogue { language: String },
30    /// The URL is not one this build is willing to fetch from.
31    UntrustedSource { url: String, detail: String },
32    /// The transfer failed.
33    Transport { url: String, detail: String },
34    /// The bytes are not the bytes that were pinned.
35    ///
36    /// Either the published dictionary changed or something served different
37    /// content. Those are indistinguishable from here, so both stop.
38    ContentMismatch {
39        url: String,
40        expected: String,
41        actual: String,
42    },
43    /// The destination cannot hold it.
44    NoRoom {
45        path: PathBuf,
46        needed: u64,
47        available: u64,
48    },
49    /// The destination cannot be written.
50    Destination { path: PathBuf, detail: String },
51    /// It arrived intact and is not a usable dictionary.
52    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
96/// Check a URL before opening a connection to it.
97fn 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
114/// Free bytes on the filesystem that holds `path`.
115///
116/// Measured rather than guessed: `available_space` is what the OS reports, so
117/// there is no heuristic here to produce a false refusal. Unavailable on some
118/// filesystems, and then the check is skipped rather than failing the install.
119fn 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
133/// Fetch one file and return its bytes, or refuse them.
134async 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    // The server's own claim about the size, checked before the body is read
154    // so an enormous response is refused rather than buffered.
155    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
197/// Install `pack` into `dir`, or leave the directory untouched.
198///
199/// Nothing is written where the engine would find it until both files have
200/// matched their pins and the pair has passed [`validate`]. A failure at any
201/// point leaves no partial pack behind, because a half-installed dictionary
202/// reads as a broken one forever after.
203///
204/// # Errors
205///
206/// [`InstallError`] for an untrusted URL, a failed transfer, bytes that do not
207/// match the pin, a destination that cannot hold or accept the files, or a
208/// download that turns out not to be a usable dictionary.
209pub 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    // Staged beside the destination, so the rename that publishes them is on
229    // the same filesystem and therefore atomic.
230    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    // Parsed before it is published, so a pack that cannot be read fails here
244    // rather than three keystrokes into a paragraph.
245    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
276/// Removes the staging directory however the install ends.
277struct 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        // The digest already stops altered content; this stops a catalogue
325        // entry pointing somewhere nobody reviewed.
326        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        // More than any real filesystem has free.
354        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        // The real Hebrew pack, which any machine running the tests can hold.
363        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        // The message has to leave the user able to tell an upstream update
378        // from an attack, because this code cannot.
379        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}