kcode-k1-web-package 0.1.2

Validate authority-namespaced buildless K1 Web package source
Documentation
use crate::{
    AuthorityId, DependencySelector, PackageError, WebId, fail, validate_logical_name,
    validate_source_path, validate_stable,
};
use kcode_k1_transaction_id::TxId;
use semver::Version;
use serde::Deserialize;
use std::str::FromStr;

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Manifest {
    name: String,
    version: String,
    entry: String,
    tests: String,
    dependencies: Vec<ManifestDependency>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ManifestDependency {
    authority: String,
    name: String,
    selector: String,
}

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SourceFile(String, Vec<u8>);

impl SourceFile {
    pub fn new(path: impl Into<String>, bytes: Vec<u8>) -> Self {
        Self(path.into(), bytes)
    }

    pub fn path(&self) -> &str {
        &self.0
    }

    pub fn bytes(&self) -> &[u8] {
        &self.1
    }
}

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct WebDependency(AuthorityId, String, DependencySelector);

impl WebDependency {
    pub const fn authority(&self) -> AuthorityId {
        self.0
    }

    pub fn name(&self) -> &str {
        &self.1
    }

    pub fn selector(&self) -> &DependencySelector {
        &self.2
    }
}

#[derive(Debug, Eq, PartialEq)]
pub struct SourcePackage {
    id: WebId,
    files: Vec<SourceFile>,
    entry: String,
    tests: String,
    dependencies: Vec<WebDependency>,
}

impl SourcePackage {
    pub fn new(id: WebId, mut files: Vec<SourceFile>) -> Result<Self, PackageError> {
        files
            .iter()
            .try_for_each(|file| validate_source_path(file.path()))?;
        files.sort();
        if files
            .windows(2)
            .any(|pair| pair[0].path() == pair[1].path())
        {
            return fail("duplicate source path");
        }
        if files
            .iter()
            .any(|file| has_file_ancestor(&files, file.path()))
        {
            return fail("source path collides with a file ancestor");
        }

        let manifest_source = required_utf8(&files, "k1-web.json")?;
        required_utf8(&files, "Documentation.md")?;
        let manifest: Manifest = serde_json::from_str(manifest_source)
            .map_err(|cause| PackageError(format!("invalid k1-web.json: {cause}")))?;
        validate_identity(&id, &manifest)?;
        required_javascript(&files, &manifest.entry, "entry")?;
        required_javascript(&files, &manifest.tests, "tests")?;
        let dependencies = parse_dependencies(manifest.dependencies)?;

        Ok(Self {
            id,
            files,
            entry: manifest.entry,
            tests: manifest.tests,
            dependencies,
        })
    }

    pub fn id(&self) -> &WebId {
        &self.id
    }

    pub fn files(&self) -> &[SourceFile] {
        &self.files
    }

    pub fn entry(&self) -> &str {
        &self.entry
    }

    pub fn tests(&self) -> &str {
        &self.tests
    }

    pub fn dependencies(&self) -> &[WebDependency] {
        &self.dependencies
    }
}

pub(crate) fn validate_manifest_for_projection(source: &str) -> Result<(), PackageError> {
    let manifest: Manifest = serde_json::from_str(source)
        .map_err(|cause| PackageError(format!("invalid k1-web.json: {cause}")))?;
    validate_logical_name(&manifest.name)?;
    canonical_manifest_version(&manifest.version)?;
    validate_javascript_path(&manifest.entry, "entry")?;
    validate_javascript_path(&manifest.tests, "tests")?;
    parse_dependencies(manifest.dependencies)?;
    Ok(())
}

fn has_file_ancestor(files: &[SourceFile], path: &str) -> bool {
    path.match_indices('/').any(|(index, _)| {
        files
            .binary_search_by(|file| file.path().cmp(&path[..index]))
            .is_ok()
    })
}

fn required_utf8<'a>(files: &'a [SourceFile], path: &str) -> Result<&'a str, PackageError> {
    let file = files
        .iter()
        .find(|file| file.path() == path)
        .ok_or_else(|| PackageError(format!("missing {path}")))?;
    std::str::from_utf8(file.bytes()).map_err(|_| PackageError(format!("{path} must be UTF-8")))
}

fn required_javascript(files: &[SourceFile], path: &str, field: &str) -> Result<(), PackageError> {
    validate_javascript_path(path, field)?;
    required_utf8(files, path)?;
    Ok(())
}

fn validate_javascript_path(path: &str, field: &str) -> Result<(), PackageError> {
    validate_source_path(path)?;
    if !(path.ends_with(".js") || path.ends_with(".mjs")) {
        return fail(format!("manifest {field} must name a JavaScript file"));
    }
    Ok(())
}

fn validate_identity(id: &WebId, manifest: &Manifest) -> Result<(), PackageError> {
    if manifest.name != id.family().logical_name() {
        return fail("manifest name does not match package identity");
    }
    let version = canonical_manifest_version(&manifest.version)?;
    if &version != id.version() {
        return fail("manifest version does not match package identity canonically");
    }
    Ok(())
}

fn canonical_manifest_version(text: &str) -> Result<Version, PackageError> {
    let version = Version::parse(text)
        .map_err(|_| PackageError("manifest version is not valid SemVer".into()))?;
    validate_stable(&version)?;
    if version.to_string() != text {
        return fail("manifest version does not use canonical spelling");
    }
    Ok(version)
}

fn parse_dependencies(
    declarations: Vec<ManifestDependency>,
) -> Result<Vec<WebDependency>, PackageError> {
    let mut dependencies = Vec::with_capacity(declarations.len());
    for declaration in declarations {
        validate_logical_name(&declaration.name)?;
        dependencies.push(WebDependency(
            parse_authority(&declaration.authority)?,
            declaration.name,
            DependencySelector::parse(&declaration.selector)?,
        ));
    }
    dependencies.sort();
    if dependencies.windows(2).any(|pair| pair[0] == pair[1]) {
        return fail("duplicate dependency declaration");
    }
    Ok(dependencies)
}

fn parse_authority(text: &str) -> Result<AuthorityId, PackageError> {
    let transaction_id =
        TxId::from_str(text).map_err(|_| PackageError("invalid dependency authority".into()))?;
    if transaction_id.to_string() != text {
        return fail("dependency authority must use canonical spelling");
    }
    Ok(AuthorityId::new(transaction_id))
}