Skip to main content

kcode_web_libs/
model.rs

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    /// Canonical slash-separated path relative to the library root.
12    pub path: String,
13    /// Complete UTF-8 file contents.
14    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    /// Canonical slash-separated path relative to the library root.
29    pub path: String,
30    /// Complete opaque file contents.
31    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
67/// Returns whether `path` is persisted and presented as an opaque asset.
68///
69/// Paths without an extension and common source/document extensions are text.
70/// Every other extension is opaque, so new asset formats work without storage
71/// metadata or library changes.
72pub 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}