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 {
pub path: String,
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());
}
}