Skip to main content

pacenotes_server/store/
motd.rs

1/// The folder beside the games holding `<lang>.txt` message-of-the-day texts.
2pub const MOTD_DIR: &str = "motd";
3
4pub const MOTD_FALLBACK_LANG: &str = "en";
5
6/// `[a-z]{2,8}`: `lang` names a file no manifest lists, so this alphabet is
7/// what keeps it inside the motd folder.
8pub fn valid_lang(lang: &str) -> bool {
9    (2..=8).contains(&lang.len()) && lang.bytes().all(|b| b.is_ascii_lowercase())
10}
11
12/// The text for `lang`, else the fallback language's, else `None`.
13pub fn read_motd(data_dir: &std::path::Path, lang: &str) -> Option<String> {
14    let mut candidates: Vec<&str> = Vec::new();
15    if valid_lang(lang) {
16        candidates.push(lang);
17    } else if !lang.is_empty() {
18        tracing::debug!("ignoring motd lang request: not [a-z]{{2,8}}");
19    }
20    if !candidates.contains(&MOTD_FALLBACK_LANG) {
21        candidates.push(MOTD_FALLBACK_LANG);
22    }
23
24    candidates.into_iter().find_map(|candidate| {
25        let path = data_dir.join(MOTD_DIR).join(format!("{candidate}.txt"));
26        match std::fs::read_to_string(&path) {
27            Ok(text) => Some(text),
28            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
29            Err(e) => {
30                tracing::warn!(error = %e, path = %path.display(), "reading motd failed");
31                None
32            }
33        }
34    })
35}
36
37#[cfg(test)]
38mod tests {
39    #[test]
40    fn motd_falls_back_to_english_and_refuses_traversal() {
41        let tmp = tempfile::tempdir().unwrap();
42        let dir = tmp.path();
43        assert!(super::read_motd(dir, "pl").is_none());
44
45        crate::store::fixture_tree::write(&dir.join("motd/en.txt"), b"English news");
46        assert_eq!(super::read_motd(dir, "en").as_deref(), Some("English news"));
47        assert_eq!(super::read_motd(dir, "pl").as_deref(), Some("English news"));
48        for odd in ["", "../manifest", "en/../en"] {
49            assert_eq!(
50                super::read_motd(dir, odd).as_deref(),
51                Some("English news"),
52                "{odd:?}"
53            );
54        }
55
56        crate::store::fixture_tree::write(&dir.join("motd/pl.txt"), "Polskie wieści".as_bytes());
57        assert_eq!(
58            super::read_motd(dir, "pl").as_deref(),
59            Some("Polskie wieści")
60        );
61        assert_eq!(super::read_motd(dir, "en").as_deref(), Some("English news"));
62    }
63
64    #[test]
65    fn lang_validation() {
66        for good in ["en", "pt", "ptbr"] {
67            assert!(super::valid_lang(good), "{good:?}");
68        }
69        for bad in ["e", "", "EN", "en-GB", "..", "toolonglanguagetag"] {
70            assert!(!super::valid_lang(bad), "{bad:?}");
71        }
72    }
73}