1use crate::{Error, Result};
2use semver::Version;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::BTreeMap;
6
7pub(crate) const MANIFEST_PATH: &str = "kcode-web.json";
8pub(crate) const DOCUMENTATION_PATH: &str = "Documentation.md";
9pub(crate) const MAX_FILES: usize = 1_024;
10pub(crate) const MAX_FILE_BYTES: usize = 16 * 1024 * 1024;
11pub(crate) const MAX_TREE_BYTES: usize = 64 * 1024 * 1024;
12
13#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
14pub struct File {
15 pub path: String,
17 pub contents: String,
19}
20
21impl File {
22 pub(crate) fn new(path: impl Into<String>, contents: impl Into<String>) -> Self {
23 Self {
24 path: path.into(),
25 contents: contents.into(),
26 }
27 }
28}
29
30#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub(crate) struct Manifest {
33 pub name: String,
34 pub version: String,
35 pub entry: String,
36 pub tests: String,
37}
38
39#[derive(Clone, Debug)]
40pub(crate) struct ValidatedTree {
41 pub manifest: Manifest,
42 pub source_sha256: String,
43}
44
45pub(crate) fn validate_module_name(name: &str) -> Result<()> {
46 if name.is_empty() || name.len() > 255 {
47 return Err(Error::invalid_name(
48 "module names must contain between 1 and 255 bytes",
49 ));
50 }
51
52 let bytes = name.as_bytes();
53 if !bytes[0].is_ascii_alphanumeric() {
54 return Err(Error::invalid_name(
55 "module names must begin with an ASCII letter or digit",
56 ));
57 }
58
59 if !bytes
60 .iter()
61 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
62 {
63 return Err(Error::invalid_name(
64 "module names may contain only ASCII letters, digits, hyphens, and underscores",
65 ));
66 }
67
68 Ok(())
69}
70
71pub(crate) fn validate_file_path(path: &str) -> Result<()> {
72 if path.is_empty() || path.len() > 4_096 {
73 return Err(Error::invalid_source(
74 "file paths must contain between 1 and 4096 bytes",
75 ));
76 }
77 if path.starts_with('/')
78 || path.ends_with('/')
79 || path.contains('\\')
80 || path.contains(':')
81 || path.contains('\0')
82 {
83 return Err(Error::invalid_source(format!(
84 "invalid relative file path `{path}`"
85 )));
86 }
87
88 for component in path.split('/') {
89 if component.is_empty() || component == "." || component == ".." {
90 return Err(Error::invalid_source(format!(
91 "invalid relative file path `{path}`"
92 )));
93 }
94 if component.len() > 255 {
95 return Err(Error::invalid_source(format!(
96 "file path component exceeds 255 bytes in `{path}`"
97 )));
98 }
99 }
100
101 Ok(())
102}
103
104pub(crate) fn validate_tree(files: &[File], expected_name: &str) -> Result<ValidatedTree> {
105 validate_module_name(expected_name)?;
106
107 if files.is_empty() || files.len() > MAX_FILES {
108 return Err(Error::invalid_source(format!(
109 "a web library must contain between 1 and {MAX_FILES} files"
110 )));
111 }
112
113 let mut by_path = BTreeMap::new();
114 let mut total_bytes = 0usize;
115
116 for file in files {
117 validate_file_path(&file.path)?;
118
119 let bytes = file.contents.len();
120 if bytes > MAX_FILE_BYTES {
121 return Err(Error::invalid_source(format!(
122 "`{}` exceeds the {} byte per-file limit",
123 file.path, MAX_FILE_BYTES
124 )));
125 }
126 total_bytes = total_bytes
127 .checked_add(bytes)
128 .ok_or_else(|| Error::invalid_source("source byte count overflow"))?;
129 if total_bytes > MAX_TREE_BYTES {
130 return Err(Error::invalid_source(format!(
131 "the source tree exceeds the {MAX_TREE_BYTES} byte limit"
132 )));
133 }
134
135 if by_path.insert(file.path.as_str(), file).is_some() {
136 return Err(Error::invalid_source(format!(
137 "duplicate source path `{}`",
138 file.path
139 )));
140 }
141 }
142
143 for path in by_path.keys() {
144 for (separator, _) in path.match_indices('/') {
145 let ancestor = &path[..separator];
146 if by_path.contains_key(ancestor) {
147 return Err(Error::invalid_source(format!(
148 "source path `{ancestor}` is a file and an ancestor of `{path}`"
149 )));
150 }
151 }
152 }
153
154 let manifest_file = by_path
155 .get(MANIFEST_PATH)
156 .ok_or_else(|| Error::invalid_source(format!("missing `{MANIFEST_PATH}`")))?;
157 if !by_path.contains_key(DOCUMENTATION_PATH) {
158 return Err(Error::invalid_source(format!(
159 "missing `{DOCUMENTATION_PATH}`"
160 )));
161 }
162
163 let manifest = validate_manifest(&manifest_file.contents, expected_name)?;
164
165 validate_module_path(&manifest.entry, "entry", &by_path)?;
166 validate_module_path(&manifest.tests, "tests", &by_path)?;
167
168 let mut hasher = Sha256::new();
169 for (path, file) in &by_path {
170 hash_field(&mut hasher, path.as_bytes());
171 hash_field(&mut hasher, file.contents.as_bytes());
172 }
173
174 Ok(ValidatedTree {
175 manifest,
176 source_sha256: hex(&hasher.finalize()),
177 })
178}
179
180pub(crate) fn validate_manifest(contents: &str, expected_name: &str) -> Result<Manifest> {
181 let manifest: Manifest = serde_json::from_str(contents)
182 .map_err(|error| Error::invalid_source(format!("invalid `{MANIFEST_PATH}`: {error}")))?;
183
184 if manifest.name != expected_name {
185 return Err(Error::invalid_source(format!(
186 "manifest name `{}` does not match managed name `{expected_name}`",
187 manifest.name
188 )));
189 }
190
191 let version = Version::parse(&manifest.version).map_err(|error| {
192 Error::invalid_source(format!(
193 "manifest version `{}` is not valid SemVer: {error}",
194 manifest.version
195 ))
196 })?;
197 if version.to_string() != manifest.version {
198 return Err(Error::invalid_source(format!(
199 "manifest version must use canonical SemVer spelling `{version}`"
200 )));
201 }
202 if !version.pre.is_empty() || !version.build.is_empty() {
203 return Err(Error::invalid_source(format!(
204 "manifest version `{}` must be a stable major.minor.patch version",
205 manifest.version
206 )));
207 }
208
209 validate_file_path(&manifest.entry)?;
210 validate_file_path(&manifest.tests)?;
211 Ok(manifest)
212}
213
214fn validate_module_path(path: &str, field: &str, by_path: &BTreeMap<&str, &File>) -> Result<()> {
215 validate_file_path(path)?;
216 if !(path.ends_with(".js") || path.ends_with(".mjs")) {
217 return Err(Error::invalid_source(format!(
218 "manifest {field} `{path}` must be a .js or .mjs file"
219 )));
220 }
221 if !by_path.contains_key(path) {
222 return Err(Error::invalid_source(format!(
223 "manifest {field} `{path}` is missing from the source tree"
224 )));
225 }
226 Ok(())
227}
228
229fn hash_field(hasher: &mut Sha256, bytes: &[u8]) {
230 hasher.update((bytes.len() as u64).to_be_bytes());
231 hasher.update(bytes);
232}
233
234fn hex(bytes: &[u8]) -> String {
235 const DIGITS: &[u8; 16] = b"0123456789abcdef";
236 let mut result = String::with_capacity(bytes.len() * 2);
237 for byte in bytes {
238 result.push(DIGITS[(byte >> 4) as usize] as char);
239 result.push(DIGITS[(byte & 0x0f) as usize] as char);
240 }
241 result
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 fn valid_files() -> Vec<File> {
249 vec![
250 File::new(
251 MANIFEST_PATH,
252 r#"{"name":"example","version":"0.1.0","entry":"index.js","tests":"tests.js"}"#,
253 ),
254 File::new(DOCUMENTATION_PATH, "Example."),
255 File::new("index.js", "export const answer = 42;"),
256 File::new("tests.js", "export async function runTests() {}"),
257 ]
258 }
259
260 #[test]
261 fn validates_and_hashes_a_complete_tree() {
262 let first = validate_tree(&valid_files(), "example").unwrap();
263
264 let mut reversed = valid_files();
265 reversed.reverse();
266 let second = validate_tree(&reversed, "example").unwrap();
267
268 assert_eq!(first.manifest.version, "0.1.0");
269 assert_eq!(first.source_sha256, second.source_sha256);
270 assert_eq!(first.source_sha256.len(), 64);
271 }
272
273 #[test]
274 fn rejects_duplicate_and_escaping_paths() {
275 let mut duplicate = valid_files();
276 duplicate.push(File::new("index.js", "different"));
277 assert!(validate_tree(&duplicate, "example").is_err());
278
279 let mut escaping = valid_files();
280 escaping.push(File::new("../outside.js", ""));
281 assert!(validate_tree(&escaping, "example").is_err());
282 }
283
284 #[test]
285 fn rejects_file_and_directory_path_collisions() {
286 let mut collision = valid_files();
287 collision.push(File::new("assets", "not a directory"));
288 collision.push(File::new("assets/scripts/module.js", "export {};"));
289
290 let error = validate_tree(&collision, "example").unwrap_err();
291 assert_eq!(
292 error.to_string(),
293 "invalid_source: source path `assets` is a file and an ancestor of \
294 `assets/scripts/module.js`"
295 );
296
297 collision.reverse();
298 assert!(validate_tree(&collision, "example").is_err());
299 }
300
301 #[test]
302 fn rejects_wrong_names_versions_and_missing_modules() {
303 let mut wrong_name = valid_files();
304 wrong_name[0].contents =
305 r#"{"name":"other","version":"0.1.0","entry":"index.js","tests":"tests.js"}"#.into();
306 assert!(validate_tree(&wrong_name, "example").is_err());
307
308 let mut wrong_version = valid_files();
309 wrong_version[0].contents =
310 r#"{"name":"example","version":"v0.1.0","entry":"index.js","tests":"tests.js"}"#.into();
311 assert!(validate_tree(&wrong_version, "example").is_err());
312
313 for version in ["1.2.3-beta", "1.2.3+build"] {
314 let mut unstable = valid_files();
315 unstable[0].contents = format!(
316 r#"{{"name":"example","version":"{version}","entry":"index.js","tests":"tests.js"}}"#
317 );
318 assert!(validate_tree(&unstable, "example").is_err());
319 }
320
321 let mut missing = valid_files();
322 missing.retain(|file| file.path != "tests.js");
323 assert!(validate_tree(&missing, "example").is_err());
324 }
325
326 #[test]
327 fn accepts_standard_names_and_utf8_paths() {
328 let mut files = valid_files();
329 files[0].contents =
330 r#"{"name":"Example_lib-2","version":"1.2.3","entry":"src/entrée module.js","tests":"tests.js"}"#
331 .into();
332 files[2].path = "src/entrée module.js".into();
333
334 assert!(validate_tree(&files, "Example_lib-2").is_ok());
335 }
336}