Skip to main content

pacenotes_server/store/
game_schema.rs

1//! Each game's `<data>/<game>/schema.toml`: which categories its catalogue
2//! has, published in the manifest with the hash installers compare.
3
4/// The repository's `schemas/<game>.toml` byte for byte, which installers of
5/// the same release embed too.
6pub const BUNDLED_SCHEMAS: &[(&str, &[u8])] = &[(
7    pacenotes_shared::GAME_RBR,
8    include_bytes!("../../../schemas/rbr.toml"),
9)];
10
11/// Strict, so a typo cannot silently change what a category means.
12#[derive(serde::Deserialize)]
13#[serde(deny_unknown_fields)]
14struct SchemaFile {
15    #[serde(default)]
16    category: Vec<SchemaCategoryFile>,
17}
18
19#[derive(serde::Deserialize)]
20#[serde(deny_unknown_fields)]
21struct SchemaCategoryFile {
22    id: String,
23    select: String,
24    install_root: String,
25}
26
27/// Validates a schema and hashes its raw bytes, comments included, since that
28/// is the hash an installer compares.
29pub fn parse_schema(bytes: &[u8]) -> anyhow::Result<pacenotes_shared::GameSchema> {
30    let text = std::str::from_utf8(bytes).map_err(|_| anyhow::anyhow!("not UTF-8"))?;
31    let file: SchemaFile = toml::from_str(text).map_err(|e| anyhow::anyhow!("{e}"))?;
32    if file.category.is_empty() {
33        anyhow::bail!("declares no [[category]] — a game with no categories can publish nothing");
34    }
35    let mut categories: Vec<pacenotes_shared::SchemaCategory> = Vec::new();
36    for category in file.category {
37        if categories.iter().any(|seen| seen.id == category.id) {
38            anyhow::bail!("category {:?} is declared twice", category.id);
39        }
40        categories.push(validated(category)?);
41    }
42    Ok(pacenotes_shared::GameSchema {
43        sha256: crate::store::sha256_bytes(bytes),
44        categories,
45    })
46}
47
48fn validated(category: SchemaCategoryFile) -> anyhow::Result<pacenotes_shared::SchemaCategory> {
49    if !pacenotes_shared::valid_id(&category.id) {
50        anyhow::bail!(
51            "category id {:?} must be [a-z0-9_-]+ (it is a folder name)",
52            category.id
53        );
54    }
55    if category.select != pacenotes_shared::SELECT_ONE
56        && category.select != pacenotes_shared::SELECT_ANY
57    {
58        anyhow::bail!(
59            "category {:?}: select must be {:?} or {:?}, not {:?}",
60            category.id,
61            pacenotes_shared::SELECT_ONE,
62            pacenotes_shared::SELECT_ANY,
63            category.select
64        );
65    }
66    // A token the installer maps to a place it already knows, so nothing here steers writes.
67    let is_token = !category.install_root.is_empty()
68        && category
69            .install_root
70            .bytes()
71            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
72    if !is_token {
73        anyhow::bail!(
74            "category {:?}: install_root {:?} must be a token like {:?}, never a path",
75            category.id,
76            category.install_root,
77            pacenotes_shared::INSTALL_ROOT_GAME
78        );
79    }
80    Ok(pacenotes_shared::SchemaCategory {
81        id: category.id,
82        select: category.select,
83        install_root: category.install_root,
84    })
85}
86
87/// The game's schema, or the error that withholds the whole game.
88pub fn load_schema(
89    game_dir: &std::path::Path,
90    game: &str,
91) -> Result<pacenotes_shared::GameSchema, String> {
92    let schema_file = pacenotes_shared::SCHEMA_FILE;
93    let path = game_dir.join(schema_file);
94    let bytes = match std::fs::read(&path) {
95        Ok(bytes) => bytes,
96        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
97            return Err(format!(
98                "{game}/{schema_file} is missing — NOTHING is published for {game} until it \
99                 exists (copy schemas/{game}.toml from the repository if there is one)"
100            ));
101        }
102        Err(e) => {
103            return Err(format!(
104                "reading {}: {e} — {game} not published",
105                path.display()
106            ))
107        }
108    };
109    parse_schema(&bytes)
110        .map_err(|e| format!("{game}/{schema_file}: {e} — nothing published for {game}"))
111}
112
113/// Why a game's schema will stop installers of this release from downloading
114/// it, if it will; a warning, since the operator may be mid-rollout.
115pub fn bundled_schema_warning(
116    game: &str,
117    schema: &pacenotes_shared::GameSchema,
118    bundled: &[(&str, &[u8])],
119) -> Option<String> {
120    match bundled.iter().find(|(id, _)| *id == game) {
121        Some((_, expected)) => {
122            let expected = crate::store::sha256_bytes(expected);
123            (expected != schema.sha256).then(|| {
124                format!(
125                    "{game}/{} differs from the schema this server release was built with \
126                     (sha256 {} vs {expected}). Installers from this release will REFUSE to \
127                     download {game} packages until they are updated — intended only as part \
128                     of a schema rollout",
129                    pacenotes_shared::SCHEMA_FILE,
130                    schema.sha256
131                )
132            })
133        }
134        None if !bundled.is_empty() => Some(format!(
135            "this server release bundles no schema for {game}: installers built alongside it \
136             will refuse to download {game} packages"
137        )),
138        None => None,
139    }
140}
141
142#[cfg(test)]
143mod tests;