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}