1use std::collections::BTreeMap;
15
16use shuyi_assets::bundle::{Layer, LayerError};
17
18#[cfg(target_arch = "wasm32")]
22const PLUGIN_VERSION: u32 = 1;
23
24#[cfg(target_arch = "wasm32")]
28#[allow(unsafe_code)]
29unsafe extern "C" {
30 safe fn shuyi_page_file_count() -> u32;
33 safe fn shuyi_page_file_path(i: u32) -> sapp_jsutils::JsObject;
35 safe fn shuyi_page_file_text(i: u32) -> sapp_jsutils::JsObject;
37 safe fn shuyi_page_report(text: sapp_jsutils::JsObjectWeak);
40}
41
42#[cfg(target_arch = "wasm32")]
44#[allow(unsafe_code)]
45#[unsafe(no_mangle)]
46pub extern "C" fn shuyi_page_files_crate_version() -> u32 {
47 PLUGIN_VERSION
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[non_exhaustive]
56pub struct PageLimits {
57 pub per_file: u64,
59 pub in_all: u64,
61}
62
63impl PageLimits {
64 #[must_use]
66 pub const fn new(per_file: u64, in_all: u64) -> Self {
67 Self { per_file, in_all }
68 }
69}
70
71impl Default for PageLimits {
72 fn default() -> Self {
74 Self::new(256 * 1024, 1024 * 1024)
75 }
76}
77
78#[derive(Debug)]
80#[non_exhaustive]
81pub enum PageError {
82 TooBig {
84 path: String,
86 limit: u64,
88 },
89 TooMuch {
91 limit: u64,
93 },
94 Layer(LayerError),
96}
97
98impl core::fmt::Display for PageError {
99 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
100 match self {
101 Self::TooBig { path, limit } => write!(f, "{path}: the file is over {limit} bytes"),
102 Self::TooMuch { limit } => {
103 write!(f, "the page's files are over {limit} bytes in all")
104 }
105 Self::Layer(error) => error.fmt(f),
106 }
107 }
108}
109
110impl std::error::Error for PageError {}
111
112pub fn page_layer(name: &str, limits: PageLimits) -> Result<Option<Layer>, PageError> {
126 layer_from(name, page_files(), limits)
127}
128
129#[cfg(target_arch = "wasm32")]
131fn page_files() -> Vec<(String, String)> {
132 let read = |object: sapp_jsutils::JsObject| {
133 let mut text = String::new();
134 object.to_string(&mut text);
135 text
136 };
137 (0..shuyi_page_file_count())
138 .map(|i| (read(shuyi_page_file_path(i)), read(shuyi_page_file_text(i))))
139 .collect()
140}
141
142#[cfg(not(target_arch = "wasm32"))]
144fn page_files() -> Vec<(String, String)> {
145 Vec::new()
146}
147
148pub fn report(text: &str) {
154 #[cfg(target_arch = "wasm32")]
155 shuyi_page_report(sapp_jsutils::JsObject::string(text).weak());
156 #[cfg(not(target_arch = "wasm32"))]
157 let _ = text;
158}
159
160fn layer_from(
163 name: &str,
164 entries: Vec<(String, String)>,
165 limits: PageLimits,
166) -> Result<Option<Layer>, PageError> {
167 let folder = if name.is_empty() {
168 String::new()
169 } else {
170 format!("{name}/")
171 };
172 let mut files = BTreeMap::new();
173 let mut total: u64 = 0;
174 for (path, text) in entries {
175 let Some(inner) = path.strip_prefix(&folder) else {
176 continue;
177 };
178 let size = u64::try_from(text.len()).unwrap_or(u64::MAX);
179 if size > limits.per_file {
180 return Err(PageError::TooBig {
181 path,
182 limit: limits.per_file,
183 });
184 }
185 total = total.saturating_add(size);
186 if total > limits.in_all {
187 return Err(PageError::TooMuch {
188 limit: limits.in_all,
189 });
190 }
191 files.insert(inner.to_owned(), text.into_bytes());
192 }
193 if files.is_empty() {
194 return Ok(None);
195 }
196 Layer::owned(name.to_owned(), files)
197 .map(Some)
198 .map_err(PageError::Layer)
199}
200
201#[cfg(test)]
202mod tests {
203 use shuyi_assets::bundle::{BadPath, Bundle};
204
205 use super::*;
206
207 static GAME: &[(&str, &[u8])] = &[("en.ron", b"text"), ("stage.map", b"the game's map")];
209
210 fn page(entries: &[(&str, &str)]) -> Vec<(String, String)> {
212 entries
213 .iter()
214 .map(|&(path, text)| (path.to_owned(), text.to_owned()))
215 .collect()
216 }
217
218 fn over(page: Layer) -> Bundle {
220 Bundle::new(vec![page, Layer::table("files", GAME)])
221 }
222
223 #[test]
224 fn no_files_is_no_layer() {
225 let layer = layer_from("files", Vec::new(), PageLimits::default()).unwrap();
226 assert!(layer.is_none());
227 }
228
229 #[test]
230 fn a_file_of_the_page_is_read_over_the_games_own() {
231 let entries = page(&[("files/stage.map", "the page's map")]);
232 let layer = layer_from("files", entries, PageLimits::default())
233 .unwrap()
234 .unwrap();
235 let bundle = over(layer);
236 assert_eq!(bundle.file("stage.map"), Some("the page's map"));
237 assert_eq!(bundle.display_path("stage.map"), "files/stage.map");
238 assert_eq!(bundle.file("en.ron"), Some("text"));
239 }
240
241 #[test]
242 fn a_new_file_of_the_page_is_read_beside_the_games() {
243 let entries = page(&[("files/more/new.ron", "new")]);
244 let layer = layer_from("files", entries, PageLimits::default())
245 .unwrap()
246 .unwrap();
247 let bundle = over(layer);
248 assert_eq!(bundle.file("more/new.ron"), Some("new"));
249 assert_eq!(bundle.file("stage.map"), Some("the game's map"));
250 }
251
252 #[test]
253 fn files_outside_the_folder_are_not_the_layers() {
254 let entries = page(&[("other/stage.map", "not ours"), ("files", "nor this")]);
255 let layer = layer_from("files", entries, PageLimits::default()).unwrap();
256 assert!(layer.is_none());
257 let entries = page(&[("other/stage.map", "all of them")]);
259 let layer = layer_from("", entries, PageLimits::default())
260 .unwrap()
261 .unwrap();
262 assert_eq!(
263 Bundle::new(vec![layer]).file("other/stage.map"),
264 Some("all of them")
265 );
266 }
267
268 #[test]
269 fn a_file_at_the_limit_is_read_and_one_over_it_is_refused() {
270 let limits = PageLimits::new(4, 100);
271 let entries = page(&[("files/a.ron", "1234"), ("files/b.ron", "12345")]);
272 let error = layer_from("files", entries, limits).unwrap_err();
273 assert!(
274 matches!(&error, PageError::TooBig { path, limit: 4 } if path == "files/b.ron"),
275 "{error:?}"
276 );
277 assert_eq!(error.to_string(), "files/b.ron: the file is over 4 bytes");
278 let entries = page(&[("files/a.ron", "1234")]);
279 assert!(layer_from("files", entries, limits).unwrap().is_some());
280 }
281
282 #[test]
283 fn files_over_the_limit_in_all_are_refused() {
284 let limits = PageLimits::new(4, 7);
285 let entries = page(&[("files/a.ron", "1234"), ("files/b.ron", "123")]);
286 assert!(layer_from("files", entries, limits).unwrap().is_some());
287 let entries = page(&[("files/a.ron", "1234"), ("files/b.ron", "1234")]);
288 let error = layer_from("files", entries, limits).unwrap_err();
289 assert!(
290 matches!(error, PageError::TooMuch { limit: 7 }),
291 "{error:?}"
292 );
293 assert!(error.to_string().contains("over 7 bytes"), "{error}");
294 }
295
296 #[test]
297 fn a_bad_path_is_refused_naming_it() {
298 let entries = page(&[("files/../secret.ron", "x")]);
299 let error = layer_from("files", entries, PageLimits::default()).unwrap_err();
300 assert!(
301 matches!(&error, PageError::Layer(e) if e.problem == BadPath::Parent && e.path == "../secret.ron"),
302 "{error:?}"
303 );
304 assert!(error.to_string().contains("../secret.ron"), "{error}");
305 }
306
307 #[test]
308 fn the_default_limits_are_256_kib_a_file_and_1_mib_in_all() {
309 assert_eq!(
310 PageLimits::default(),
311 PageLimits::new(256 * 1024, 1024 * 1024)
312 );
313 }
314
315 #[test]
316 fn off_the_web_the_page_holds_no_files_and_hears_nothing() {
317 assert!(
318 page_layer("files", PageLimits::default())
319 .unwrap()
320 .is_none()
321 );
322 assert!(page_layer("", PageLimits::default()).unwrap().is_none());
323 report("files/stage.map:3: a problem");
324 }
325}