Skip to main content

kcode_web_libs/
model.rs

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    /// Canonical slash-separated path relative to the library root.
16    pub path: String,
17    /// Complete UTF-8 file contents.
18    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}