use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::contracts::{
ContractDocument, ContractError, ContractKind, ContractMetadata, PlatformContract,
decode_contract, encode_contract,
};
pub const DEPENDENCY_LOCK_FILE: &str = "zeb.lock";
pub const DEPENDENCY_LOCK_BACKUP_FILE: &str = "zeb-lock";
pub const MAX_DEPENDENCY_LOCK_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_DEPENDENCY_LOCK_LIBRARIES: usize = 1024;
pub const MAX_DEPENDENCY_LOCK_NODE_BUNDLES: usize = 1024;
pub const MAX_DEPENDENCY_LOCK_BUNDLE_DEFINITIONS: usize = 256;
pub struct DependencyLockContract;
impl PlatformContract for DependencyLockContract {
type Spec = DependencyLockSpec;
const KIND: ContractKind = ContractKind::DependencyLock;
fn validate(metadata: &ContractMetadata, spec: &Self::Spec) -> Result<(), ContractError> {
validate_logical_name("metadata.name", &metadata.name, false)?;
if metadata.version.is_some()
|| metadata.digest.is_some()
|| !metadata.annotations.is_empty()
{
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_METADATA",
"DependencyLock metadata contains only name",
));
}
spec.validate()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DependencyLockSpec {
#[serde(default)]
pub rwe: DependencyLockRweSpec,
#[serde(default)]
pub nodes: DependencyLockNodesSpec,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DependencyLockRweSpec {
#[serde(default)]
pub libraries: BTreeMap<String, DependencyLockArtifactSpec>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DependencyLockNodesSpec {
#[serde(default)]
pub bundles: BTreeMap<String, DependencyLockNodeBundleSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DependencyLockArtifactSpec {
pub version: String,
pub source: DependencyLockSource,
pub source_id: String,
pub entry: String,
pub integrity: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DependencyLockNodeBundleSpec {
pub version: String,
pub source: DependencyLockSource,
pub source_id: String,
pub entry: String,
pub integrity: String,
pub definitions: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum DependencyLockSource {
#[serde(rename = "hub.local")]
HubLocal,
#[serde(rename = "hub.public")]
HubPublic,
#[serde(rename = "hub.static")]
HubStatic,
#[serde(rename = "direct.npm")]
DirectNpm,
#[serde(rename = "direct.file")]
DirectFile,
#[serde(rename = "project")]
Project,
}
impl DependencyLockSource {
pub const fn as_str(self) -> &'static str {
match self {
Self::HubLocal => "hub.local",
Self::HubPublic => "hub.public",
Self::HubStatic => "hub.static",
Self::DirectNpm => "direct.npm",
Self::DirectFile => "direct.file",
Self::Project => "project",
}
}
pub const fn is_hub(self) -> bool {
matches!(self, Self::HubLocal | Self::HubPublic | Self::HubStatic)
}
}
impl DependencyLockSpec {
fn validate(&self) -> Result<(), ContractError> {
if self.rwe.libraries.len() > MAX_DEPENDENCY_LOCK_LIBRARIES {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_LIMIT",
format!(
"spec.rwe.libraries exceeds the {MAX_DEPENDENCY_LOCK_LIBRARIES} library limit"
),
));
}
if self.nodes.bundles.len() > MAX_DEPENDENCY_LOCK_NODE_BUNDLES {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_LIMIT",
format!(
"spec.nodes.bundles exceeds the {MAX_DEPENDENCY_LOCK_NODE_BUNDLES} bundle limit"
),
));
}
for (name, entry) in &self.rwe.libraries {
let path = format!("spec.rwe.libraries[{name:?}]");
validate_namespaced_name(&path, name)?;
if entry.source == DependencyLockSource::Project {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_SOURCE",
format!(
"{path}.source must be hub.local, hub.public, hub.static, direct.npm, \
or direct.file in zebflow.com/v1"
),
));
}
validate_artifact_fields(
&path,
&entry.version,
entry.source,
&entry.source_id,
&entry.entry,
&entry.integrity,
)?;
}
let mut providers = BTreeMap::<&str, &str>::new();
for (name, entry) in &self.nodes.bundles {
let path = format!("spec.nodes.bundles[{name:?}]");
validate_bundle_identity(&path, name)?;
if entry.source == DependencyLockSource::DirectNpm {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_SOURCE",
format!(
"{path}.source must be hub.local, hub.public, hub.static, direct.file, \
or project in zebflow.com/v1"
),
));
}
validate_artifact_fields(
&path,
&entry.version,
entry.source,
&entry.source_id,
&entry.entry,
&entry.integrity,
)?;
if entry.definitions.is_empty()
|| entry.definitions.len() > MAX_DEPENDENCY_LOCK_BUNDLE_DEFINITIONS
{
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_DEFINITIONS",
format!(
"{path}.definitions must contain 1 to {MAX_DEPENDENCY_LOCK_BUNDLE_DEFINITIONS} node kinds"
),
));
}
let unique = entry.definitions.iter().collect::<BTreeSet<_>>();
if unique.len() != entry.definitions.len()
|| !entry.definitions.windows(2).all(|pair| pair[0] < pair[1])
{
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_DEFINITIONS",
format!("{path}.definitions must be sorted and unique"),
));
}
for definition in &entry.definitions {
validate_node_kind(&format!("{path}.definitions"), definition)?;
if let Some(previous) = providers.insert(definition, name) {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_PROVIDER_COLLISION",
format!(
"node kind '{definition}' is provided by both '{previous}' and '{name}'"
),
));
}
}
}
Ok(())
}
}
pub fn decode_dependency_lock(
bytes: &[u8],
) -> Result<ContractDocument<DependencyLockSpec>, ContractError> {
if bytes.len() > MAX_DEPENDENCY_LOCK_BYTES {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_LIMIT",
format!("DependencyLock exceeds the {MAX_DEPENDENCY_LOCK_BYTES} byte limit"),
));
}
decode_contract::<DependencyLockContract>(bytes)
}
pub fn encode_dependency_lock(
metadata: ContractMetadata,
spec: DependencyLockSpec,
) -> Result<Vec<u8>, ContractError> {
let bytes = encode_contract::<DependencyLockContract>(metadata, spec)?;
if bytes.len() > MAX_DEPENDENCY_LOCK_BYTES {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_LIMIT",
format!("DependencyLock exceeds the {MAX_DEPENDENCY_LOCK_BYTES} byte limit"),
));
}
Ok(bytes)
}
fn validate_artifact_fields(
path: &str,
version: &str,
source: DependencyLockSource,
source_id: &str,
entry: &str,
integrity: &str,
) -> Result<(), ContractError> {
validate_text(&format!("{path}.version"), version, 128)?;
validate_source_id(&format!("{path}.source_id"), source, source_id)?;
validate_relative_path(&format!("{path}.entry"), entry)?;
validate_sha256(&format!("{path}.integrity"), integrity)
}
fn validate_source_id(
path: &str,
source: DependencyLockSource,
value: &str,
) -> Result<(), ContractError> {
validate_text(path, value, 256)?;
if value.contains(char::is_whitespace) {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_NAME",
format!("{path} must not contain whitespace"),
));
}
if source.is_hub() {
let Some((coordinate, version)) = value.split_once('@') else {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_NAME",
format!("{path} must be a package coordinate 'publisher.package@version'"),
));
};
if version.is_empty()
|| !coordinate.contains('.')
|| validate_bundle_identity(path, coordinate).is_err()
{
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_NAME",
format!("{path} must be a package coordinate 'publisher.package@version'"),
));
}
}
if source == DependencyLockSource::DirectNpm
&& !value.strip_prefix("npm/").is_some_and(|rest| {
rest.split_once('@')
.is_some_and(|(name, ver)| !name.is_empty() && !ver.is_empty())
})
{
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_NAME",
format!("{path} must be a converted npm coordinate 'npm/{{name}}@{{version}}'"),
));
}
Ok(())
}
fn validate_bundle_identity(path: &str, value: &str) -> Result<(), ContractError> {
validate_logical_name(path, value, false)
}
fn validate_namespaced_name(path: &str, value: &str) -> Result<(), ContractError> {
validate_logical_name(path, value, true)?;
if !value.contains('/') {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_NAME",
format!("{path} must include its namespace"),
));
}
Ok(())
}
fn validate_node_kind(path: &str, value: &str) -> Result<(), ContractError> {
validate_logical_name(path, value, false)
}
fn validate_logical_name(path: &str, value: &str, allow_slash: bool) -> Result<(), ContractError> {
let valid = !value.is_empty()
&& value.len() <= 256
&& value.bytes().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| matches!(byte, b'-' | b'_' | b'.')
|| (allow_slash && byte == b'/')
})
&& !value
.split('/')
.any(|segment| segment.is_empty() || matches!(segment, "." | ".."));
if !valid {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_NAME",
format!(
"{path} must be a lowercase logical name using letters, numbers, dot, underscore, hyphen, and optional namespace separators"
),
));
}
Ok(())
}
fn validate_text(path: &str, value: &str, maximum: usize) -> Result<(), ContractError> {
if value.trim().is_empty() || value.len() > maximum || value.chars().any(char::is_control) {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_VALUE",
format!(
"{path} must be non-empty, at most {maximum} bytes, and contain no control characters"
),
));
}
Ok(())
}
fn validate_relative_path(path: &str, value: &str) -> Result<(), ContractError> {
validate_text(path, value, 2048)?;
if value.starts_with('/')
|| value.contains('\\')
|| value
.split('/')
.any(|segment| segment.is_empty() || matches!(segment, "." | ".."))
{
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_ENTRY",
format!("{path} must be a normalized relative path"),
));
}
Ok(())
}
fn validate_sha256(path: &str, value: &str) -> Result<(), ContractError> {
let Some(hex) = value.strip_prefix("sha256:") else {
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_INTEGRITY",
format!("{path} must use sha256:<hex>"),
));
};
if hex.len() != 64
|| !hex
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(ContractError::violation(
"ZF_DEPENDENCY_LOCK_INTEGRITY",
format!("{path} must contain exactly 64 lowercase hexadecimal digits"),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> &'static [u8] {
include_bytes!("../../../tests/fixtures/contracts/dependency-lock/v1-complete.json")
}
#[test]
fn golden_v1_fixture_roundtrips_deterministically() {
let document = decode_dependency_lock(fixture()).expect("valid lock fixture");
let encoded = encode_dependency_lock(document.metadata, document.spec).unwrap();
assert_eq!(encoded, fixture());
}
#[test]
fn rejects_unknown_fields_unsafe_entries_and_bad_digests() {
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
value["spec"]["extra"] = serde_json::json!(true);
assert!(decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_err());
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
value["spec"]["rwe"]["libraries"]["zeb/deckgl"]["entry"] = serde_json::json!("../secret");
assert!(decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_err());
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
value["spec"]["nodes"]["bundles"]["zebflow.sim-des"]["integrity"] =
serde_json::json!("sha256:not-a-digest");
assert!(decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_err());
}
#[test]
fn rejects_future_versions_unsorted_definitions_and_duplicate_providers() {
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
value["apiVersion"] = serde_json::json!("zebflow.com/v2");
assert!(decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_err());
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
value["spec"]["nodes"]["bundles"]["zebflow.sim-des"]["definitions"] =
serde_json::json!(["x.wasm.model.score", "x.wasm.model.load"]);
assert!(decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_err());
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
let mut duplicate = value["spec"]["nodes"]["bundles"]["zebflow.sim-des"].clone();
duplicate["source_id"] = serde_json::json!("another.provider@1.0.0");
value["spec"]["nodes"]["bundles"]["zebflow.duplicate"] = duplicate;
assert!(decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_err());
}
#[test]
fn rejects_every_dead_source_word() {
for dead in ["embedded", "hub", "file", "offline", "direct", "hub.remote"] {
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
value["spec"]["rwe"]["libraries"]["zeb/deckgl"]["source"] = serde_json::json!(dead);
assert!(
decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_err(),
"rwe library source '{dead}' must refuse"
);
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
value["spec"]["nodes"]["bundles"]["zebflow.sim-des"]["source"] =
serde_json::json!(dead);
assert!(
decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_err(),
"node bundle source '{dead}' must refuse"
);
}
}
#[test]
fn rejects_dependency_sources_without_v1_resolvers() {
for accepted in ["hub.local", "hub.public", "hub.static", "direct.file"] {
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
value["spec"]["rwe"]["libraries"]["zeb/deckgl"]["source"] = serde_json::json!(accepted);
assert!(
decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_ok(),
"rwe library source '{accepted}' must be accepted"
);
}
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
value["spec"]["rwe"]["libraries"]["zeb/deckgl"]["source"] = serde_json::json!("direct.npm");
value["spec"]["rwe"]["libraries"]["zeb/deckgl"]["source_id"] =
serde_json::json!("npm/deck.gl@9.1.0");
assert!(decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_ok());
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
value["spec"]["rwe"]["libraries"]["zeb/deckgl"]["source"] = serde_json::json!("project");
assert!(decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_err());
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
value["spec"]["nodes"]["bundles"]["zebflow.sim-des"]["source"] =
serde_json::json!("direct.npm");
value["spec"]["nodes"]["bundles"]["zebflow.sim-des"]["source_id"] =
serde_json::json!("npm/sim-des@1.0.0");
assert!(decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_err());
}
#[test]
fn hub_source_ids_must_be_package_coordinates() {
for bad in ["zebflow/deckgl", "zebflow.deckgl", "deckgl@0.1.1", "@0.1.1"] {
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
value["spec"]["rwe"]["libraries"]["zeb/deckgl"]["source_id"] = serde_json::json!(bad);
assert!(
decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_err(),
"hub source_id '{bad}' must refuse"
);
}
}
#[test]
fn rejects_slash_form_bundle_keys() {
let mut value: serde_json::Value = serde_json::from_slice(fixture()).unwrap();
let entry = value["spec"]["nodes"]["bundles"]["zebflow.sim-des"].clone();
value["spec"]["nodes"]["bundles"] = serde_json::json!({"zebflow/sim-des": entry});
assert!(decode_dependency_lock(&serde_json::to_vec(&value).unwrap()).is_err());
}
#[test]
fn writer_orders_dependency_keys_lexically() {
let mut spec = DependencyLockSpec::default();
for name in ["zeb/z-last", "zeb/a-first"] {
spec.rwe.libraries.insert(
name.to_string(),
DependencyLockArtifactSpec {
version: "1".to_string(),
source: DependencyLockSource::HubLocal,
source_id: format!("zebflow.{}@1", name.trim_start_matches("zeb/")),
entry: "dist/main.mjs".to_string(),
integrity: format!("sha256:{}", "a".repeat(64)),
},
);
}
let text = String::from_utf8(
encode_dependency_lock(ContractMetadata::named("project"), spec).unwrap(),
)
.unwrap();
assert!(text.find("zeb/a-first").unwrap() < text.find("zeb/z-last").unwrap());
}
}