kcode-web-libs 0.1.0

Managed plain HTML, CSS, and JavaScript libraries with browser checks and immutable publication
Documentation
use crate::{Error, Result};
use semver::Version;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;

pub(crate) const MANIFEST_PATH: &str = "kcode-web.json";
pub(crate) const DOCUMENTATION_PATH: &str = "Documentation.md";
pub(crate) const MAX_FILES: usize = 1_024;
pub(crate) const MAX_FILE_BYTES: usize = 16 * 1024 * 1024;
pub(crate) const MAX_TREE_BYTES: usize = 64 * 1024 * 1024;

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct File {
    /// Canonical slash-separated path relative to the library root.
    pub path: String,
    /// Complete UTF-8 file contents.
    pub contents: String,
}

impl File {
    pub(crate) fn new(path: impl Into<String>, contents: impl Into<String>) -> Self {
        Self {
            path: path.into(),
            contents: contents.into(),
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Manifest {
    pub name: String,
    pub version: String,
    pub entry: String,
    pub tests: String,
}

#[derive(Clone, Debug)]
pub(crate) struct ValidatedTree {
    pub manifest: Manifest,
    pub source_sha256: String,
}

pub(crate) fn validate_module_name(name: &str) -> Result<()> {
    if name.is_empty() || name.len() > 255 {
        return Err(Error::invalid_name(
            "module names must contain between 1 and 255 bytes",
        ));
    }

    let bytes = name.as_bytes();
    if !bytes[0].is_ascii_alphanumeric() {
        return Err(Error::invalid_name(
            "module names must begin with an ASCII letter or digit",
        ));
    }

    if !bytes
        .iter()
        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
    {
        return Err(Error::invalid_name(
            "module names may contain only ASCII letters, digits, hyphens, and underscores",
        ));
    }

    Ok(())
}

pub(crate) fn validate_file_path(path: &str) -> Result<()> {
    if path.is_empty() || path.len() > 4_096 {
        return Err(Error::invalid_source(
            "file paths must contain between 1 and 4096 bytes",
        ));
    }
    if path.starts_with('/')
        || path.ends_with('/')
        || path.contains('\\')
        || path.contains(':')
        || path.contains('\0')
    {
        return Err(Error::invalid_source(format!(
            "invalid relative file path `{path}`"
        )));
    }

    for component in path.split('/') {
        if component.is_empty() || component == "." || component == ".." {
            return Err(Error::invalid_source(format!(
                "invalid relative file path `{path}`"
            )));
        }
        if component.len() > 255 {
            return Err(Error::invalid_source(format!(
                "file path component exceeds 255 bytes in `{path}`"
            )));
        }
    }

    Ok(())
}

pub(crate) fn validate_tree(files: &[File], expected_name: &str) -> Result<ValidatedTree> {
    validate_module_name(expected_name)?;

    if files.is_empty() || files.len() > MAX_FILES {
        return Err(Error::invalid_source(format!(
            "a web library must contain between 1 and {MAX_FILES} files"
        )));
    }

    let mut by_path = BTreeMap::new();
    let mut total_bytes = 0usize;

    for file in files {
        validate_file_path(&file.path)?;

        let bytes = file.contents.len();
        if bytes > MAX_FILE_BYTES {
            return Err(Error::invalid_source(format!(
                "`{}` exceeds the {} byte per-file limit",
                file.path, MAX_FILE_BYTES
            )));
        }
        total_bytes = total_bytes
            .checked_add(bytes)
            .ok_or_else(|| Error::invalid_source("source byte count overflow"))?;
        if total_bytes > MAX_TREE_BYTES {
            return Err(Error::invalid_source(format!(
                "the source tree exceeds the {MAX_TREE_BYTES} byte limit"
            )));
        }

        if by_path.insert(file.path.as_str(), file).is_some() {
            return Err(Error::invalid_source(format!(
                "duplicate source path `{}`",
                file.path
            )));
        }
    }

    for path in by_path.keys() {
        for (separator, _) in path.match_indices('/') {
            let ancestor = &path[..separator];
            if by_path.contains_key(ancestor) {
                return Err(Error::invalid_source(format!(
                    "source path `{ancestor}` is a file and an ancestor of `{path}`"
                )));
            }
        }
    }

    let manifest_file = by_path
        .get(MANIFEST_PATH)
        .ok_or_else(|| Error::invalid_source(format!("missing `{MANIFEST_PATH}`")))?;
    if !by_path.contains_key(DOCUMENTATION_PATH) {
        return Err(Error::invalid_source(format!(
            "missing `{DOCUMENTATION_PATH}`"
        )));
    }

    let manifest = validate_manifest(&manifest_file.contents, expected_name)?;

    validate_module_path(&manifest.entry, "entry", &by_path)?;
    validate_module_path(&manifest.tests, "tests", &by_path)?;

    let mut hasher = Sha256::new();
    for (path, file) in &by_path {
        hash_field(&mut hasher, path.as_bytes());
        hash_field(&mut hasher, file.contents.as_bytes());
    }

    Ok(ValidatedTree {
        manifest,
        source_sha256: hex(&hasher.finalize()),
    })
}

pub(crate) fn validate_manifest(contents: &str, expected_name: &str) -> Result<Manifest> {
    let manifest: Manifest = serde_json::from_str(contents)
        .map_err(|error| Error::invalid_source(format!("invalid `{MANIFEST_PATH}`: {error}")))?;

    if manifest.name != expected_name {
        return Err(Error::invalid_source(format!(
            "manifest name `{}` does not match managed name `{expected_name}`",
            manifest.name
        )));
    }

    let version = Version::parse(&manifest.version).map_err(|error| {
        Error::invalid_source(format!(
            "manifest version `{}` is not valid SemVer: {error}",
            manifest.version
        ))
    })?;
    if version.to_string() != manifest.version {
        return Err(Error::invalid_source(format!(
            "manifest version must use canonical SemVer spelling `{version}`"
        )));
    }
    if !version.pre.is_empty() || !version.build.is_empty() {
        return Err(Error::invalid_source(format!(
            "manifest version `{}` must be a stable major.minor.patch version",
            manifest.version
        )));
    }

    validate_file_path(&manifest.entry)?;
    validate_file_path(&manifest.tests)?;
    Ok(manifest)
}

fn validate_module_path(path: &str, field: &str, by_path: &BTreeMap<&str, &File>) -> Result<()> {
    validate_file_path(path)?;
    if !(path.ends_with(".js") || path.ends_with(".mjs")) {
        return Err(Error::invalid_source(format!(
            "manifest {field} `{path}` must be a .js or .mjs file"
        )));
    }
    if !by_path.contains_key(path) {
        return Err(Error::invalid_source(format!(
            "manifest {field} `{path}` is missing from the source tree"
        )));
    }
    Ok(())
}

fn hash_field(hasher: &mut Sha256, bytes: &[u8]) {
    hasher.update((bytes.len() as u64).to_be_bytes());
    hasher.update(bytes);
}

fn hex(bytes: &[u8]) -> String {
    const DIGITS: &[u8; 16] = b"0123456789abcdef";
    let mut result = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        result.push(DIGITS[(byte >> 4) as usize] as char);
        result.push(DIGITS[(byte & 0x0f) as usize] as char);
    }
    result
}

#[cfg(test)]
mod tests {
    use super::*;

    fn valid_files() -> Vec<File> {
        vec![
            File::new(
                MANIFEST_PATH,
                r#"{"name":"example","version":"0.1.0","entry":"index.js","tests":"tests.js"}"#,
            ),
            File::new(DOCUMENTATION_PATH, "Example."),
            File::new("index.js", "export const answer = 42;"),
            File::new("tests.js", "export async function runTests() {}"),
        ]
    }

    #[test]
    fn validates_and_hashes_a_complete_tree() {
        let first = validate_tree(&valid_files(), "example").unwrap();

        let mut reversed = valid_files();
        reversed.reverse();
        let second = validate_tree(&reversed, "example").unwrap();

        assert_eq!(first.manifest.version, "0.1.0");
        assert_eq!(first.source_sha256, second.source_sha256);
        assert_eq!(first.source_sha256.len(), 64);
    }

    #[test]
    fn rejects_duplicate_and_escaping_paths() {
        let mut duplicate = valid_files();
        duplicate.push(File::new("index.js", "different"));
        assert!(validate_tree(&duplicate, "example").is_err());

        let mut escaping = valid_files();
        escaping.push(File::new("../outside.js", ""));
        assert!(validate_tree(&escaping, "example").is_err());
    }

    #[test]
    fn rejects_file_and_directory_path_collisions() {
        let mut collision = valid_files();
        collision.push(File::new("assets", "not a directory"));
        collision.push(File::new("assets/scripts/module.js", "export {};"));

        let error = validate_tree(&collision, "example").unwrap_err();
        assert_eq!(
            error.to_string(),
            "invalid_source: source path `assets` is a file and an ancestor of \
             `assets/scripts/module.js`"
        );

        collision.reverse();
        assert!(validate_tree(&collision, "example").is_err());
    }

    #[test]
    fn rejects_wrong_names_versions_and_missing_modules() {
        let mut wrong_name = valid_files();
        wrong_name[0].contents =
            r#"{"name":"other","version":"0.1.0","entry":"index.js","tests":"tests.js"}"#.into();
        assert!(validate_tree(&wrong_name, "example").is_err());

        let mut wrong_version = valid_files();
        wrong_version[0].contents =
            r#"{"name":"example","version":"v0.1.0","entry":"index.js","tests":"tests.js"}"#.into();
        assert!(validate_tree(&wrong_version, "example").is_err());

        for version in ["1.2.3-beta", "1.2.3+build"] {
            let mut unstable = valid_files();
            unstable[0].contents = format!(
                r#"{{"name":"example","version":"{version}","entry":"index.js","tests":"tests.js"}}"#
            );
            assert!(validate_tree(&unstable, "example").is_err());
        }

        let mut missing = valid_files();
        missing.retain(|file| file.path != "tests.js");
        assert!(validate_tree(&missing, "example").is_err());
    }

    #[test]
    fn accepts_standard_names_and_utf8_paths() {
        let mut files = valid_files();
        files[0].contents =
            r#"{"name":"Example_lib-2","version":"1.2.3","entry":"src/entrée module.js","tests":"tests.js"}"#
                .into();
        files[2].path = "src/entrée module.js".into();

        assert!(validate_tree(&files, "Example_lib-2").is_ok());
    }
}