use crate::{
CONSOLE_MODULE_PROTOCOL, CONSOLE_MODULE_PROTOCOL_MAJOR, CONSOLE_UI_ESM_FORMAT,
ConsoleModuleManifest, ConsolePermissionRequest, ConsoleSurfacePresentation,
ConsoleUiArtifactStyleAsset, MODULE_MANIFEST_PROTOCOL, ModuleManifest, lint_module_manifest,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use std::collections::BTreeSet;
use std::fmt::Write as _;
pub const MODULE_RELEASE_PROTOCOL: &str = "lenso.module-release.v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ArtifactReference {
pub locator: String,
pub digest: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AttestationReference {
pub locator: String,
pub digest: String,
pub issuer: String,
pub signer: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct LinkedModuleDelivery {
pub package: String,
pub crate_version: String,
pub archive_checksum: String,
#[serde(default)]
pub default_features: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
pub binding: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attestations: Vec<AttestationReference>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub migrations: Vec<ArtifactReference>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ServiceResponsibilityProfile {
Provider,
Autonomous,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ServiceModuleDelivery {
pub service_id: String,
pub service_release_version: String,
pub service_release_digest: String,
pub export: String,
pub responsibility_profile: ServiceResponsibilityProfile,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub contract_digests: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ModuleDelivery {
Linked(LinkedModuleDelivery),
Service(ServiceModuleDelivery),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ConsoleUiArtifactEntry {
pub name: String,
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ConsoleUiArtifactFormat {
#[serde(rename = "console_ui_esm")]
Esm,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct ConsoleUiArtifact {
pub artifact: ArtifactReference,
pub format: ConsoleUiArtifactFormat,
pub protocol_major: u32,
pub entry: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<ConsoleUiArtifactEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub style_assets: Vec<ConsoleUiArtifactStyleAsset>,
pub manifest: ConsoleModuleManifest,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requested_permissions: Vec<ConsolePermissionRequest>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub provenance: Vec<ArtifactReference>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ModuleCompatibilityDeclaration {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lenso_requirement: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_api_requirement: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub console_ui_requirement: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rust_requirement: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub targets: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub transports: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub protocol_digests: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ModuleRelease {
pub protocol: String,
pub module_id: String,
pub version: String,
pub manifest: ModuleManifest,
pub manifest_digest: String,
pub delivery: ModuleDelivery,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub console_ui_artifact: Option<ConsoleUiArtifact>,
#[serde(default)]
pub compatibility: ModuleCompatibilityDeclaration,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub provenance: Vec<ArtifactReference>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModuleContractIssue {
pub path: String,
pub message: String,
}
impl ModuleRelease {
pub fn new(
module_id: impl Into<String>,
version: impl AsRef<str>,
manifest: ModuleManifest,
delivery: ModuleDelivery,
) -> Result<Self, Vec<ModuleContractIssue>> {
let version = semver::Version::parse(version.as_ref())
.map_err(|error| {
vec![issue(
"$.version",
format!("Module version must be normalized SemVer: {error}"),
)]
})?
.to_string();
let manifest_digest = digest_json(&manifest).map_err(|error| {
vec![issue(
"$.manifest",
format!("Manifest cannot be canonicalized: {error}"),
)]
})?;
let release = Self {
protocol: MODULE_RELEASE_PROTOCOL.to_owned(),
module_id: module_id.into(),
version,
manifest: manifest.clone(),
manifest_digest,
delivery,
console_ui_artifact: None,
compatibility: ModuleCompatibilityDeclaration::default(),
provenance: Vec::new(),
};
let issues = release.validate();
if issues.is_empty() {
Ok(release)
} else {
Err(issues)
}
}
#[must_use]
pub fn validate(&self) -> Vec<ModuleContractIssue> {
let mut issues = Vec::new();
if self.protocol != MODULE_RELEASE_PROTOCOL {
issues.push(issue(
"$.protocol",
format!("protocol must be {MODULE_RELEASE_PROTOCOL}"),
));
}
if !valid_module_id(&self.module_id) {
issues.push(issue("$.module_id", "ModuleId must use namespace/name"));
}
if self.manifest.protocol != MODULE_MANIFEST_PROTOCOL {
issues.push(issue(
"$.manifest.protocol",
format!("protocol must be {MODULE_MANIFEST_PROTOCOL}"),
));
}
if self.manifest.module_id != self.module_id {
issues.push(issue(
"$.manifest.module_id",
"Manifest and Release ModuleIds must match",
));
}
if !matches!(
semver::Version::parse(&self.version),
Ok(version) if version.to_string() == self.version
) {
issues.push(issue(
"$.version",
"Module version must be normalized SemVer",
));
}
match digest_json(&self.manifest) {
Ok(digest) if digest == self.manifest_digest => {}
Ok(_) => issues.push(issue(
"$.manifest_digest",
"Manifest digest does not match canonical Manifest bytes",
)),
Err(error) => issues.push(issue(
"$.manifest",
format!("Manifest cannot be canonicalized: {error}"),
)),
}
for lint in lint_module_manifest(&self.manifest) {
if matches!(lint.severity, crate::ModuleManifestLintSeverity::Error) {
issues.push(issue(format!("$.manifest.{}", lint.subject), lint.message));
}
}
validate_delivery(&self.delivery, &mut issues);
validate_artifacts("$.provenance", &self.provenance, &mut issues);
if let Some(console) = &self.console_ui_artifact {
validate_artifact_reference(
"$.console_ui_artifact.artifact",
&console.artifact,
&mut issues,
);
if !matches!(console.format, ConsoleUiArtifactFormat::Esm) {
issues.push(issue(
"$.console_ui_artifact.format",
format!("format must be {CONSOLE_UI_ESM_FORMAT}"),
));
}
if console.protocol_major != CONSOLE_MODULE_PROTOCOL_MAJOR {
issues.push(issue(
"$.console_ui_artifact.protocol_major",
format!(
"protocol_major must be {CONSOLE_MODULE_PROTOCOL_MAJOR} for {CONSOLE_MODULE_PROTOCOL}"
),
));
}
validate_console_module_manifest(
"$.console_ui_artifact.manifest",
&console.manifest,
&mut issues,
);
if console.manifest.module_id != self.module_id {
issues.push(issue(
"$.console_ui_artifact.manifest.module_id",
"Console Module manifest and Module Release ModuleIds must match",
));
}
if let Some(requirement) = &self.compatibility.host_api_requirement
&& &console.manifest.host_api != requirement
{
issues.push(issue(
"$.console_ui_artifact.manifest.host_api",
"Console Module host_api must match the Release host_api_requirement",
));
}
if let Some(requirement) = &self.compatibility.console_ui_requirement
&& &console.manifest.console_ui != requirement
{
issues.push(issue(
"$.console_ui_artifact.manifest.console_ui",
"Console Module console_ui must match the Release console_ui_requirement",
));
}
if console.entry.trim().is_empty()
|| !console
.entries
.iter()
.any(|entry| entry.path == console.entry)
{
issues.push(issue(
"$.console_ui_artifact.entry",
"Console UI entry must be declared by the artifact entries",
));
}
validate_console_ui_entries(&console.entries, &mut issues);
validate_console_ui_style_assets(&console.style_assets, &console.entries, &mut issues);
validate_console_permission_requests(&console.requested_permissions, &mut issues);
validate_artifacts(
"$.console_ui_artifact.provenance",
&console.provenance,
&mut issues,
);
validate_console_surface_entries(&self.manifest, console, &mut issues);
if self.manifest.console.iter().any(|surface| {
matches!(surface.presentation, ConsoleSurfacePresentation::Esm { .. })
}) {
if self.compatibility.host_api_requirement.is_none() {
issues.push(issue(
"$.compatibility.host_api_requirement",
"ESM Console Releases require an independent host API SemVer range",
));
}
if self.compatibility.console_ui_requirement.is_none() {
issues.push(issue(
"$.compatibility.console_ui_requirement",
"ESM Console Releases require an independent Console UI SemVer range",
));
}
}
} else if self
.manifest
.console
.iter()
.any(|surface| matches!(surface.presentation, ConsoleSurfacePresentation::Esm { .. }))
{
issues.push(issue(
"$.console_ui_artifact",
"ESM Console surfaces require a Console UI Artifact in the same Module Release",
));
}
for (path, requirement) in [
(
"$.compatibility.lenso_requirement",
self.compatibility.lenso_requirement.as_deref(),
),
(
"$.compatibility.host_api_requirement",
self.compatibility.host_api_requirement.as_deref(),
),
(
"$.compatibility.console_ui_requirement",
self.compatibility.console_ui_requirement.as_deref(),
),
(
"$.compatibility.rust_requirement",
self.compatibility.rust_requirement.as_deref(),
),
] {
if let Some(requirement) = requirement {
validate_version_requirement(path, requirement, &mut issues);
}
}
for (path, values) in [
("$.compatibility.targets", &self.compatibility.targets),
("$.compatibility.transports", &self.compatibility.transports),
(
"$.compatibility.protocol_digests",
&self.compatibility.protocol_digests,
),
] {
validate_sorted_unique(path, values, &mut issues);
}
for digest in &self.compatibility.protocol_digests {
validate_digest("$.compatibility.protocol_digests", digest, &mut issues);
}
issues
}
}
pub fn canonical_json<T: Serialize>(value: &T) -> serde_json::Result<Vec<u8>> {
serde_json_canonicalizer::to_vec(value)
}
pub fn digest_json<T: Serialize>(value: &T) -> serde_json::Result<String> {
let bytes = canonical_json(value)?;
let mut rendered = String::with_capacity("sha256:".len() + 64);
rendered.push_str("sha256:");
for byte in Sha256::digest(bytes) {
write!(&mut rendered, "{byte:02x}").expect("writing to a String cannot fail");
}
Ok(rendered)
}
fn validate_delivery(delivery: &ModuleDelivery, issues: &mut Vec<ModuleContractIssue>) {
match delivery {
ModuleDelivery::Linked(linked) => {
if linked.package.trim().is_empty() || linked.binding.trim().is_empty() {
issues.push(issue(
"$.delivery",
"Linked delivery requires package and binding",
));
}
validate_semver("$.delivery.crate_version", &linked.crate_version, issues);
validate_digest(
"$.delivery.archive_checksum",
&linked.archive_checksum,
issues,
);
validate_sorted_unique("$.delivery.features", &linked.features, issues);
let mut attestation_keys = BTreeSet::new();
for attestation in &linked.attestations {
if attestation.locator.trim().is_empty()
|| attestation.issuer.trim().is_empty()
|| attestation.signer.trim().is_empty()
|| !attestation_keys.insert((&attestation.locator, &attestation.digest))
{
issues.push(issue(
"$.delivery.attestations",
"Attestations require unique locator/digest pairs and complete identity",
));
}
validate_digest(
"$.delivery.attestations.digest",
&attestation.digest,
issues,
);
}
validate_artifacts("$.delivery.migrations", &linked.migrations, issues);
}
ModuleDelivery::Service(service) => {
if !valid_module_id(&service.service_id) || service.export.trim().is_empty() {
issues.push(issue(
"$.delivery",
"Service delivery requires a fully qualified ServiceId and export",
));
}
validate_semver(
"$.delivery.service_release_version",
&service.service_release_version,
issues,
);
validate_digest(
"$.delivery.service_release_digest",
&service.service_release_digest,
issues,
);
if service.contract_digests.is_empty() {
issues.push(issue(
"$.delivery.contract_digests",
"Service delivery requires governing contract digests",
));
}
for digest in &service.contract_digests {
validate_digest("$.delivery.contract_digests", digest, issues);
}
validate_sorted_unique(
"$.delivery.contract_digests",
&service.contract_digests,
issues,
);
}
}
}
fn validate_semver(path: &str, value: &str, issues: &mut Vec<ModuleContractIssue>) {
if !matches!(
semver::Version::parse(value),
Ok(version) if version.to_string() == value
) {
issues.push(issue(path, "value must be normalized SemVer"));
}
}
fn validate_version_requirement(path: &str, value: &str, issues: &mut Vec<ModuleContractIssue>) {
if !matches!(
semver::VersionReq::parse(value),
Ok(requirement) if requirement.to_string() == value
) {
issues.push(issue(path, "value must be a normalized SemVer requirement"));
}
}
fn validate_digest(path: &str, value: &str, issues: &mut Vec<ModuleContractIssue>) {
if !value.strip_prefix("sha256:").is_some_and(|digest| {
digest.len() == 64
&& digest
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}) {
issues.push(issue(path, "value must be sha256:<64 lowercase hex>"));
}
}
fn validate_sorted_unique(path: &str, values: &[String], issues: &mut Vec<ModuleContractIssue>) {
if values.iter().any(|value| value.trim().is_empty())
|| values.windows(2).any(|pair| pair[0] >= pair[1])
{
issues.push(issue(path, "values must be non-empty, sorted, and unique"));
}
}
fn validate_unique<T, F>(path: &str, values: &[T], key: F, issues: &mut Vec<ModuleContractIssue>)
where
F: Fn(&T) -> &String,
{
let mut seen = BTreeSet::new();
if values.iter().any(|value| !seen.insert(key(value))) {
issues.push(issue(path, "values must be unique"));
}
}
fn validate_artifacts(
path: &str,
values: &[ArtifactReference],
issues: &mut Vec<ModuleContractIssue>,
) {
validate_unique(path, values, |item| &item.digest, issues);
for artifact in values {
if artifact.locator.trim().is_empty() {
issues.push(issue(path, "artifact locator must be non-empty"));
}
validate_digest(path, &artifact.digest, issues);
}
}
fn validate_artifact_reference(
path: &str,
artifact: &ArtifactReference,
issues: &mut Vec<ModuleContractIssue>,
) {
if artifact.locator.trim().is_empty() {
issues.push(issue(path, "artifact locator must be non-empty"));
}
validate_digest(path, &artifact.digest, issues);
}
fn validate_console_ui_entries(
entries: &[ConsoleUiArtifactEntry],
issues: &mut Vec<ModuleContractIssue>,
) {
let mut paths = BTreeSet::new();
validate_unique(
"$.console_ui_artifact.entries",
entries,
|entry| &entry.name,
issues,
);
for entry in entries {
if entry.name.trim().is_empty()
|| entry.path.trim().is_empty()
|| entry.path.starts_with('/')
|| !paths.insert(&entry.path)
|| entry
.path
.split('/')
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
{
issues.push(issue(
"$.console_ui_artifact.entries",
"Console UI entries require a stable name and a relative artifact path without traversal",
));
}
}
}
fn validate_console_ui_style_assets(
assets: &[ConsoleUiArtifactStyleAsset],
entries: &[ConsoleUiArtifactEntry],
issues: &mut Vec<ModuleContractIssue>,
) {
let mut paths = BTreeSet::new();
for asset in assets {
if asset.path.trim().is_empty()
|| asset.path.starts_with('/')
|| asset
.path
.split('/')
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
|| !paths.insert(&asset.path)
{
issues.push(issue(
"$.console_ui_artifact.style_assets",
"Console UI style assets require unique relative paths without traversal",
));
}
if !entries.iter().any(|entry| entry.path == asset.path) {
issues.push(issue(
"$.console_ui_artifact.style_assets",
"Console UI style assets must be declared by artifact entries",
));
}
if asset
.media
.as_deref()
.is_some_and(|media| media.trim().is_empty())
{
issues.push(issue(
"$.console_ui_artifact.style_assets.media",
"Console UI style asset media must be non-empty when present",
));
}
}
}
fn validate_console_module_manifest(
path: &str,
manifest: &ConsoleModuleManifest,
issues: &mut Vec<ModuleContractIssue>,
) {
if manifest.protocol != CONSOLE_MODULE_PROTOCOL {
issues.push(issue(
format!("{path}.protocol"),
format!("protocol must be {CONSOLE_MODULE_PROTOCOL}"),
));
}
if !valid_module_id(&manifest.module_id) {
issues.push(issue(
format!("{path}.module_id"),
"Console Module manifest requires a fully qualified ModuleId",
));
}
for (field_path, requirement) in [
(format!("{path}.host_api"), manifest.host_api.as_str()),
(format!("{path}.console_ui"), manifest.console_ui.as_str()),
] {
validate_version_requirement(&field_path, requirement, issues);
}
if manifest.surfaces.is_empty() {
issues.push(issue(
format!("{path}.surfaces"),
"Console Module manifest must declare at least one ESM surface",
));
}
let mut ids = BTreeSet::new();
let mut paths = BTreeSet::new();
for (index, surface) in manifest.surfaces.iter().enumerate() {
let subject = format!("{path}.surfaces[{index}]");
if surface.id.trim().is_empty() || !ids.insert(&surface.id) {
issues.push(issue(
format!("{subject}.id"),
"Console Module surface ids must be non-empty and unique",
));
}
if surface.label.trim().is_empty() {
issues.push(issue(
format!("{subject}.label"),
"Console Module surface labels must be non-empty",
));
}
if !valid_console_surface_path(&surface.path) || !paths.insert(&surface.path) {
issues.push(issue(
format!("{subject}.path"),
"Console Module surface paths must be unique absolute static paths",
));
}
if surface
.required_capabilities
.windows(2)
.any(|pair| pair[0] >= pair[1])
|| surface
.required_capabilities
.iter()
.any(|capability| capability.trim().is_empty())
{
issues.push(issue(
format!("{subject}.required_capabilities"),
"Console Module surface capabilities must be non-empty, sorted, and unique",
));
}
}
}
fn valid_console_surface_path(value: &str) -> bool {
value.starts_with('/')
&& !value.contains('\\')
&& !value.contains('?')
&& !value.contains('#')
&& !value[1..]
.split('/')
.any(|segment| segment.is_empty() || segment == "..")
}
fn validate_console_permission_requests(
permissions: &[ConsolePermissionRequest],
issues: &mut Vec<ModuleContractIssue>,
) {
validate_unique(
"$.console_ui_artifact.requested_permissions",
permissions,
|permission| &permission.permission_id,
issues,
);
for permission in permissions {
if permission.permission_id.trim().is_empty() {
issues.push(issue(
"$.console_ui_artifact.requested_permissions",
"Console permission identifiers must be non-empty",
));
}
for (path, values) in [
("operations", &permission.operations),
("resources", &permission.resources),
("outbound_destinations", &permission.outbound_destinations),
("secret_references", &permission.secret_references),
] {
validate_sorted_unique(
&format!("$.console_ui_artifact.requested_permissions.{path}"),
values,
issues,
);
}
}
}
fn validate_console_surface_entries(
manifest: &ModuleManifest,
artifact: &ConsoleUiArtifact,
issues: &mut Vec<ModuleContractIssue>,
) {
let generated = manifest.console_module_manifest(
artifact.manifest.host_api.clone(),
artifact.manifest.console_ui.clone(),
);
if generated != artifact.manifest {
issues.push(issue(
"$.console_ui_artifact.manifest.surfaces",
"Console Module manifest must be generated from the Release Module declaration",
));
}
let entries = artifact
.entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<BTreeSet<_>>();
for surface in &manifest.console {
if let ConsoleSurfacePresentation::Esm { entry } = &surface.presentation {
if !entries.contains(entry.as_str()) {
issues.push(issue(
"$.manifest.console.presentation.entry",
format!(
"Console surface entry `{entry}` is missing from the release UI artifact"
),
));
}
}
}
}
fn valid_module_id(value: &str) -> bool {
let Some((namespace, name)) = value.split_once('/') else {
return false;
};
!namespace.is_empty()
&& !name.is_empty()
&& !name.contains('/')
&& [namespace, name].into_iter().all(|segment| {
segment
.bytes()
.next()
.is_some_and(|byte| byte.is_ascii_lowercase())
&& segment.bytes().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| byte == b'-'
|| byte == b'_'
})
})
}
fn issue(path: impl Into<String>, message: impl Into<String>) -> ModuleContractIssue {
ModuleContractIssue {
path: path.into(),
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn digest(value: &str) -> String {
format!("sha256:{}", value.repeat(64))
}
fn manifest() -> ModuleManifest {
ModuleManifest::builder("acme/support-ticket").build()
}
fn linked_delivery() -> ModuleDelivery {
ModuleDelivery::Linked(LinkedModuleDelivery {
package: "acme-support-ticket".to_owned(),
crate_version: "1.2.3".to_owned(),
archive_checksum: digest("a"),
default_features: false,
features: Vec::new(),
binding: "support_ticket".to_owned(),
attestations: Vec::new(),
migrations: Vec::new(),
})
}
#[test]
fn canonical_digest_is_order_independent() {
let left = serde_json::json!({"b": 2, "a": 1});
let right = serde_json::json!({"a": 1, "b": 2});
assert_eq!(digest_json(&left).unwrap(), digest_json(&right).unwrap());
assert_eq!(canonical_json(&left).unwrap(), br#"{"a":1,"b":2}"#);
}
#[test]
fn linked_release_binds_exact_manifest() {
let manifest = manifest();
let release = ModuleRelease::new(
"acme/support-ticket",
"1.2.3",
manifest.clone(),
ModuleDelivery::Linked(LinkedModuleDelivery {
package: "acme-support-ticket".to_owned(),
crate_version: "4.2.0".to_owned(),
archive_checksum: digest("a"),
default_features: false,
features: vec!["postgres".to_owned()],
binding: "support_ticket".to_owned(),
attestations: Vec::new(),
migrations: Vec::new(),
}),
)
.unwrap();
assert_eq!(release.manifest_digest, digest_json(&manifest).unwrap());
assert!(release.validate().is_empty());
}
fn console_manifest() -> ModuleManifest {
ModuleManifest::builder("acme/support-ticket")
.console(vec![crate::ConsoleSurface {
name: "tickets".to_owned(),
label: "Tickets".to_owned(),
route: "/support/tickets".to_owned(),
presentation: ConsoleSurfacePresentation::Esm {
entry: "tickets".to_owned(),
},
icon: None,
required_capabilities: Vec::new(),
navigation: None,
}])
.build()
}
fn console_compatibility() -> ModuleCompatibilityDeclaration {
ModuleCompatibilityDeclaration {
host_api_requirement: Some("^1.0.0".to_owned()),
console_ui_requirement: Some("^1.0.0".to_owned()),
..ModuleCompatibilityDeclaration::default()
}
}
fn console_artifact(manifest: &ModuleManifest) -> ConsoleUiArtifact {
ConsoleUiArtifact {
artifact: ArtifactReference {
locator: "oci://registry.example/acme/support-ticket-ui@sha256:ui".to_owned(),
digest: digest("b"),
},
format: ConsoleUiArtifactFormat::Esm,
protocol_major: CONSOLE_MODULE_PROTOCOL_MAJOR,
entry: "entries/tickets/index.js".to_owned(),
entries: vec![
ConsoleUiArtifactEntry {
name: "tickets".to_owned(),
path: "entries/tickets/index.js".to_owned(),
},
ConsoleUiArtifactEntry {
name: "tickets-style".to_owned(),
path: "assets/tickets.css".to_owned(),
},
],
style_assets: vec![crate::ConsoleUiArtifactStyleAsset {
path: "assets/tickets.css".to_owned(),
order: Some(0),
media: None,
}],
manifest: manifest.console_module_manifest("^1.0.0", "^1.0.0"),
requested_permissions: Vec::new(),
provenance: Vec::new(),
}
}
#[test]
fn esm_console_surface_requires_ui_artifact_in_same_release() {
let manifest = console_manifest();
let issues = ModuleRelease::new(
"acme/support-ticket",
"1.2.3",
manifest.clone(),
linked_delivery(),
)
.unwrap_err();
assert!(
issues
.iter()
.any(|issue| issue.path == "$.console_ui_artifact")
);
let mut release = ModuleRelease {
protocol: MODULE_RELEASE_PROTOCOL.to_owned(),
module_id: "acme/support-ticket".to_owned(),
version: "1.2.3".to_owned(),
manifest_digest: digest_json(&manifest).unwrap(),
manifest: manifest.clone(),
delivery: linked_delivery(),
console_ui_artifact: Some({
let mut artifact = console_artifact(&manifest);
artifact.entries[0].name = "other".to_owned();
artifact
}),
compatibility: console_compatibility(),
provenance: Vec::new(),
};
assert!(
release
.validate()
.iter()
.any(|issue| { issue.path == "$.manifest.console.presentation.entry" })
);
release.console_ui_artifact.as_mut().unwrap().entries[0].name = "tickets".to_owned();
assert!(release.validate().is_empty());
}
#[test]
fn console_ui_permissions_and_protocol_are_exact_and_digest_bound() {
let manifest = console_manifest();
let release = ModuleRelease {
protocol: MODULE_RELEASE_PROTOCOL.to_owned(),
module_id: "acme/support-ticket".to_owned(),
version: "1.2.3".to_owned(),
manifest_digest: digest_json(&manifest).unwrap(),
manifest: manifest.clone(),
delivery: linked_delivery(),
console_ui_artifact: Some(ConsoleUiArtifact {
protocol_major: 99,
requested_permissions: vec![ConsolePermissionRequest {
permission_id: "tickets.read".to_owned(),
operations: vec!["read".to_owned(), "read".to_owned()],
resources: Vec::new(),
outbound_destinations: Vec::new(),
secret_references: Vec::new(),
}],
..console_artifact(&manifest)
}),
compatibility: console_compatibility(),
provenance: Vec::new(),
};
let paths = release
.validate()
.into_iter()
.map(|issue| issue.path)
.collect::<Vec<_>>();
assert!(paths.contains(&"$.console_ui_artifact.protocol_major".to_owned()));
assert!(
paths.contains(&"$.console_ui_artifact.requested_permissions.operations".to_owned())
);
}
#[test]
fn release_rejects_identity_and_digest_drift() {
let mut release = ModuleRelease::new(
"acme/support-ticket",
"1.2.3",
manifest(),
ModuleDelivery::Service(ServiceModuleDelivery {
service_id: "acme/support-suite".to_owned(),
service_release_version: "4.2.0".to_owned(),
service_release_digest: digest("b"),
export: "support".to_owned(),
responsibility_profile: ServiceResponsibilityProfile::Provider,
contract_digests: vec![digest("c")],
}),
)
.unwrap();
release.manifest.module_id = "acme/other".to_owned();
let paths = release
.validate()
.into_iter()
.map(|issue| issue.path)
.collect::<Vec<_>>();
assert!(paths.contains(&"$.manifest.module_id".to_owned()));
assert!(paths.contains(&"$.manifest_digest".to_owned()));
}
#[test]
fn delivery_union_rejects_removed_and_future_kinds() {
for kind in ["remote", "bundled", "wasm"] {
let value = serde_json::json!({"kind": kind});
assert!(serde_json::from_value::<ModuleDelivery>(value).is_err());
}
}
#[test]
fn provider_and_autonomous_service_profiles_are_valid() {
for responsibility_profile in [
ServiceResponsibilityProfile::Provider,
ServiceResponsibilityProfile::Autonomous,
] {
let release = ModuleRelease::new(
"acme/support-ticket",
"1.2.3",
manifest(),
ModuleDelivery::Service(ServiceModuleDelivery {
service_id: "acme/support-service".to_owned(),
service_release_version: "4.2.0".to_owned(),
service_release_digest: digest("b"),
export: "support".to_owned(),
responsibility_profile,
contract_digests: vec![digest("c")],
}),
)
.unwrap();
assert!(release.validate().is_empty());
}
}
#[test]
fn unknown_delivery_and_secret_fields_are_rejected() {
let release = ModuleRelease::new(
"acme/support-ticket",
"1.2.3",
manifest(),
ModuleDelivery::Linked(LinkedModuleDelivery {
package: "acme-support-ticket".to_owned(),
crate_version: "1.2.3".to_owned(),
archive_checksum: digest("a"),
default_features: false,
features: Vec::new(),
binding: "support_ticket".to_owned(),
attestations: Vec::new(),
migrations: Vec::new(),
}),
)
.unwrap();
let mut value = serde_json::to_value(release).unwrap();
value["delivery"]["endpoint"] = serde_json::json!("https://example.test");
value["delivery"]["credential"] = serde_json::json!("secret");
assert!(serde_json::from_value::<ModuleRelease>(value).is_err());
}
#[test]
fn manifest_rejects_embedded_secret_defaults_and_unknown_fields() {
use crate::{
ModuleConfigActivation, ModuleConfigContract, ModuleConfigField, ModuleConfigFieldType,
ModuleConfigMutability, ModuleConfigScope,
};
let manifest = ModuleManifest::builder("acme/support-ticket")
.config(ModuleConfigContract {
fields: vec![ModuleConfigField {
key: "api_token".to_owned(),
field_type: ModuleConfigFieldType::String,
required: true,
scope: ModuleConfigScope::Service,
sensitive: true,
secret_reference: true,
mutability: ModuleConfigMutability::Static,
activation: ModuleConfigActivation::ServiceRestart,
read_capability: None,
write_capability: None,
default: Some(serde_json::json!("plaintext-secret")),
validation: None,
}],
})
.build();
assert!(lint_module_manifest(&manifest).iter().any(|lint| {
lint.severity == crate::ModuleManifestLintSeverity::Error
&& lint.subject == "config api_token default"
}));
let mut value = serde_json::to_value(manifest).unwrap();
value["source"] = serde_json::json!("linked");
assert!(serde_json::from_value::<ModuleManifest>(value).is_err());
}
}