pacenotes_server/store/
game_schema.rs1pub const BUNDLED_SCHEMAS: &[(&str, &[u8])] = &[(
7 pacenotes_shared::GAME_RBR,
8 include_bytes!("../../../schemas/rbr.toml"),
9)];
10
11#[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
27pub 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 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
87pub 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
113pub 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;