use kcode_k1_transaction_id::TxId;
use semver::Version;
use std::fmt::{Display, Formatter};
use std::ops::Range;
mod source;
pub use source::{SourceFile, SourcePackage, WebDependency};
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct AuthorityId(TxId);
impl AuthorityId {
pub const fn new(transaction_id: TxId) -> Self {
Self(transaction_id)
}
pub const fn transaction_id(&self) -> &TxId {
&self.0
}
}
impl Display for AuthorityId {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self.0, formatter)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct WebFamily(AuthorityId, String);
impl WebFamily {
pub fn new(
authority: AuthorityId,
logical_name: impl Into<String>,
) -> Result<Self, PackageError> {
let logical_name = logical_name.into();
validate_logical_name(&logical_name)?;
Ok(Self(authority, logical_name))
}
pub const fn authority(&self) -> AuthorityId {
self.0
}
pub fn logical_name(&self) -> &str {
&self.1
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct WebId(WebFamily, Version);
impl WebId {
pub fn new(family: WebFamily, version: Version) -> Result<Self, PackageError> {
validate_stable(&version)?;
Ok(Self(family, version))
}
pub fn family(&self) -> &WebFamily {
&self.0
}
pub fn version(&self) -> &Version {
&self.1
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
enum SelectorKind {
Any,
Major(u64),
MajorMinor(u64, u64),
Exact(u64, u64, u64),
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DependencySelector(SelectorKind);
impl DependencySelector {
pub fn parse(text: &str) -> Result<Self, PackageError> {
if text == "*" {
return Ok(Self(SelectorKind::Any));
}
let numbers = text
.split('.')
.map(parse_selector_number)
.collect::<Result<Vec<_>, _>>()?;
let kind = match numbers.as_slice() {
[major] => SelectorKind::Major(*major),
[major, minor] => SelectorKind::MajorMinor(*major, *minor),
[major, minor, patch] => SelectorKind::Exact(*major, *minor, *patch),
_ => return fail("dependency selector must contain one to three numbers or *"),
};
Ok(Self(kind))
}
pub fn matches(&self, version: &Version) -> bool {
if !version.pre.is_empty() || !version.build.is_empty() {
return false;
}
match self.0 {
SelectorKind::Any => true,
SelectorKind::Major(major) => version.major == major,
SelectorKind::MajorMinor(major, minor) => {
version.major == major && version.minor == minor
}
SelectorKind::Exact(major, minor, patch) => {
version.major == major && version.minor == minor && version.patch == patch
}
}
}
}
impl Display for DependencySelector {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self.0 {
SelectorKind::Any => formatter.write_str("*"),
SelectorKind::Major(major) => write!(formatter, "{major}"),
SelectorKind::MajorMinor(major, minor) => write!(formatter, "{major}.{minor}"),
SelectorKind::Exact(major, minor, patch) => {
write!(formatter, "{major}.{minor}.{patch}")
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PackageError(String);
impl PackageError {
pub fn message(&self) -> &str {
&self.0
}
}
impl Display for PackageError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for PackageError {}
pub fn validate_source_path(path: &str) -> Result<(), PackageError> {
let valid = !path.is_empty()
&& path.len() <= 4096
&& !path.starts_with('/')
&& !path.contains([':', '\\', '\0'])
&& path
.split('/')
.all(|part| !part.is_empty() && !matches!(part, "." | "..") && part.len() <= 255);
if !valid {
return fail("invalid source path");
}
Ok(())
}
pub fn project_manifest_version(
files: &[SourceFile],
version: &Version,
) -> Result<Vec<SourceFile>, PackageError> {
validate_stable(version)?;
let mut output = files.to_vec();
output
.iter()
.try_for_each(|file| validate_source_path(file.path()))?;
output.sort();
if output
.windows(2)
.any(|pair| pair[0].path() == pair[1].path())
{
return fail("duplicate source path");
}
if output
.iter()
.any(|file| has_file_ancestor(&output, file.path()))
{
return fail("source path collides with a file ancestor");
}
let manifest_index = output
.binary_search_by(|file| file.path().cmp("k1-web.json"))
.map_err(|_| PackageError("source must contain exactly one k1-web.json".into()))?;
let source = std::str::from_utf8(output[manifest_index].bytes())
.map_err(|_| PackageError("k1-web.json must be UTF-8".into()))?;
source::validate_manifest_for_projection(source)?;
let range = top_level_string_value(source, "version")?;
let replacement = serde_json::to_string(&version.to_string())
.map_err(|cause| PackageError(format!("encode manifest version: {cause}")))?;
let mut projected = String::with_capacity(source.len() - range.len() + replacement.len());
projected.push_str(&source[..range.start]);
projected.push_str(&replacement);
projected.push_str(&source[range.end..]);
output[manifest_index] = SourceFile::new("k1-web.json", projected.as_bytes().to_vec());
Ok(output)
}
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 top_level_string_value(source: &str, wanted: &str) -> Result<Range<usize>, PackageError> {
let bytes = source.as_bytes();
let mut cursor = skip_whitespace(bytes, 0);
if bytes.get(cursor) != Some(&b'{') {
return fail("k1-web.json must contain a JSON object");
}
cursor += 1;
let mut found = None;
loop {
cursor = skip_whitespace(bytes, cursor);
if bytes.get(cursor) == Some(&b'}') {
cursor += 1;
break;
}
let key_start = cursor;
let key_end = json_string_end(bytes, key_start)?;
let key: String = serde_json::from_str(&source[key_start..key_end])
.map_err(|cause| PackageError(format!("invalid k1-web.json key: {cause}")))?;
cursor = skip_whitespace(bytes, key_end);
if bytes.get(cursor) != Some(&b':') {
return fail("invalid k1-web.json object separator");
}
cursor = skip_whitespace(bytes, cursor + 1);
let value_start = cursor;
let mut values = serde_json::Deserializer::from_str(&source[value_start..])
.into_iter::<serde_json::Value>();
let value = values
.next()
.ok_or_else(|| PackageError("missing k1-web.json value".into()))?
.map_err(|cause| PackageError(format!("invalid k1-web.json: {cause}")))?;
let value_end = value_start
.checked_add(values.byte_offset())
.ok_or_else(|| PackageError("k1-web.json value is too large".into()))?;
if key == wanted {
if !value.is_string() {
return fail("manifest version must be a string");
}
if found.replace(value_start..value_end).is_some() {
return fail("manifest contains duplicate version fields");
}
}
cursor = skip_whitespace(bytes, value_end);
match bytes.get(cursor) {
Some(b',') => cursor += 1,
Some(b'}') => {
cursor += 1;
break;
}
_ => return fail("invalid k1-web.json object terminator"),
}
}
if skip_whitespace(bytes, cursor) != bytes.len() {
return fail("k1-web.json contains trailing data");
}
found.ok_or_else(|| PackageError("manifest is missing version".into()))
}
fn json_string_end(bytes: &[u8], start: usize) -> Result<usize, PackageError> {
if bytes.get(start) != Some(&b'"') {
return fail("k1-web.json object key must be a string");
}
let mut cursor = start + 1;
while let Some(byte) = bytes.get(cursor) {
match byte {
b'"' => return Ok(cursor + 1),
b'\\' => {
cursor = cursor
.checked_add(2)
.ok_or_else(|| PackageError("k1-web.json key is too large".into()))?;
}
0x00..=0x1f => return fail("invalid control byte in k1-web.json key"),
_ => cursor += 1,
}
}
fail("unterminated k1-web.json object key")
}
fn skip_whitespace(bytes: &[u8], mut cursor: usize) -> usize {
while bytes
.get(cursor)
.is_some_and(|byte| matches!(byte, b' ' | b'\n' | b'\r' | b'\t'))
{
cursor += 1;
}
cursor
}
pub(crate) fn fail<T>(message: impl Into<String>) -> Result<T, PackageError> {
Err(PackageError(message.into()))
}
pub(crate) fn validate_logical_name(name: &str) -> Result<(), PackageError> {
let valid = !name.is_empty()
&& name.len() <= 250
&& name.split('-').all(|part| {
!part.is_empty()
&& part
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
});
if !valid {
return fail("logical name must be 1-250 characters of lowercase kebab case");
}
Ok(())
}
pub(crate) fn validate_stable(version: &Version) -> Result<(), PackageError> {
if !version.pre.is_empty() || !version.build.is_empty() {
return fail("version must be stable SemVer without prerelease or build metadata");
}
Ok(())
}
fn parse_selector_number(text: &str) -> Result<u64, PackageError> {
let canonical = !text.is_empty()
&& !(text.len() > 1 && text.starts_with('0'))
&& text.bytes().all(|byte| byte.is_ascii_digit());
if !canonical {
return fail("dependency selector numbers must use canonical decimal spelling");
}
text.parse()
.map_err(|_| PackageError("dependency selector number is too large".into()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn projects_only_the_manifest_version_token() {
let manifest = "{\n \"name\":\"alpha\", \"version\" : \"1.0.0\",\n \"entry\":\"index.js\",\"tests\":\"index.js\",\"dependencies\":[]\n}\n";
let binary = vec![0, 159, 255];
let files = vec![
SourceFile::new("z.bin", binary.clone()),
SourceFile::new("k1-web.json", manifest.as_bytes().to_vec()),
SourceFile::new("Documentation.md", b"docs".to_vec()),
];
let projected = project_manifest_version(&files, &Version::new(2, 3, 4)).unwrap();
let rendered = std::str::from_utf8(
projected
.iter()
.find(|file| file.path() == "k1-web.json")
.unwrap()
.bytes(),
)
.unwrap();
assert_eq!(rendered, manifest.replace("\"1.0.0\"", "\"2.3.4\""));
assert_eq!(
projected
.iter()
.find(|file| file.path() == "z.bin")
.unwrap()
.bytes(),
binary
);
assert!(projected.windows(2).all(|pair| pair[0] < pair[1]));
}
#[test]
fn projection_handles_escaped_keys_and_rejects_bad_versions() {
let escaped = r#"{"name":"alpha","ver\u0073ion":"1.0.0","entry":"index.js","tests":"index.js","dependencies":[]}"#;
let files = vec![SourceFile::new("k1-web.json", escaped.as_bytes().to_vec())];
let projected = project_manifest_version(&files, &Version::new(9, 8, 7)).unwrap();
assert_eq!(
std::str::from_utf8(projected[0].bytes()).unwrap(),
escaped.replace("\"1.0.0\"", "\"9.8.7\"")
);
assert!(
project_manifest_version(&files, &Version::parse("1.0.0-preview").unwrap()).is_err()
);
let missing = vec![SourceFile::new("index.js", Vec::new())];
assert!(project_manifest_version(&missing, &Version::new(1, 0, 0)).is_err());
let invalid = vec![SourceFile::new("k1-web.json", br#"{"version":1}"#.to_vec())];
assert!(project_manifest_version(&invalid, &Version::new(1, 0, 0)).is_err());
let invalid_structure = vec![SourceFile::new(
"k1-web.json",
br#"{"name":"alpha","version":"1.0.0","entry":"index.js","tests":"index.js","dependencies":[],"extra":true}"#.to_vec(),
)];
assert!(project_manifest_version(&invalid_structure, &Version::new(1, 0, 0)).is_err());
}
#[test]
fn projection_rejects_invalid_tree_paths() {
let manifest =
br#"{"name":"alpha","version":"1.0.0","entry":"index.js","tests":"index.js","dependencies":[]}"#;
let base = SourceFile::new("k1-web.json", manifest.to_vec());
assert!(
project_manifest_version(
&[base.clone(), SourceFile::new("../bad", Vec::new())],
&Version::new(1, 0, 0),
)
.is_err()
);
assert!(
project_manifest_version(
&[
base.clone(),
SourceFile::new("a", Vec::new()),
SourceFile::new("a", Vec::new()),
],
&Version::new(1, 0, 0),
)
.is_err()
);
assert!(
project_manifest_version(
&[
base,
SourceFile::new("assets", Vec::new()),
SourceFile::new("assets/icon.png", Vec::new()),
],
&Version::new(1, 0, 0),
)
.is_err()
);
}
#[test]
fn public_path_validation_matches_package_paths() {
for path in ["a", "nested/file.unknown", "assets/icon.png"] {
validate_source_path(path).unwrap();
}
for path in ["", "/a", "a//b", "a/./b", "a/../b", "a\\b", "a:b"] {
assert!(validate_source_path(path).is_err(), "{path}");
}
}
}