1pub fn generate(data_dir: &std::path::Path) -> anyhow::Result<super::Generated> {
12 generate_with(data_dir, crate::store::BUNDLED_SCHEMAS)
13}
14
15pub fn generate_with(
18 data_dir: &std::path::Path,
19 bundled: &[(&str, &[u8])],
20) -> anyhow::Result<super::Generated> {
21 let mut generated = super::Generated {
22 manifest: pacenotes_shared::Manifest::default(),
23 warnings: Vec::new(),
24 errors: Vec::new(),
25 };
26
27 for entry in read_dir_sorted(data_dir)? {
28 let path = entry.path();
29 if path.is_file() {
30 if is_zip(&path) {
31 generated.error(format!(
32 "top-level zip {}: a package belongs under <game>/{}/<directory>/ — \
33 not published",
34 path.display(),
35 crate::store::PACKAGES_DIR
36 ));
37 }
38 continue;
39 }
40 let game = entry.file_name().to_string_lossy().into_owned();
41 if path.is_dir() && !crate::store::RESERVED_TOP_LEVEL.contains(&game.as_str()) {
42 scan_game(&path, &game, bundled, &mut generated)?;
43 }
44 }
45
46 for problem in crate::store::requires_check::requires_problems(&generated.manifest) {
47 generated.warn(problem);
48 }
49 let reports_dir = data_dir.join(crate::store::REPORTS_DIR);
51 for warning in crate::reports::overlap_warnings(&reports_dir, crate::auth::now()) {
52 generated.warn(warning);
53 }
54 Ok(generated)
55}
56
57fn scan_game(
58 game_dir: &std::path::Path,
59 game: &str,
60 bundled: &[(&str, &[u8])],
61 generated: &mut super::Generated,
62) -> anyhow::Result<()> {
63 let packages_dir = crate::store::PACKAGES_DIR;
64 if !pacenotes_shared::valid_id(game) {
65 generated.warn(format!("skipping {game:?}: a game id must be [a-z0-9_-]+"));
66 return Ok(());
67 }
68 if pacenotes_shared::KNOWN_CATEGORIES.contains(&game) || game == pacenotes_shared::PUBLIC_DIR {
69 generated.warn(format!(
70 "{game:?} is at the top level, where a GAME id belongs (e.g. {:?}). \
71 The layout is <game>/{packages_dir}/<directory>/<name>.zip.",
72 pacenotes_shared::GAME_RBR
73 ));
74 }
75 if !pacenotes_shared::KNOWN_GAMES.contains(&game) {
76 generated.warn(format!(
77 "{game:?} is not a game this protocol knows ({:?}) — publishing it anyway",
78 pacenotes_shared::KNOWN_GAMES
79 ));
80 }
81
82 let packages_root = game_dir.join(packages_dir);
83 if !packages_root.is_dir() {
84 generated.warn(format!(
85 "{game}/ has no {packages_dir}/ folder — nothing published for it"
86 ));
87 return Ok(());
88 }
89 let schema = match crate::store::game_schema::load_schema(game_dir, game) {
90 Ok(schema) => schema,
91 Err(error) => {
92 generated.error(error);
93 return Ok(());
94 }
95 };
96 if let Some(warning) = crate::store::game_schema::bundled_schema_warning(game, &schema, bundled)
97 {
98 generated.warn(warning);
99 }
100
101 for entry in read_dir_sorted(&packages_root)? {
102 let path = entry.path();
103 if path.is_file() {
104 if is_zip(&path) {
105 generated.error(format!(
106 "zip outside an access directory: {} — every package must live in one \
107 (put anonymous packages in {}/); not published",
108 path.display(),
109 pacenotes_shared::PUBLIC_DIR
110 ));
111 }
112 continue;
113 }
114 let directory = entry.file_name().to_string_lossy().into_owned();
115 if !path.is_dir() {
116 continue;
117 }
118 if !pacenotes_shared::valid_id(&directory) {
119 generated.warn(format!(
120 "skipping {game}/{packages_dir}/{directory:?}: an access directory must be \
121 [a-z0-9_-]+"
122 ));
123 continue;
124 }
125 if pacenotes_shared::KNOWN_CATEGORIES.contains(&directory.as_str()) {
126 generated.warn(format!(
127 "{game}/{packages_dir}/{directory}/ is a CATEGORY name where an access \
128 directory (e.g. {public:?}) belongs — categories go one level down, as \
129 {public}/{directory}/…",
130 public = pacenotes_shared::PUBLIC_DIR
131 ));
132 }
133 scan_access_directory(&path, game, &directory, &schema, generated)?;
134 }
135 generated.manifest.schemas.insert(game.to_string(), schema);
136 Ok(())
137}
138
139fn scan_access_directory(
140 directory_path: &std::path::Path,
141 game: &str,
142 directory: &str,
143 schema: &pacenotes_shared::GameSchema,
144 generated: &mut super::Generated,
145) -> anyhow::Result<()> {
146 let mut found = FoundFiles::default();
147 collect(directory_path, "", &mut found, generated);
148
149 for (name, zip_path) in &found.zips {
150 let category = name.split('/').next().unwrap_or(name);
151 if !schema
152 .categories
153 .iter()
154 .any(|declared| declared.id == category)
155 {
156 generated.warn(format!(
157 "{game}/{directory}/{name}.zip is in category {category:?}, which \
158 {game}/{} does not declare — not published",
159 pacenotes_shared::SCHEMA_FILE
160 ));
161 continue;
162 }
163 let sidecar = match crate::store::sidecar::read_sidecar(&zip_path.with_extension("toml")) {
164 Ok(sidecar) => sidecar,
165 Err(e) => {
166 generated.error(format!(
167 "{game}/{directory}/{name}.toml: {e} — package not published"
168 ));
169 continue;
170 }
171 };
172 let package = package_entry(game, directory, name, zip_path, sidecar)?;
173 generated.manifest.packages.push(package);
174 }
175
176 for sidecar in &found.sidecars {
177 if !found.zips.iter().any(|(name, _)| name == sidecar) {
178 generated.warn(format!(
179 "{game}/{directory}/{sidecar}.toml has no {sidecar}.zip next to it — \
180 the metadata is ignored"
181 ));
182 }
183 }
184 Ok(())
185}
186
187fn package_entry(
190 game: &str,
191 directory: &str,
192 name: &str,
193 zip_path: &std::path::Path,
194 sidecar: crate::store::sidecar::Sidecar,
195) -> anyhow::Result<pacenotes_shared::PackageInfo> {
196 let metadata = std::fs::metadata(zip_path)
197 .map_err(|e| anyhow::anyhow!("stat {}: {e}", zip_path.display()))?;
198 let updated_at = metadata
199 .modified()
200 .ok()
201 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
202 .map(|since_epoch| since_epoch.as_secs() as i64)
203 .unwrap_or(0);
204
205 let mut requires = sidecar.requires.unwrap_or_default();
206 requires.sort();
207 requires.dedup();
208
209 let sha256 = crate::store::sha256_file(zip_path)?;
210 Ok(pacenotes_shared::PackageInfo {
211 directory: directory.to_string(),
212 name: name.to_string(),
213 version: sidecar
214 .version
215 .unwrap_or_else(|| crate::store::derived_version(&sha256)),
216 size: metadata.len(),
217 sha256,
218 updated_at,
219 game: game.to_string(),
220 display_name: sidecar.display_name,
221 description: sidecar.description,
222 exclusive_group: sidecar.exclusive_group,
223 requires,
224 })
225}
226
227#[derive(Default)]
229struct FoundFiles {
230 zips: Vec<(String, std::path::PathBuf)>,
231 sidecars: Vec<String>,
232}
233
234fn collect(
236 dir: &std::path::Path,
237 prefix: &str,
238 found: &mut FoundFiles,
239 generated: &mut super::Generated,
240) {
241 let entries = match read_dir_sorted(dir) {
242 Ok(entries) => entries,
243 Err(e) => {
244 generated.warn(format!("skipping {}: {e}", dir.display()));
245 return;
246 }
247 };
248
249 for entry in entries {
250 let path = entry.path();
251 let Some(file_name) = entry.file_name().to_str().map(str::to_string) else {
252 generated.warn(format!(
253 "skipping {}: name is not valid UTF-8",
254 path.display()
255 ));
256 continue;
257 };
258 if path.is_dir() {
259 collect(&path, &join_name(prefix, &file_name), found, generated);
260 continue;
261 }
262
263 let extension = path.extension().and_then(|extension| extension.to_str());
264 if !path.is_file() || !matches!(extension, Some("zip" | "toml")) {
265 continue;
266 }
267 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
268 continue;
269 };
270 let name = join_name(prefix, stem);
271 if !pacenotes_shared::valid_name(&name) {
272 generated.warn(format!(
273 "skipping {}: {name:?} is not a usable package name",
274 path.display()
275 ));
276 continue;
277 }
278
279 if extension == Some("zip") {
280 if !url_clean(&name) {
281 generated.warn(format!(
282 "{name:?} contains characters a client must percent-encode \
283 (space, #, ?, %, +, …) — consider renaming the file"
284 ));
285 }
286 found.zips.push((name, path));
287 } else {
288 found.sidecars.push(name);
289 }
290 }
291}
292
293fn url_clean(name: &str) -> bool {
295 name.bytes()
296 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/'))
297}
298
299fn join_name(prefix: &str, segment: &str) -> String {
301 if prefix.is_empty() {
302 segment.to_string()
303 } else {
304 format!("{prefix}/{segment}")
305 }
306}
307
308fn is_zip(path: &std::path::Path) -> bool {
309 path.extension().is_some_and(|extension| extension == "zip")
310}
311
312fn read_dir_sorted(dir: &std::path::Path) -> anyhow::Result<Vec<std::fs::DirEntry>> {
313 let mut entries = std::fs::read_dir(dir)
314 .map_err(|e| anyhow::anyhow!("reading {}: {e}", dir.display()))?
315 .collect::<std::io::Result<Vec<_>>>()
316 .map_err(|e| anyhow::anyhow!("reading {}: {e}", dir.display()))?;
317 entries.sort_by_key(|entry| entry.file_name());
318 Ok(entries)
319}
320
321#[cfg(test)]
322mod tests;