use std::collections::HashSet;
use std::path::{Component, Path};
use serde::{Deserialize, Serialize};
use crate::contracts::{ContractError, ContractKind, ContractMetadata, PlatformContract};
pub const HUB_REPOSITORY_INDEX_FILE: &str = "zebflow-repository.json";
pub const MAX_HUB_REPOSITORY_INDEX_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_HUB_REPOSITORY_INDEX_PACKAGES: usize = 4096;
pub const MAX_HUB_REPOSITORY_INDEX_RELEASES: usize = 512;
pub const MAX_HUB_REPOSITORY_INDEX_TEXT_BYTES: usize = 4096;
pub const MAX_HUB_REPOSITORY_INDEX_DESCRIPTION_BYTES: usize = 64 * 1024;
pub fn decode_hub_repository_index(
bytes: &[u8],
) -> Result<crate::contracts::ContractDocument<HubRepositoryIndexSpec>, ContractError> {
if bytes.len() > MAX_HUB_REPOSITORY_INDEX_BYTES {
return Err(ContractError::invalid(format!(
"HubRepositoryIndex exceeds the {MAX_HUB_REPOSITORY_INDEX_BYTES} byte limit"
)));
}
crate::contracts::decode_contract::<HubRepositoryIndexContract>(bytes)
}
pub fn encode_hub_repository_index(
metadata: ContractMetadata,
spec: HubRepositoryIndexSpec,
) -> Result<Vec<u8>, ContractError> {
let bytes = crate::contracts::encode_contract::<HubRepositoryIndexContract>(metadata, spec)?;
if bytes.len() > MAX_HUB_REPOSITORY_INDEX_BYTES {
return Err(ContractError::invalid(format!(
"HubRepositoryIndex exceeds the {MAX_HUB_REPOSITORY_INDEX_BYTES} byte limit"
)));
}
Ok(bytes)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct HubRepositoryIndexSpec {
#[serde(default)]
pub packages: Vec<HubRepositoryIndexPackage>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct HubRepositoryIndexPackage {
pub package_id: String,
pub asset_kind: String,
pub title: String,
#[serde(default)]
pub description: String,
pub latest_version: String,
pub releases: Vec<HubRepositoryIndexRelease>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct HubRepositoryIndexRelease {
pub version: String,
pub path: String,
pub sha256: String,
pub size_bytes: usize,
}
impl HubRepositoryIndexSpec {
pub fn find_release(
&self,
package_id: &str,
version: Option<&str>,
) -> Option<(&HubRepositoryIndexPackage, &HubRepositoryIndexRelease)> {
let package = self
.packages
.iter()
.find(|item| item.package_id == package_id)?;
let wanted = version.unwrap_or(package.latest_version.as_str());
let release = package
.releases
.iter()
.find(|item| item.version == wanted)?;
Some((package, release))
}
}
pub struct HubRepositoryIndexContract;
impl PlatformContract for HubRepositoryIndexContract {
type Spec = HubRepositoryIndexSpec;
const KIND: ContractKind = ContractKind::HubRepositoryIndex;
fn validate(_metadata: &ContractMetadata, spec: &Self::Spec) -> Result<(), ContractError> {
validate_limit(
"spec.packages",
spec.packages.len(),
MAX_HUB_REPOSITORY_INDEX_PACKAGES,
)?;
let mut seen_packages = HashSet::new();
for (index, package) in spec.packages.iter().enumerate() {
let at = format!("spec.packages[{index}]");
validate_package_id(&format!("{at}.package_id"), &package.package_id)?;
if !seen_packages.insert(package.package_id.as_str()) {
return Err(ContractError::invalid(format!(
"spec.packages lists '{}' more than once",
package.package_id
)));
}
validate_asset_kind(&format!("{at}.asset_kind"), &package.asset_kind)?;
validate_single_line(&format!("{at}.title"), &package.title)?;
validate_length(
&format!("{at}.description"),
&package.description,
MAX_HUB_REPOSITORY_INDEX_DESCRIPTION_BYTES,
)?;
if package.releases.is_empty() {
return Err(ContractError::invalid(format!(
"{at}.releases must name at least one release"
)));
}
validate_limit(
&format!("{at}.releases"),
package.releases.len(),
MAX_HUB_REPOSITORY_INDEX_RELEASES,
)?;
let mut seen_versions = HashSet::new();
for (release_index, release) in package.releases.iter().enumerate() {
let release_at = format!("{at}.releases[{release_index}]");
validate_version(&format!("{release_at}.version"), &release.version)?;
if !seen_versions.insert(release.version.as_str()) {
return Err(ContractError::invalid(format!(
"{at}.releases lists version '{}' more than once",
release.version
)));
}
validate_relative_file(&format!("{release_at}.path"), &release.path)?;
validate_sha256(&format!("{release_at}.sha256"), &release.sha256)?;
if release.size_bytes == 0
|| release.size_bytes > super::hub_package::MAX_HUB_PACKAGE_BYTES
{
return Err(ContractError::invalid(format!(
"{release_at}.size_bytes must be between 1 and {}",
super::hub_package::MAX_HUB_PACKAGE_BYTES
)));
}
}
validate_version(&format!("{at}.latest_version"), &package.latest_version)?;
if !seen_versions.contains(package.latest_version.as_str()) {
return Err(ContractError::invalid(format!(
"{at}.latest_version '{}' names no release this index carries",
package.latest_version
)));
}
}
Ok(())
}
}
fn validate_limit(path: &str, actual: usize, limit: usize) -> Result<(), ContractError> {
if actual > limit {
return Err(ContractError::invalid(format!(
"{path} exceeds the limit of {limit}"
)));
}
Ok(())
}
fn validate_length(path: &str, value: &str, limit: usize) -> Result<(), ContractError> {
if value.len() > limit {
return Err(ContractError::invalid(format!(
"{path} exceeds the {limit} byte limit"
)));
}
Ok(())
}
fn validate_single_line(path: &str, value: &str) -> Result<(), ContractError> {
validate_length(path, value, MAX_HUB_REPOSITORY_INDEX_TEXT_BYTES)?;
if value.chars().any(char::is_control) {
return Err(ContractError::invalid(format!(
"{path} must not contain control characters"
)));
}
Ok(())
}
fn validate_package_id(path: &str, value: &str) -> Result<(), ContractError> {
let ok = !value.is_empty()
&& value.len() <= 256
&& value.bytes().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| byte == b'.'
|| byte == b'-'
|| byte == b'_'
})
&& !value.starts_with('.')
&& !value.ends_with('.');
if !ok {
return Err(ContractError::invalid(format!(
"{path} '{value}' must be lowercase letters, digits, '.', '-', or '_'"
)));
}
Ok(())
}
fn validate_asset_kind(path: &str, value: &str) -> Result<(), ContractError> {
let ok = !value.is_empty()
&& value.len() <= 64
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_');
if !ok {
return Err(ContractError::invalid(format!(
"{path} '{value}' must be a lowercase token of letters, digits, or '_'"
)));
}
Ok(())
}
fn validate_version(path: &str, value: &str) -> Result<(), ContractError> {
let ok = !value.is_empty()
&& value.len() <= 256
&& !value.contains('/')
&& !value.contains('\\')
&& !value.chars().any(char::is_control);
if !ok {
return Err(ContractError::invalid(format!(
"{path} '{value}' must be a non-empty single-line token containing no path separator"
)));
}
Ok(())
}
fn validate_sha256(path: &str, value: &str) -> Result<(), ContractError> {
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(ContractError::invalid(format!(
"{path} must be 64 lowercase hexadecimal digits"
)));
}
Ok(())
}
fn validate_relative_file(path: &str, value: &str) -> Result<(), ContractError> {
let value_path = Path::new(value);
if value.is_empty()
|| value.len() > MAX_HUB_REPOSITORY_INDEX_TEXT_BYTES
|| value.contains('\\')
|| value.contains("//")
|| value.chars().any(char::is_control)
|| value_path.is_absolute()
|| value_path.components().any(|component| {
matches!(
component,
Component::ParentDir
| Component::CurDir
| Component::RootDir
| Component::Prefix(_)
)
})
{
return Err(ContractError::invalid(format!(
"{path} must be a normalized relative file path"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const V1_INDEX: &[u8] =
include_bytes!("../../../tests/fixtures/contracts/hub-repository-index/v1-complete.json");
fn mutate(bytes: &[u8], mutation: impl FnOnce(&mut serde_json::Value)) -> Vec<u8> {
let mut value: serde_json::Value = serde_json::from_slice(bytes).expect("json");
mutation(&mut value);
serde_json::to_vec(&value).expect("json bytes")
}
fn release_field<'a>(value: &'a mut serde_json::Value) -> &'a mut serde_json::Value {
&mut value["spec"]["packages"][0]["releases"][0]
}
#[test]
fn the_reference_index_decodes_and_resolves_its_latest() {
let document = decode_hub_repository_index(V1_INDEX).expect("decode");
assert_eq!(document.metadata.name, "zebflow-hub");
let (package, release) = document
.spec
.find_release("zebflow.kids-educational-games", None)
.expect("latest resolves");
assert_eq!(package.asset_kind, "project_bundle");
assert_eq!(release.version, "1.0.0");
assert_eq!(
release.path,
"packages/zebflow.kids-educational-games/1.0.0/package.json"
);
}
#[test]
fn an_unknown_field_is_refused_rather_than_ignored() {
let bytes = mutate(V1_INDEX, |value| {
value["spec"]["packages"][0]["mirror"] = serde_json::json!("https://elsewhere");
});
decode_hub_repository_index(&bytes).expect_err("unknown field");
}
#[test]
fn a_release_path_may_not_leave_the_repository_base() {
for escape in ["../../etc/passwd", "/etc/passwd", "a\\b", "a//b"] {
let bytes = mutate(V1_INDEX, |value| {
release_field(value)["path"] = serde_json::json!(escape);
});
decode_hub_repository_index(&bytes).expect_err(escape);
}
}
#[test]
fn a_release_without_a_usable_digest_is_refused() {
for digest in ["", "abc", &"A".repeat(64)] {
let bytes = mutate(V1_INDEX, |value| {
release_field(value)["sha256"] = serde_json::json!(digest);
});
decode_hub_repository_index(&bytes).expect_err(digest);
}
}
#[test]
fn latest_version_must_name_a_release_the_index_carries() {
let bytes = mutate(V1_INDEX, |value| {
value["spec"]["packages"][0]["latest_version"] = serde_json::json!("9.9.9");
});
let error = decode_hub_repository_index(&bytes).expect_err("dangling latest");
assert!(error.to_string().contains("names no release"), "{error}");
}
#[test]
fn one_id_offered_twice_is_refused() {
let bytes = mutate(V1_INDEX, |value| {
let first = value["spec"]["packages"][0].clone();
value["spec"]["packages"]
.as_array_mut()
.expect("packages")
.push(first);
});
let error = decode_hub_repository_index(&bytes).expect_err("duplicate id");
assert!(error.to_string().contains("more than once"), "{error}");
}
#[test]
fn a_package_with_no_releases_is_refused() {
let bytes = mutate(V1_INDEX, |value| {
value["spec"]["packages"][0]["releases"] = serde_json::json!([]);
});
decode_hub_repository_index(&bytes).expect_err("no releases");
}
#[test]
fn another_kinds_document_is_not_read_as_an_index() {
let bytes = mutate(V1_INDEX, |value| {
value["kind"] = serde_json::json!("HubPackage");
});
decode_hub_repository_index(&bytes).expect_err("wrong kind");
}
#[test]
fn a_future_api_version_is_refused_rather_than_guessed() {
let bytes = mutate(V1_INDEX, |value| {
value["apiVersion"] = serde_json::json!("zebflow.com/v2");
});
decode_hub_repository_index(&bytes).expect_err("wrong api version");
}
#[test]
fn the_reference_index_round_trips() {
let document = decode_hub_repository_index(V1_INDEX).expect("decode");
let bytes = encode_hub_repository_index(document.metadata.clone(), document.spec.clone())
.expect("encode");
let again = decode_hub_repository_index(&bytes).expect("decode again");
assert_eq!(again.spec, document.spec);
}
}