Skip to main content

pacenotes_server/reports/
validation.rs

1/// A real report is a few hundred bytes.
2pub const MAX_BODY_BYTES: usize = 8 * 1024;
3/// Kept from one report; the rest count as dropped.
4pub const MAX_CONFLICTS: usize = 32;
5/// A conflict needs two writers, and nobody sends nine by accident.
6pub const MIN_PACKAGES: usize = 2;
7pub const MAX_PACKAGES: usize = 8;
8/// About a Windows path: anything longer is not a game file.
9pub const MAX_FILE_BYTES: usize = 260;
10pub const MAX_INSTALLER_LEN: usize = 32;
11
12/// What a report that passed validation stores.
13#[derive(Debug, PartialEq, Eq)]
14pub struct Validated {
15    pub game: String,
16    pub installer: String,
17    pub conflicts: Vec<pacenotes_shared::Conflict>,
18    /// Invalid or over the cap.
19    pub dropped: usize,
20}
21
22/// A bad game, installer or an empty result rejects the report; a bad conflict
23/// is dropped and counted, so one malformed entry does not cost the rest.
24pub fn validate(report: pacenotes_shared::ConflictReport) -> Result<Validated, &'static str> {
25    if !pacenotes_shared::valid_id(&report.game) {
26        return Err("game is not a valid game id");
27    }
28    let installer_ok = (1..=MAX_INSTALLER_LEN).contains(&report.installer.len())
29        && report
30            .installer
31            .bytes()
32            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'+' | b'-'));
33    if !installer_ok {
34        return Err("installer must be 1-32 characters of [0-9A-Za-z.+-]");
35    }
36
37    let mut conflicts = Vec::new();
38    let mut dropped = 0;
39    for conflict in report.conflicts {
40        if conflicts.len() < MAX_CONFLICTS && is_usable(&conflict) {
41            conflicts.push(conflict);
42        } else {
43            dropped += 1;
44        }
45    }
46    if conflicts.is_empty() {
47        return Err("the report holds no valid conflict");
48    }
49    Ok(Validated {
50        game: report.game,
51        installer: report.installer,
52        conflicts,
53        dropped,
54    })
55}
56
57fn is_usable(conflict: &pacenotes_shared::Conflict) -> bool {
58    conflict.file.len() <= MAX_FILE_BYTES
59        && pacenotes_shared::valid_name(&conflict.file)
60        && (MIN_PACKAGES..=MAX_PACKAGES).contains(&conflict.packages.len())
61        && conflict
62            .packages
63            .iter()
64            .all(|reference| pacenotes_shared::PackageRef::parse(reference).is_some())
65}
66
67#[cfg(test)]
68mod tests {
69    const PAIR: [&str; 2] = [
70        "public/language/polish",
71        "public/codriver/polish/male/Kamil",
72    ];
73
74    fn report(
75        game: &str,
76        installer: &str,
77        conflicts: Vec<(&str, Vec<&str>)>,
78    ) -> pacenotes_shared::ConflictReport {
79        pacenotes_shared::ConflictReport {
80            game: game.into(),
81            installer: installer.into(),
82            conflicts: conflicts
83                .into_iter()
84                .map(|(file, packages)| pacenotes_shared::Conflict {
85                    file: file.into(),
86                    packages: packages.into_iter().map(String::from).collect(),
87                })
88                .collect(),
89        }
90    }
91
92    #[test]
93    fn a_good_report_passes_untouched() {
94        let validated = super::validate(report(
95            "rbr",
96            "0.2.0",
97            vec![("Plugins/Pacenote/PaceNote.ini", PAIR.to_vec())],
98        ))
99        .unwrap();
100        assert_eq!(validated.game, "rbr");
101        assert_eq!(validated.conflicts.len(), 1);
102        assert_eq!(validated.dropped, 0);
103    }
104
105    #[test]
106    fn whole_report_problems_reject_it() {
107        let one_conflict = || vec![("a/b.ini", PAIR.to_vec())];
108        for (game, installer, conflicts) in [
109            ("../etc", "0.2.0", one_conflict()),
110            ("rbr", "", one_conflict()),
111            ("rbr", "0.2.0; rm -rf", one_conflict()),
112            ("rbr", &"9".repeat(33), one_conflict()),
113            ("rbr", "0.2.0", vec![]),
114        ] {
115            assert!(
116                super::validate(report(game, installer, conflicts)).is_err(),
117                "{game} {installer}"
118            );
119        }
120    }
121
122    #[test]
123    fn bad_entries_are_dropped_and_counted_not_fatal() {
124        let long = "a/".repeat(140) + "x.ini";
125        let nine: Vec<&str> = std::iter::repeat_n(PAIR[0], 9).collect();
126        let validated = super::validate(report(
127            "rbr",
128            "0.2.0",
129            vec![
130                ("Plugins/Pacenote/PaceNote.ini", PAIR.to_vec()),
131                ("C:\\Windows\\system.ini", PAIR.to_vec()),
132                ("../escape.ini", PAIR.to_vec()),
133                (long.as_str(), PAIR.to_vec()),
134                ("a.ini", vec![PAIR[0]]),
135                ("a.ini", nine),
136                ("a.ini", vec![PAIR[0], "no-separator"]),
137            ],
138        ))
139        .unwrap();
140        assert_eq!(validated.conflicts.len(), 1);
141        assert_eq!(validated.dropped, 6);
142    }
143
144    #[test]
145    fn conflicts_beyond_the_cap_are_dropped() {
146        let many: Vec<(&str, Vec<&str>)> = (0..40).map(|_| ("a.ini", PAIR.to_vec())).collect();
147        let validated = super::validate(report("rbr", "0.2.0", many)).unwrap();
148        assert_eq!(validated.conflicts.len(), super::MAX_CONFLICTS);
149        assert_eq!(validated.dropped, 40 - super::MAX_CONFLICTS);
150    }
151}