Skip to main content

lang_check/packs/
catalogue.rs

1//! The packs `lang-check` knows how to fetch, pinned to the bytes it expects.
2//!
3//! Every entry carries a SHA-256 and a byte count recorded when the entry was
4//! written. A download that does not match both is refused and nothing is
5//! installed. That is the point: a mirror, a CDN or the host itself can be
6//! compromised, and a checker that fetches a word list and feeds it to a
7//! parser is a fine place to put a payload. Pinning means an attacker has to
8//! break the hash rather than the web server.
9//!
10//! The cost is that a pin goes stale when upstream republishes. That is
11//! deliberate -- an upstream change and an attack look identical over the
12//! wire, so the honest response is to stop and say the published dictionary no
13//! longer matches, rather than to trust whatever arrived.
14
15/// One downloadable file, and the bytes it must turn out to be.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct RemoteFile {
18    pub url: &'static str,
19    /// Lowercase hex SHA-256 of the file's exact bytes.
20    pub sha256: &'static str,
21    pub bytes: u64,
22}
23
24/// A pack that can be installed without the user finding it themselves.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct CataloguePack {
27    /// The BCP-47 tag a document declares.
28    pub language: &'static str,
29    /// The stem the files are written under, which is what resolution expects.
30    pub stem: &'static str,
31    pub aff: RemoteFile,
32    pub dic: RemoteFile,
33    /// The dictionary's own licence, which is not this project's.
34    ///
35    /// Shown before anything is downloaded, because these are strong copyleft
36    /// terms and a user installing one should know that rather than discover
37    /// it later.
38    pub licence: &'static str,
39    /// Where the words came from, for the same reason.
40    pub provenance: &'static str,
41}
42
43/// Hosts a pack may be fetched from.
44///
45/// An allowlist rather than a scheme check alone: a pinned digest already
46/// stops altered content, and this stops a future catalogue entry from
47/// quietly pointing somewhere nobody reviewed.
48pub const ALLOWED_HOSTS: &[&str] = &["raw.githubusercontent.com"];
49
50/// The packs with a known-good source.
51///
52/// Short on purpose. An entry means the bytes have been fetched, checked and
53/// pinned by hand; a language absent from here is still usable by pointing
54/// `engines.hunspell.dictionary_paths` at a pack installed any other way.
55pub const CATALOGUE: &[CataloguePack] = &[CataloguePack {
56    language: "he",
57    stem: "he_IL",
58    aff: RemoteFile {
59        url: "https://raw.githubusercontent.com/LibreOffice/dictionaries/master/he_IL/he_IL.aff",
60        sha256: "6caf86b3a545be5614f135d33a48baa244a59aca43f051dda6173d5d9cbc7700",
61        bytes: 78_883,
62    },
63    dic: RemoteFile {
64        url: "https://raw.githubusercontent.com/LibreOffice/dictionaries/master/he_IL/he_IL.dic",
65        sha256: "5f5331f90ed775bd527f6fb7ad1ead9a1b7d8ce46ad640c2387d9d1dc91d3058",
66        bytes: 7_796_259,
67    },
68    licence: "AGPL-3.0-only",
69    provenance: "Hspell 1.4, via the LibreOffice dictionaries repository",
70}];
71
72/// The catalogue entry for a language, if there is one.
73///
74/// Matched on the primary subtag, so `he-IL` finds the `he` entry.
75#[must_use]
76pub fn find(language: &str) -> Option<&'static CataloguePack> {
77    let primary = language
78        .split(['-', '_'])
79        .next()
80        .unwrap_or(language)
81        .to_ascii_lowercase();
82    if primary.is_empty() {
83        return None;
84    }
85    CATALOGUE
86        .iter()
87        .find(|pack| pack.language.eq_ignore_ascii_case(&primary))
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn every_entry_is_fetched_over_https_from_an_allowed_host() {
96        for pack in CATALOGUE {
97            for file in [&pack.aff, &pack.dic] {
98                assert!(
99                    file.url.starts_with("https://"),
100                    "{} is not https: {}",
101                    pack.language,
102                    file.url
103                );
104                let host = file
105                    .url
106                    .trim_start_matches("https://")
107                    .split('/')
108                    .next()
109                    .unwrap_or_default();
110                assert!(
111                    ALLOWED_HOSTS.contains(&host),
112                    "{} points at {host}, which is not on the allowlist",
113                    pack.language
114                );
115            }
116        }
117    }
118
119    #[test]
120    fn every_entry_pins_a_full_length_digest_and_a_size() {
121        for pack in CATALOGUE {
122            for file in [&pack.aff, &pack.dic] {
123                assert_eq!(
124                    file.sha256.len(),
125                    64,
126                    "{}: a SHA-256 is 64 hex characters",
127                    pack.language
128                );
129                assert!(
130                    file.sha256
131                        .chars()
132                        .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
133                    "{}: digest must be lowercase hex",
134                    pack.language
135                );
136                assert!(
137                    file.bytes > 0,
138                    "{}: a size of zero pins nothing",
139                    pack.language
140                );
141            }
142        }
143    }
144
145    #[test]
146    fn every_entry_states_its_licence_and_where_the_words_came_from() {
147        // These are strong copyleft terms on someone else's work; installing
148        // one without saying so is not a thing to do quietly.
149        for pack in CATALOGUE {
150            assert!(!pack.licence.is_empty(), "{}", pack.language);
151            assert!(!pack.provenance.is_empty(), "{}", pack.language);
152        }
153    }
154
155    #[test]
156    fn a_language_is_found_by_its_primary_subtag() {
157        assert_eq!(find("he").map(|p| p.stem), Some("he_IL"));
158        assert_eq!(find("he-IL").map(|p| p.stem), Some("he_IL"));
159        assert_eq!(find("HE").map(|p| p.stem), Some("he_IL"));
160        assert!(find("la").is_none(), "Latin ships only as an archive");
161        assert!(find("").is_none());
162    }
163}