1use crate::{Error, Result};
2use semver::Version;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6pub(crate) const MANIFEST_PATH: &str = "kcode-web.json";
7pub(crate) const DOCUMENTATION_PATH: &str = "Documentation.md";
8
9#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
10pub struct File {
11 pub path: String,
13 pub contents: String,
15}
16
17impl File {
18 pub(crate) fn new(path: impl Into<String>, contents: impl Into<String>) -> Self {
19 Self {
20 path: path.into(),
21 contents: contents.into(),
22 }
23 }
24}
25
26#[derive(Clone, Eq, PartialEq)]
27pub struct Asset {
28 pub path: String,
30 pub bytes: Vec<u8>,
32}
33
34impl Asset {
35 pub fn new(path: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
36 Self {
37 path: path.into(),
38 bytes: bytes.into(),
39 }
40 }
41}
42
43impl std::fmt::Debug for Asset {
44 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 formatter
46 .debug_struct("Asset")
47 .field("path", &self.path)
48 .field("byte_len", &self.bytes.len())
49 .finish()
50 }
51}
52
53#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub(crate) struct Manifest {
56 pub name: String,
57 pub version: String,
58 pub entry: String,
59 pub tests: String,
60}
61
62#[derive(Clone, Debug)]
63pub(crate) struct ValidatedTree {
64 pub manifest: Manifest,
65}
66
67pub fn is_asset_path(path: &str) -> bool {
73 let file_name = path.rsplit('/').next().unwrap_or(path);
74 let Some((stem, extension)) = file_name.rsplit_once('.') else {
75 return false;
76 };
77 if stem.is_empty() {
78 return false;
79 }
80
81 !matches!(
82 extension.to_ascii_lowercase().as_str(),
83 "cjs"
84 | "css"
85 | "csv"
86 | "htm"
87 | "html"
88 | "js"
89 | "json"
90 | "jsonld"
91 | "jsx"
92 | "map"
93 | "markdown"
94 | "md"
95 | "mjs"
96 | "scss"
97 | "text"
98 | "toml"
99 | "ts"
100 | "tsx"
101 | "txt"
102 | "webmanifest"
103 | "xml"
104 | "yaml"
105 | "yml"
106 )
107}
108
109pub(crate) fn validate_module_name(name: &str) -> Result<()> {
110 if name.is_empty() || name.len() > 255 {
111 return Err(Error::invalid_name(
112 "module names must contain between 1 and 255 bytes",
113 ));
114 }
115
116 let bytes = name.as_bytes();
117 if !bytes[0].is_ascii_alphanumeric() {
118 return Err(Error::invalid_name(
119 "module names must begin with an ASCII letter or digit",
120 ));
121 }
122
123 if !bytes
124 .iter()
125 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
126 {
127 return Err(Error::invalid_name(
128 "module names may contain only ASCII letters, digits, hyphens, and underscores",
129 ));
130 }
131
132 Ok(())
133}
134
135pub(crate) fn validate_file_path(path: &str) -> Result<()> {
136 if path.is_empty() || path.len() > 4_096 {
137 return Err(Error::invalid_source(
138 "file paths must contain between 1 and 4096 bytes",
139 ));
140 }
141 if path.starts_with('/')
142 || path.ends_with('/')
143 || path.contains('\\')
144 || path.contains(':')
145 || path.contains('\0')
146 {
147 return Err(Error::invalid_source(format!(
148 "invalid relative file path `{path}`"
149 )));
150 }
151
152 for component in path.split('/') {
153 if component.is_empty() || component == "." || component == ".." {
154 return Err(Error::invalid_source(format!(
155 "invalid relative file path `{path}`"
156 )));
157 }
158 if component.len() > 255 {
159 return Err(Error::invalid_source(format!(
160 "file path component exceeds 255 bytes in `{path}`"
161 )));
162 }
163 }
164
165 Ok(())
166}
167
168pub(crate) fn validate_tree(
169 files: &[File],
170 assets: &[Asset],
171 expected_name: &str,
172) -> Result<ValidatedTree> {
173 validate_module_name(expected_name)?;
174
175 let mut by_path = BTreeMap::new();
176
177 for file in files {
178 validate_file_path(&file.path)?;
179 if is_asset_path(&file.path) {
180 return Err(Error::invalid_source(format!(
181 "`{}` has an opaque asset extension and must be represented as an Asset",
182 file.path
183 )));
184 }
185
186 if by_path.insert(file.path.as_str(), file).is_some() {
187 return Err(Error::invalid_source(format!(
188 "duplicate source path `{}`",
189 file.path
190 )));
191 }
192 }
193
194 let mut assets_by_path = BTreeMap::new();
195 for asset in assets {
196 validate_file_path(&asset.path)?;
197 if !is_asset_path(&asset.path) {
198 return Err(Error::invalid_source(format!(
199 "`{}` has a text extension and must be represented as a File",
200 asset.path
201 )));
202 }
203
204 if by_path.contains_key(asset.path.as_str())
205 || assets_by_path.insert(asset.path.as_str(), asset).is_some()
206 {
207 return Err(Error::invalid_source(format!(
208 "duplicate source path `{}`",
209 asset.path
210 )));
211 }
212 }
213
214 let mut all_paths = by_path
215 .keys()
216 .copied()
217 .chain(assets_by_path.keys().copied())
218 .collect::<Vec<_>>();
219 all_paths.sort_unstable();
220 for path in &all_paths {
221 for (separator, _) in path.match_indices('/') {
222 let ancestor = &path[..separator];
223 if all_paths.binary_search(&ancestor).is_ok() {
224 return Err(Error::invalid_source(format!(
225 "source path `{ancestor}` is a file and an ancestor of `{path}`"
226 )));
227 }
228 }
229 }
230
231 let manifest_file = by_path
232 .get(MANIFEST_PATH)
233 .ok_or_else(|| Error::invalid_source(format!("missing `{MANIFEST_PATH}`")))?;
234 if !by_path.contains_key(DOCUMENTATION_PATH) {
235 return Err(Error::invalid_source(format!(
236 "missing `{DOCUMENTATION_PATH}`"
237 )));
238 }
239
240 let manifest = validate_manifest(&manifest_file.contents, expected_name)?;
241
242 validate_module_path(&manifest.entry, "entry", &by_path)?;
243 validate_module_path(&manifest.tests, "tests", &by_path)?;
244
245 Ok(ValidatedTree { manifest })
246}
247
248pub(crate) fn validate_manifest(contents: &str, expected_name: &str) -> Result<Manifest> {
249 let manifest: Manifest = serde_json::from_str(contents)
250 .map_err(|error| Error::invalid_source(format!("invalid `{MANIFEST_PATH}`: {error}")))?;
251
252 if manifest.name != expected_name {
253 return Err(Error::invalid_source(format!(
254 "manifest name `{}` does not match managed name `{expected_name}`",
255 manifest.name
256 )));
257 }
258
259 let version = Version::parse(&manifest.version).map_err(|error| {
260 Error::invalid_source(format!(
261 "manifest version `{}` is not valid SemVer: {error}",
262 manifest.version
263 ))
264 })?;
265 if version.to_string() != manifest.version {
266 return Err(Error::invalid_source(format!(
267 "manifest version must use canonical SemVer spelling `{version}`"
268 )));
269 }
270 if !version.pre.is_empty() || !version.build.is_empty() {
271 return Err(Error::invalid_source(format!(
272 "manifest version `{}` must be a stable major.minor.patch version",
273 manifest.version
274 )));
275 }
276
277 validate_file_path(&manifest.entry)?;
278 validate_file_path(&manifest.tests)?;
279 Ok(manifest)
280}
281
282fn validate_module_path(path: &str, field: &str, by_path: &BTreeMap<&str, &File>) -> Result<()> {
283 validate_file_path(path)?;
284 if !(path.ends_with(".js") || path.ends_with(".mjs")) {
285 return Err(Error::invalid_source(format!(
286 "manifest {field} `{path}` must be a .js or .mjs file"
287 )));
288 }
289 if !by_path.contains_key(path) {
290 return Err(Error::invalid_source(format!(
291 "manifest {field} `{path}` is missing from the source tree"
292 )));
293 }
294 Ok(())
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 fn valid_files() -> Vec<File> {
302 vec![
303 File::new(
304 MANIFEST_PATH,
305 r#"{"name":"example","version":"0.1.0","entry":"index.js","tests":"tests.js"}"#,
306 ),
307 File::new(DOCUMENTATION_PATH, "Example."),
308 File::new("index.js", "export const answer = 42;"),
309 File::new("tests.js", "export async function runTests() {}"),
310 ]
311 }
312
313 #[test]
314 fn validates_a_complete_tree_independent_of_order() {
315 let first = validate_tree(&valid_files(), &[], "example").unwrap();
316
317 let mut reversed = valid_files();
318 reversed.reverse();
319 let second = validate_tree(&reversed, &[], "example").unwrap();
320
321 assert_eq!(first.manifest.version, "0.1.0");
322 assert_eq!(first.manifest, second.manifest);
323 }
324
325 #[test]
326 fn rejects_duplicate_and_escaping_paths() {
327 let mut duplicate = valid_files();
328 duplicate.push(File::new("index.js", "different"));
329 assert!(validate_tree(&duplicate, &[], "example").is_err());
330
331 let mut escaping = valid_files();
332 escaping.push(File::new("../outside.js", ""));
333 assert!(validate_tree(&escaping, &[], "example").is_err());
334 }
335
336 #[test]
337 fn rejects_file_and_directory_path_collisions() {
338 let mut collision = valid_files();
339 collision.push(File::new("assets", "not a directory"));
340 collision.push(File::new("assets/scripts/module.js", "export {};"));
341
342 let error = validate_tree(&collision, &[], "example").unwrap_err();
343 assert_eq!(
344 error.to_string(),
345 "invalid_source: source path `assets` is a file and an ancestor of \
346 `assets/scripts/module.js`"
347 );
348
349 collision.reverse();
350 assert!(validate_tree(&collision, &[], "example").is_err());
351 }
352
353 #[test]
354 fn rejects_wrong_names_versions_and_missing_modules() {
355 let mut wrong_name = valid_files();
356 wrong_name[0].contents =
357 r#"{"name":"other","version":"0.1.0","entry":"index.js","tests":"tests.js"}"#.into();
358 assert!(validate_tree(&wrong_name, &[], "example").is_err());
359
360 let mut wrong_version = valid_files();
361 wrong_version[0].contents =
362 r#"{"name":"example","version":"v0.1.0","entry":"index.js","tests":"tests.js"}"#.into();
363 assert!(validate_tree(&wrong_version, &[], "example").is_err());
364
365 for version in ["1.2.3-beta", "1.2.3+build"] {
366 let mut unstable = valid_files();
367 unstable[0].contents = format!(
368 r#"{{"name":"example","version":"{version}","entry":"index.js","tests":"tests.js"}}"#
369 );
370 assert!(validate_tree(&unstable, &[], "example").is_err());
371 }
372
373 let mut missing = valid_files();
374 missing.retain(|file| file.path != "tests.js");
375 assert!(validate_tree(&missing, &[], "example").is_err());
376 }
377
378 #[test]
379 fn accepts_standard_names_and_utf8_paths() {
380 let mut files = valid_files();
381 files[0].contents =
382 r#"{"name":"Example_lib-2","version":"1.2.3","entry":"src/entrée module.js","tests":"tests.js"}"#
383 .into();
384 files[2].path = "src/entrée module.js".into();
385
386 assert!(validate_tree(&files, &[], "Example_lib-2").is_ok());
387 }
388
389 #[test]
390 fn validates_opaque_assets_in_the_same_tree() {
391 let assets = vec![Asset::new("fonts/display.woff2", vec![0, 159, 146, 150])];
392 validate_tree(&valid_files(), &assets, "example").unwrap();
393
394 let collision = vec![Asset::new("index.js", vec![0, 1])];
395 assert!(validate_tree(&valid_files(), &collision, "example").is_err());
396
397 let mut opaque_text = valid_files();
398 opaque_text.push(File::new("images/logo.svg", "<svg></svg>"));
399 assert!(validate_tree(&opaque_text, &[], "example").is_err());
400 }
401
402 #[test]
403 fn extension_alone_classifies_assets() {
404 for path in [
405 "fonts/display.woff2",
406 "images/logo.SVG",
407 "downloads/archive.zip",
408 "data/custom-format",
409 ] {
410 assert_eq!(is_asset_path(path), path != "data/custom-format", "{path}");
411 }
412 for path in [
413 "index.html",
414 "styles.css",
415 "entry.js",
416 "data.json",
417 "notes.md",
418 ".gitignore",
419 ] {
420 assert!(!is_asset_path(path), "{path}");
421 }
422 }
423
424 #[test]
425 fn accepts_large_and_numerous_assets_without_policy_limits() {
426 let many = (0..1_100)
427 .map(|index| Asset::new(format!("assets/{index}.bin"), vec![index as u8]))
428 .collect::<Vec<_>>();
429 validate_tree(&valid_files(), &many, "example").unwrap();
430
431 let large = vec![Asset::new(
432 "assets/large.bin",
433 vec![0; 16 * 1024 * 1024 + 1],
434 )];
435 validate_tree(&valid_files(), &large, "example").unwrap();
436 }
437}