use crate::{Error, Result};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub(crate) const MANIFEST_PATH: &str = "kcode-web.json";
pub(crate) const DOCUMENTATION_PATH: &str = "Documentation.md";
#[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, Eq, PartialEq)]
pub struct Asset {
pub path: String,
pub bytes: Vec<u8>,
}
impl Asset {
pub fn new(path: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
Self {
path: path.into(),
bytes: bytes.into(),
}
}
}
impl std::fmt::Debug for Asset {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Asset")
.field("path", &self.path)
.field("byte_len", &self.bytes.len())
.finish()
}
}
#[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 fn is_asset_path(path: &str) -> bool {
let file_name = path.rsplit('/').next().unwrap_or(path);
let Some((stem, extension)) = file_name.rsplit_once('.') else {
return false;
};
if stem.is_empty() {
return false;
}
!matches!(
extension.to_ascii_lowercase().as_str(),
"cjs"
| "css"
| "csv"
| "htm"
| "html"
| "js"
| "json"
| "jsonld"
| "jsx"
| "map"
| "markdown"
| "md"
| "mjs"
| "scss"
| "text"
| "toml"
| "ts"
| "tsx"
| "txt"
| "webmanifest"
| "xml"
| "yaml"
| "yml"
)
}
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],
assets: &[Asset],
expected_name: &str,
) -> Result<ValidatedTree> {
validate_module_name(expected_name)?;
let mut by_path = BTreeMap::new();
for file in files {
validate_file_path(&file.path)?;
if is_asset_path(&file.path) {
return Err(Error::invalid_source(format!(
"`{}` has an opaque asset extension and must be represented as an Asset",
file.path
)));
}
if by_path.insert(file.path.as_str(), file).is_some() {
return Err(Error::invalid_source(format!(
"duplicate source path `{}`",
file.path
)));
}
}
let mut assets_by_path = BTreeMap::new();
for asset in assets {
validate_file_path(&asset.path)?;
if !is_asset_path(&asset.path) {
return Err(Error::invalid_source(format!(
"`{}` has a text extension and must be represented as a File",
asset.path
)));
}
if by_path.contains_key(asset.path.as_str())
|| assets_by_path.insert(asset.path.as_str(), asset).is_some()
{
return Err(Error::invalid_source(format!(
"duplicate source path `{}`",
asset.path
)));
}
}
let mut all_paths = by_path
.keys()
.copied()
.chain(assets_by_path.keys().copied())
.collect::<Vec<_>>();
all_paths.sort_unstable();
for path in &all_paths {
for (separator, _) in path.match_indices('/') {
let ancestor = &path[..separator];
if all_paths.binary_search(&ancestor).is_ok() {
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)?;
Ok(ValidatedTree { manifest })
}
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(())
}
#[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_a_complete_tree_independent_of_order() {
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.manifest, second.manifest);
}
#[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());
}
#[test]
fn validates_opaque_assets_in_the_same_tree() {
let assets = vec![Asset::new("fonts/display.woff2", vec![0, 159, 146, 150])];
validate_tree(&valid_files(), &assets, "example").unwrap();
let collision = vec![Asset::new("index.js", vec![0, 1])];
assert!(validate_tree(&valid_files(), &collision, "example").is_err());
let mut opaque_text = valid_files();
opaque_text.push(File::new("images/logo.svg", "<svg></svg>"));
assert!(validate_tree(&opaque_text, &[], "example").is_err());
}
#[test]
fn extension_alone_classifies_assets() {
for path in [
"fonts/display.woff2",
"images/logo.SVG",
"downloads/archive.zip",
"data/custom-format",
] {
assert_eq!(is_asset_path(path), path != "data/custom-format", "{path}");
}
for path in [
"index.html",
"styles.css",
"entry.js",
"data.json",
"notes.md",
".gitignore",
] {
assert!(!is_asset_path(path), "{path}");
}
}
#[test]
fn accepts_large_and_numerous_assets_without_policy_limits() {
let many = (0..1_100)
.map(|index| Asset::new(format!("assets/{index}.bin"), vec![index as u8]))
.collect::<Vec<_>>();
validate_tree(&valid_files(), &many, "example").unwrap();
let large = vec![Asset::new(
"assets/large.bin",
vec![0; 16 * 1024 * 1024 + 1],
)];
validate_tree(&valid_files(), &large, "example").unwrap();
}
}