1use crate::{
2 CONSOLE_MODULE_PROTOCOL, CONSOLE_MODULE_PROTOCOL_MAJOR, CONSOLE_UI_ESM_FORMAT,
3 ConsoleModuleManifest, ConsolePermissionRequest, ConsoleSurfacePresentation,
4 ConsoleUiArtifactStyleAsset, MODULE_MANIFEST_PROTOCOL, ModuleManifest, lint_module_manifest,
5};
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest as _, Sha256};
9use std::collections::BTreeSet;
10use std::fmt::Write as _;
11
12pub const MODULE_RELEASE_PROTOCOL: &str = "lenso.module-release.v1";
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
15#[serde(deny_unknown_fields)]
16pub struct ArtifactReference {
17 pub locator: String,
18 pub digest: String,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
22#[serde(deny_unknown_fields)]
23pub struct AttestationReference {
24 pub locator: String,
25 pub digest: String,
26 pub issuer: String,
27 pub signer: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
31#[serde(deny_unknown_fields)]
32pub struct LinkedModuleDelivery {
33 pub package: String,
34 pub crate_version: String,
35 pub archive_checksum: String,
36 #[serde(default)]
37 pub default_features: bool,
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 pub features: Vec<String>,
40 pub binding: String,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub attestations: Vec<AttestationReference>,
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub migrations: Vec<ArtifactReference>,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(rename_all = "snake_case")]
49pub enum ServiceResponsibilityProfile {
50 Provider,
51 Autonomous,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(deny_unknown_fields)]
56pub struct ServiceModuleDelivery {
57 pub service_id: String,
58 pub service_release_version: String,
59 pub service_release_digest: String,
60 pub export: String,
61 pub responsibility_profile: ServiceResponsibilityProfile,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub contract_digests: Vec<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(tag = "kind", rename_all = "snake_case")]
68pub enum ModuleDelivery {
69 Linked(LinkedModuleDelivery),
70 Service(ServiceModuleDelivery),
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75pub struct ConsoleUiArtifactEntry {
76 pub name: String,
77 pub path: String,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
81#[serde(rename_all = "snake_case")]
82pub enum ConsoleUiArtifactFormat {
83 #[serde(rename = "console_ui_esm")]
84 Esm,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
88#[serde(rename_all = "camelCase")]
89#[serde(deny_unknown_fields)]
90pub struct ConsoleUiArtifact {
91 pub artifact: ArtifactReference,
92 pub format: ConsoleUiArtifactFormat,
93 pub protocol_major: u32,
94 pub entry: String,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub entries: Vec<ConsoleUiArtifactEntry>,
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub style_assets: Vec<ConsoleUiArtifactStyleAsset>,
99 pub manifest: ConsoleModuleManifest,
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub requested_permissions: Vec<ConsolePermissionRequest>,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub provenance: Vec<ArtifactReference>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
107#[serde(deny_unknown_fields)]
108pub struct ModuleCompatibilityDeclaration {
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub lenso_requirement: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub host_api_requirement: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub console_ui_requirement: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub rust_requirement: Option<String>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub targets: Vec<String>,
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub transports: Vec<String>,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub protocol_digests: Vec<String>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
126#[serde(deny_unknown_fields)]
127pub struct ModuleRelease {
128 pub protocol: String,
129 pub module_id: String,
130 pub version: String,
131 pub manifest: ModuleManifest,
132 pub manifest_digest: String,
133 pub delivery: ModuleDelivery,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub console_ui_artifact: Option<ConsoleUiArtifact>,
136 #[serde(default)]
137 pub compatibility: ModuleCompatibilityDeclaration,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
139 pub provenance: Vec<ArtifactReference>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct ModuleContractIssue {
144 pub path: String,
145 pub message: String,
146}
147
148impl ModuleRelease {
149 pub fn new(
150 module_id: impl Into<String>,
151 version: impl AsRef<str>,
152 manifest: ModuleManifest,
153 delivery: ModuleDelivery,
154 ) -> Result<Self, Vec<ModuleContractIssue>> {
155 let version = semver::Version::parse(version.as_ref())
156 .map_err(|error| {
157 vec![issue(
158 "$.version",
159 format!("Module version must be normalized SemVer: {error}"),
160 )]
161 })?
162 .to_string();
163 let manifest_digest = digest_json(&manifest).map_err(|error| {
164 vec![issue(
165 "$.manifest",
166 format!("Manifest cannot be canonicalized: {error}"),
167 )]
168 })?;
169 let release = Self {
170 protocol: MODULE_RELEASE_PROTOCOL.to_owned(),
171 module_id: module_id.into(),
172 version,
173 manifest: manifest.clone(),
174 manifest_digest,
175 delivery,
176 console_ui_artifact: None,
177 compatibility: ModuleCompatibilityDeclaration::default(),
178 provenance: Vec::new(),
179 };
180 let issues = release.validate();
181 if issues.is_empty() {
182 Ok(release)
183 } else {
184 Err(issues)
185 }
186 }
187
188 #[must_use]
189 pub fn validate(&self) -> Vec<ModuleContractIssue> {
190 let mut issues = Vec::new();
191 if self.protocol != MODULE_RELEASE_PROTOCOL {
192 issues.push(issue(
193 "$.protocol",
194 format!("protocol must be {MODULE_RELEASE_PROTOCOL}"),
195 ));
196 }
197 if !valid_module_id(&self.module_id) {
198 issues.push(issue("$.module_id", "ModuleId must use namespace/name"));
199 }
200 if self.manifest.protocol != MODULE_MANIFEST_PROTOCOL {
201 issues.push(issue(
202 "$.manifest.protocol",
203 format!("protocol must be {MODULE_MANIFEST_PROTOCOL}"),
204 ));
205 }
206 if self.manifest.module_id != self.module_id {
207 issues.push(issue(
208 "$.manifest.module_id",
209 "Manifest and Release ModuleIds must match",
210 ));
211 }
212 if !matches!(
213 semver::Version::parse(&self.version),
214 Ok(version) if version.to_string() == self.version
215 ) {
216 issues.push(issue(
217 "$.version",
218 "Module version must be normalized SemVer",
219 ));
220 }
221 match digest_json(&self.manifest) {
222 Ok(digest) if digest == self.manifest_digest => {}
223 Ok(_) => issues.push(issue(
224 "$.manifest_digest",
225 "Manifest digest does not match canonical Manifest bytes",
226 )),
227 Err(error) => issues.push(issue(
228 "$.manifest",
229 format!("Manifest cannot be canonicalized: {error}"),
230 )),
231 }
232 for lint in lint_module_manifest(&self.manifest) {
233 if matches!(lint.severity, crate::ModuleManifestLintSeverity::Error) {
234 issues.push(issue(format!("$.manifest.{}", lint.subject), lint.message));
235 }
236 }
237 validate_delivery(&self.delivery, &mut issues);
238 validate_artifacts("$.provenance", &self.provenance, &mut issues);
239 if let Some(console) = &self.console_ui_artifact {
240 validate_artifact_reference(
241 "$.console_ui_artifact.artifact",
242 &console.artifact,
243 &mut issues,
244 );
245 if !matches!(console.format, ConsoleUiArtifactFormat::Esm) {
246 issues.push(issue(
247 "$.console_ui_artifact.format",
248 format!("format must be {CONSOLE_UI_ESM_FORMAT}"),
249 ));
250 }
251 if console.protocol_major != CONSOLE_MODULE_PROTOCOL_MAJOR {
252 issues.push(issue(
253 "$.console_ui_artifact.protocol_major",
254 format!(
255 "protocol_major must be {CONSOLE_MODULE_PROTOCOL_MAJOR} for {CONSOLE_MODULE_PROTOCOL}"
256 ),
257 ));
258 }
259 validate_console_module_manifest(
260 "$.console_ui_artifact.manifest",
261 &console.manifest,
262 &mut issues,
263 );
264 if console.manifest.module_id != self.module_id {
265 issues.push(issue(
266 "$.console_ui_artifact.manifest.module_id",
267 "Console Module manifest and Module Release ModuleIds must match",
268 ));
269 }
270 if let Some(requirement) = &self.compatibility.host_api_requirement
271 && &console.manifest.host_api != requirement
272 {
273 issues.push(issue(
274 "$.console_ui_artifact.manifest.host_api",
275 "Console Module host_api must match the Release host_api_requirement",
276 ));
277 }
278 if let Some(requirement) = &self.compatibility.console_ui_requirement
279 && &console.manifest.console_ui != requirement
280 {
281 issues.push(issue(
282 "$.console_ui_artifact.manifest.console_ui",
283 "Console Module console_ui must match the Release console_ui_requirement",
284 ));
285 }
286 if console.entry.trim().is_empty()
287 || !console
288 .entries
289 .iter()
290 .any(|entry| entry.path == console.entry)
291 {
292 issues.push(issue(
293 "$.console_ui_artifact.entry",
294 "Console UI entry must be declared by the artifact entries",
295 ));
296 }
297 validate_console_ui_entries(&console.entries, &mut issues);
298 validate_console_ui_style_assets(&console.style_assets, &console.entries, &mut issues);
299 validate_console_permission_requests(&console.requested_permissions, &mut issues);
300 validate_artifacts(
301 "$.console_ui_artifact.provenance",
302 &console.provenance,
303 &mut issues,
304 );
305 validate_console_surface_entries(&self.manifest, console, &mut issues);
306 if self.manifest.console.iter().any(|surface| {
307 matches!(surface.presentation, ConsoleSurfacePresentation::Esm { .. })
308 }) {
309 if self.compatibility.host_api_requirement.is_none() {
310 issues.push(issue(
311 "$.compatibility.host_api_requirement",
312 "ESM Console Releases require an independent host API SemVer range",
313 ));
314 }
315 if self.compatibility.console_ui_requirement.is_none() {
316 issues.push(issue(
317 "$.compatibility.console_ui_requirement",
318 "ESM Console Releases require an independent Console UI SemVer range",
319 ));
320 }
321 }
322 } else if self
323 .manifest
324 .console
325 .iter()
326 .any(|surface| matches!(surface.presentation, ConsoleSurfacePresentation::Esm { .. }))
327 {
328 issues.push(issue(
329 "$.console_ui_artifact",
330 "ESM Console surfaces require a Console UI Artifact in the same Module Release",
331 ));
332 }
333 for (path, requirement) in [
334 (
335 "$.compatibility.lenso_requirement",
336 self.compatibility.lenso_requirement.as_deref(),
337 ),
338 (
339 "$.compatibility.host_api_requirement",
340 self.compatibility.host_api_requirement.as_deref(),
341 ),
342 (
343 "$.compatibility.console_ui_requirement",
344 self.compatibility.console_ui_requirement.as_deref(),
345 ),
346 (
347 "$.compatibility.rust_requirement",
348 self.compatibility.rust_requirement.as_deref(),
349 ),
350 ] {
351 if let Some(requirement) = requirement {
352 validate_version_requirement(path, requirement, &mut issues);
353 }
354 }
355 for (path, values) in [
356 ("$.compatibility.targets", &self.compatibility.targets),
357 ("$.compatibility.transports", &self.compatibility.transports),
358 (
359 "$.compatibility.protocol_digests",
360 &self.compatibility.protocol_digests,
361 ),
362 ] {
363 validate_sorted_unique(path, values, &mut issues);
364 }
365 for digest in &self.compatibility.protocol_digests {
366 validate_digest("$.compatibility.protocol_digests", digest, &mut issues);
367 }
368 issues
369 }
370}
371
372pub fn canonical_json<T: Serialize>(value: &T) -> serde_json::Result<Vec<u8>> {
373 serde_json_canonicalizer::to_vec(value)
374}
375
376pub fn digest_json<T: Serialize>(value: &T) -> serde_json::Result<String> {
377 let bytes = canonical_json(value)?;
378 let mut rendered = String::with_capacity("sha256:".len() + 64);
379 rendered.push_str("sha256:");
380 for byte in Sha256::digest(bytes) {
381 write!(&mut rendered, "{byte:02x}").expect("writing to a String cannot fail");
382 }
383 Ok(rendered)
384}
385
386fn validate_delivery(delivery: &ModuleDelivery, issues: &mut Vec<ModuleContractIssue>) {
387 match delivery {
388 ModuleDelivery::Linked(linked) => {
389 if linked.package.trim().is_empty() || linked.binding.trim().is_empty() {
390 issues.push(issue(
391 "$.delivery",
392 "Linked delivery requires package and binding",
393 ));
394 }
395 validate_semver("$.delivery.crate_version", &linked.crate_version, issues);
396 validate_digest(
397 "$.delivery.archive_checksum",
398 &linked.archive_checksum,
399 issues,
400 );
401 validate_sorted_unique("$.delivery.features", &linked.features, issues);
402 let mut attestation_keys = BTreeSet::new();
403 for attestation in &linked.attestations {
404 if attestation.locator.trim().is_empty()
405 || attestation.issuer.trim().is_empty()
406 || attestation.signer.trim().is_empty()
407 || !attestation_keys.insert((&attestation.locator, &attestation.digest))
408 {
409 issues.push(issue(
410 "$.delivery.attestations",
411 "Attestations require unique locator/digest pairs and complete identity",
412 ));
413 }
414 validate_digest(
415 "$.delivery.attestations.digest",
416 &attestation.digest,
417 issues,
418 );
419 }
420 validate_artifacts("$.delivery.migrations", &linked.migrations, issues);
421 }
422 ModuleDelivery::Service(service) => {
423 if !valid_module_id(&service.service_id) || service.export.trim().is_empty() {
424 issues.push(issue(
425 "$.delivery",
426 "Service delivery requires a fully qualified ServiceId and export",
427 ));
428 }
429 validate_semver(
430 "$.delivery.service_release_version",
431 &service.service_release_version,
432 issues,
433 );
434 validate_digest(
435 "$.delivery.service_release_digest",
436 &service.service_release_digest,
437 issues,
438 );
439 if service.contract_digests.is_empty() {
440 issues.push(issue(
441 "$.delivery.contract_digests",
442 "Service delivery requires governing contract digests",
443 ));
444 }
445 for digest in &service.contract_digests {
446 validate_digest("$.delivery.contract_digests", digest, issues);
447 }
448 validate_sorted_unique(
449 "$.delivery.contract_digests",
450 &service.contract_digests,
451 issues,
452 );
453 }
454 }
455}
456
457fn validate_semver(path: &str, value: &str, issues: &mut Vec<ModuleContractIssue>) {
458 if !matches!(
459 semver::Version::parse(value),
460 Ok(version) if version.to_string() == value
461 ) {
462 issues.push(issue(path, "value must be normalized SemVer"));
463 }
464}
465
466fn validate_version_requirement(path: &str, value: &str, issues: &mut Vec<ModuleContractIssue>) {
467 if !matches!(
468 semver::VersionReq::parse(value),
469 Ok(requirement) if requirement.to_string() == value
470 ) {
471 issues.push(issue(path, "value must be a normalized SemVer requirement"));
472 }
473}
474
475fn validate_digest(path: &str, value: &str, issues: &mut Vec<ModuleContractIssue>) {
476 if !value.strip_prefix("sha256:").is_some_and(|digest| {
477 digest.len() == 64
478 && digest
479 .bytes()
480 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
481 }) {
482 issues.push(issue(path, "value must be sha256:<64 lowercase hex>"));
483 }
484}
485
486fn validate_sorted_unique(path: &str, values: &[String], issues: &mut Vec<ModuleContractIssue>) {
487 if values.iter().any(|value| value.trim().is_empty())
488 || values.windows(2).any(|pair| pair[0] >= pair[1])
489 {
490 issues.push(issue(path, "values must be non-empty, sorted, and unique"));
491 }
492}
493
494fn validate_unique<T, F>(path: &str, values: &[T], key: F, issues: &mut Vec<ModuleContractIssue>)
495where
496 F: Fn(&T) -> &String,
497{
498 let mut seen = BTreeSet::new();
499 if values.iter().any(|value| !seen.insert(key(value))) {
500 issues.push(issue(path, "values must be unique"));
501 }
502}
503
504fn validate_artifacts(
505 path: &str,
506 values: &[ArtifactReference],
507 issues: &mut Vec<ModuleContractIssue>,
508) {
509 validate_unique(path, values, |item| &item.digest, issues);
510 for artifact in values {
511 if artifact.locator.trim().is_empty() {
512 issues.push(issue(path, "artifact locator must be non-empty"));
513 }
514 validate_digest(path, &artifact.digest, issues);
515 }
516}
517
518fn validate_artifact_reference(
519 path: &str,
520 artifact: &ArtifactReference,
521 issues: &mut Vec<ModuleContractIssue>,
522) {
523 if artifact.locator.trim().is_empty() {
524 issues.push(issue(path, "artifact locator must be non-empty"));
525 }
526 validate_digest(path, &artifact.digest, issues);
527}
528
529fn validate_console_ui_entries(
530 entries: &[ConsoleUiArtifactEntry],
531 issues: &mut Vec<ModuleContractIssue>,
532) {
533 let mut paths = BTreeSet::new();
534 validate_unique(
535 "$.console_ui_artifact.entries",
536 entries,
537 |entry| &entry.name,
538 issues,
539 );
540 for entry in entries {
541 if entry.name.trim().is_empty()
542 || entry.path.trim().is_empty()
543 || entry.path.starts_with('/')
544 || !paths.insert(&entry.path)
545 || entry
546 .path
547 .split('/')
548 .any(|segment| segment.is_empty() || segment == "." || segment == "..")
549 {
550 issues.push(issue(
551 "$.console_ui_artifact.entries",
552 "Console UI entries require a stable name and a relative artifact path without traversal",
553 ));
554 }
555 }
556}
557
558fn validate_console_ui_style_assets(
559 assets: &[ConsoleUiArtifactStyleAsset],
560 entries: &[ConsoleUiArtifactEntry],
561 issues: &mut Vec<ModuleContractIssue>,
562) {
563 let mut paths = BTreeSet::new();
564 for asset in assets {
565 if asset.path.trim().is_empty()
566 || asset.path.starts_with('/')
567 || asset
568 .path
569 .split('/')
570 .any(|segment| segment.is_empty() || segment == "." || segment == "..")
571 || !paths.insert(&asset.path)
572 {
573 issues.push(issue(
574 "$.console_ui_artifact.style_assets",
575 "Console UI style assets require unique relative paths without traversal",
576 ));
577 }
578 if !entries.iter().any(|entry| entry.path == asset.path) {
579 issues.push(issue(
580 "$.console_ui_artifact.style_assets",
581 "Console UI style assets must be declared by artifact entries",
582 ));
583 }
584 if asset
585 .media
586 .as_deref()
587 .is_some_and(|media| media.trim().is_empty())
588 {
589 issues.push(issue(
590 "$.console_ui_artifact.style_assets.media",
591 "Console UI style asset media must be non-empty when present",
592 ));
593 }
594 }
595}
596
597fn validate_console_module_manifest(
598 path: &str,
599 manifest: &ConsoleModuleManifest,
600 issues: &mut Vec<ModuleContractIssue>,
601) {
602 if manifest.protocol != CONSOLE_MODULE_PROTOCOL {
603 issues.push(issue(
604 format!("{path}.protocol"),
605 format!("protocol must be {CONSOLE_MODULE_PROTOCOL}"),
606 ));
607 }
608 if !valid_module_id(&manifest.module_id) {
609 issues.push(issue(
610 format!("{path}.module_id"),
611 "Console Module manifest requires a fully qualified ModuleId",
612 ));
613 }
614 for (field_path, requirement) in [
615 (format!("{path}.host_api"), manifest.host_api.as_str()),
616 (format!("{path}.console_ui"), manifest.console_ui.as_str()),
617 ] {
618 validate_version_requirement(&field_path, requirement, issues);
619 }
620 if manifest.surfaces.is_empty() {
621 issues.push(issue(
622 format!("{path}.surfaces"),
623 "Console Module manifest must declare at least one ESM surface",
624 ));
625 }
626 let mut ids = BTreeSet::new();
627 let mut paths = BTreeSet::new();
628 for (index, surface) in manifest.surfaces.iter().enumerate() {
629 let subject = format!("{path}.surfaces[{index}]");
630 if surface.id.trim().is_empty() || !ids.insert(&surface.id) {
631 issues.push(issue(
632 format!("{subject}.id"),
633 "Console Module surface ids must be non-empty and unique",
634 ));
635 }
636 if surface.label.trim().is_empty() {
637 issues.push(issue(
638 format!("{subject}.label"),
639 "Console Module surface labels must be non-empty",
640 ));
641 }
642 if !valid_console_surface_path(&surface.path) || !paths.insert(&surface.path) {
643 issues.push(issue(
644 format!("{subject}.path"),
645 "Console Module surface paths must be unique absolute static paths",
646 ));
647 }
648 if surface
649 .required_capabilities
650 .windows(2)
651 .any(|pair| pair[0] >= pair[1])
652 || surface
653 .required_capabilities
654 .iter()
655 .any(|capability| capability.trim().is_empty())
656 {
657 issues.push(issue(
658 format!("{subject}.required_capabilities"),
659 "Console Module surface capabilities must be non-empty, sorted, and unique",
660 ));
661 }
662 }
663}
664
665fn valid_console_surface_path(value: &str) -> bool {
666 value.starts_with('/')
667 && !value.contains('\\')
668 && !value.contains('?')
669 && !value.contains('#')
670 && !value[1..]
671 .split('/')
672 .any(|segment| segment.is_empty() || segment == "..")
673}
674
675fn validate_console_permission_requests(
676 permissions: &[ConsolePermissionRequest],
677 issues: &mut Vec<ModuleContractIssue>,
678) {
679 validate_unique(
680 "$.console_ui_artifact.requested_permissions",
681 permissions,
682 |permission| &permission.permission_id,
683 issues,
684 );
685 for permission in permissions {
686 if permission.permission_id.trim().is_empty() {
687 issues.push(issue(
688 "$.console_ui_artifact.requested_permissions",
689 "Console permission identifiers must be non-empty",
690 ));
691 }
692 for (path, values) in [
693 ("operations", &permission.operations),
694 ("resources", &permission.resources),
695 ("outbound_destinations", &permission.outbound_destinations),
696 ("secret_references", &permission.secret_references),
697 ] {
698 validate_sorted_unique(
699 &format!("$.console_ui_artifact.requested_permissions.{path}"),
700 values,
701 issues,
702 );
703 }
704 }
705}
706
707fn validate_console_surface_entries(
708 manifest: &ModuleManifest,
709 artifact: &ConsoleUiArtifact,
710 issues: &mut Vec<ModuleContractIssue>,
711) {
712 let generated = manifest.console_module_manifest(
713 artifact.manifest.host_api.clone(),
714 artifact.manifest.console_ui.clone(),
715 );
716 if generated != artifact.manifest {
717 issues.push(issue(
718 "$.console_ui_artifact.manifest.surfaces",
719 "Console Module manifest must be generated from the Release Module declaration",
720 ));
721 }
722 let entries = artifact
723 .entries
724 .iter()
725 .map(|entry| entry.name.as_str())
726 .collect::<BTreeSet<_>>();
727 for surface in &manifest.console {
728 if let ConsoleSurfacePresentation::Esm { entry } = &surface.presentation {
729 if !entries.contains(entry.as_str()) {
730 issues.push(issue(
731 "$.manifest.console.presentation.entry",
732 format!(
733 "Console surface entry `{entry}` is missing from the release UI artifact"
734 ),
735 ));
736 }
737 }
738 }
739}
740
741fn valid_module_id(value: &str) -> bool {
742 let Some((namespace, name)) = value.split_once('/') else {
743 return false;
744 };
745 !namespace.is_empty()
746 && !name.is_empty()
747 && !name.contains('/')
748 && [namespace, name].into_iter().all(|segment| {
749 segment
750 .bytes()
751 .next()
752 .is_some_and(|byte| byte.is_ascii_lowercase())
753 && segment.bytes().all(|byte| {
754 byte.is_ascii_lowercase()
755 || byte.is_ascii_digit()
756 || byte == b'-'
757 || byte == b'_'
758 })
759 })
760}
761
762fn issue(path: impl Into<String>, message: impl Into<String>) -> ModuleContractIssue {
763 ModuleContractIssue {
764 path: path.into(),
765 message: message.into(),
766 }
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772
773 fn digest(value: &str) -> String {
774 format!("sha256:{}", value.repeat(64))
775 }
776
777 fn manifest() -> ModuleManifest {
778 ModuleManifest::builder("acme/support-ticket").build()
779 }
780
781 fn linked_delivery() -> ModuleDelivery {
782 ModuleDelivery::Linked(LinkedModuleDelivery {
783 package: "acme-support-ticket".to_owned(),
784 crate_version: "1.2.3".to_owned(),
785 archive_checksum: digest("a"),
786 default_features: false,
787 features: Vec::new(),
788 binding: "support_ticket".to_owned(),
789 attestations: Vec::new(),
790 migrations: Vec::new(),
791 })
792 }
793
794 #[test]
795 fn canonical_digest_is_order_independent() {
796 let left = serde_json::json!({"b": 2, "a": 1});
797 let right = serde_json::json!({"a": 1, "b": 2});
798 assert_eq!(digest_json(&left).unwrap(), digest_json(&right).unwrap());
799 assert_eq!(canonical_json(&left).unwrap(), br#"{"a":1,"b":2}"#);
800 }
801
802 #[test]
803 fn linked_release_binds_exact_manifest() {
804 let manifest = manifest();
805 let release = ModuleRelease::new(
806 "acme/support-ticket",
807 "1.2.3",
808 manifest.clone(),
809 ModuleDelivery::Linked(LinkedModuleDelivery {
810 package: "acme-support-ticket".to_owned(),
811 crate_version: "4.2.0".to_owned(),
812 archive_checksum: digest("a"),
813 default_features: false,
814 features: vec!["postgres".to_owned()],
815 binding: "support_ticket".to_owned(),
816 attestations: Vec::new(),
817 migrations: Vec::new(),
818 }),
819 )
820 .unwrap();
821
822 assert_eq!(release.manifest_digest, digest_json(&manifest).unwrap());
823 assert!(release.validate().is_empty());
824 }
825
826 fn console_manifest() -> ModuleManifest {
827 ModuleManifest::builder("acme/support-ticket")
828 .console(vec![crate::ConsoleSurface {
829 name: "tickets".to_owned(),
830 label: "Tickets".to_owned(),
831 route: "/support/tickets".to_owned(),
832 presentation: ConsoleSurfacePresentation::Esm {
833 entry: "tickets".to_owned(),
834 },
835 icon: None,
836 required_capabilities: Vec::new(),
837 navigation: None,
838 }])
839 .build()
840 }
841
842 fn console_compatibility() -> ModuleCompatibilityDeclaration {
843 ModuleCompatibilityDeclaration {
844 host_api_requirement: Some("^1.0.0".to_owned()),
845 console_ui_requirement: Some("^1.0.0".to_owned()),
846 ..ModuleCompatibilityDeclaration::default()
847 }
848 }
849
850 fn console_artifact(manifest: &ModuleManifest) -> ConsoleUiArtifact {
851 ConsoleUiArtifact {
852 artifact: ArtifactReference {
853 locator: "oci://registry.example/acme/support-ticket-ui@sha256:ui".to_owned(),
854 digest: digest("b"),
855 },
856 format: ConsoleUiArtifactFormat::Esm,
857 protocol_major: CONSOLE_MODULE_PROTOCOL_MAJOR,
858 entry: "entries/tickets/index.js".to_owned(),
859 entries: vec![
860 ConsoleUiArtifactEntry {
861 name: "tickets".to_owned(),
862 path: "entries/tickets/index.js".to_owned(),
863 },
864 ConsoleUiArtifactEntry {
865 name: "tickets-style".to_owned(),
866 path: "assets/tickets.css".to_owned(),
867 },
868 ],
869 style_assets: vec![crate::ConsoleUiArtifactStyleAsset {
870 path: "assets/tickets.css".to_owned(),
871 order: Some(0),
872 media: None,
873 }],
874 manifest: manifest.console_module_manifest("^1.0.0", "^1.0.0"),
875 requested_permissions: Vec::new(),
876 provenance: Vec::new(),
877 }
878 }
879
880 #[test]
881 fn esm_console_surface_requires_ui_artifact_in_same_release() {
882 let manifest = console_manifest();
883 let issues = ModuleRelease::new(
884 "acme/support-ticket",
885 "1.2.3",
886 manifest.clone(),
887 linked_delivery(),
888 )
889 .unwrap_err();
890 assert!(
891 issues
892 .iter()
893 .any(|issue| issue.path == "$.console_ui_artifact")
894 );
895
896 let mut release = ModuleRelease {
897 protocol: MODULE_RELEASE_PROTOCOL.to_owned(),
898 module_id: "acme/support-ticket".to_owned(),
899 version: "1.2.3".to_owned(),
900 manifest_digest: digest_json(&manifest).unwrap(),
901 manifest: manifest.clone(),
902 delivery: linked_delivery(),
903 console_ui_artifact: Some({
904 let mut artifact = console_artifact(&manifest);
905 artifact.entries[0].name = "other".to_owned();
906 artifact
907 }),
908 compatibility: console_compatibility(),
909 provenance: Vec::new(),
910 };
911 assert!(
912 release
913 .validate()
914 .iter()
915 .any(|issue| { issue.path == "$.manifest.console.presentation.entry" })
916 );
917
918 release.console_ui_artifact.as_mut().unwrap().entries[0].name = "tickets".to_owned();
919 assert!(release.validate().is_empty());
920 }
921
922 #[test]
923 fn console_ui_permissions_and_protocol_are_exact_and_digest_bound() {
924 let manifest = console_manifest();
925 let release = ModuleRelease {
926 protocol: MODULE_RELEASE_PROTOCOL.to_owned(),
927 module_id: "acme/support-ticket".to_owned(),
928 version: "1.2.3".to_owned(),
929 manifest_digest: digest_json(&manifest).unwrap(),
930 manifest: manifest.clone(),
931 delivery: linked_delivery(),
932 console_ui_artifact: Some(ConsoleUiArtifact {
933 protocol_major: 99,
934 requested_permissions: vec![ConsolePermissionRequest {
935 permission_id: "tickets.read".to_owned(),
936 operations: vec!["read".to_owned(), "read".to_owned()],
937 resources: Vec::new(),
938 outbound_destinations: Vec::new(),
939 secret_references: Vec::new(),
940 }],
941 ..console_artifact(&manifest)
942 }),
943 compatibility: console_compatibility(),
944 provenance: Vec::new(),
945 };
946
947 let paths = release
948 .validate()
949 .into_iter()
950 .map(|issue| issue.path)
951 .collect::<Vec<_>>();
952 assert!(paths.contains(&"$.console_ui_artifact.protocol_major".to_owned()));
953 assert!(
954 paths.contains(&"$.console_ui_artifact.requested_permissions.operations".to_owned())
955 );
956 }
957
958 #[test]
959 fn release_rejects_identity_and_digest_drift() {
960 let mut release = ModuleRelease::new(
961 "acme/support-ticket",
962 "1.2.3",
963 manifest(),
964 ModuleDelivery::Service(ServiceModuleDelivery {
965 service_id: "acme/support-suite".to_owned(),
966 service_release_version: "4.2.0".to_owned(),
967 service_release_digest: digest("b"),
968 export: "support".to_owned(),
969 responsibility_profile: ServiceResponsibilityProfile::Provider,
970 contract_digests: vec![digest("c")],
971 }),
972 )
973 .unwrap();
974 release.manifest.module_id = "acme/other".to_owned();
975
976 let paths = release
977 .validate()
978 .into_iter()
979 .map(|issue| issue.path)
980 .collect::<Vec<_>>();
981 assert!(paths.contains(&"$.manifest.module_id".to_owned()));
982 assert!(paths.contains(&"$.manifest_digest".to_owned()));
983 }
984
985 #[test]
986 fn delivery_union_rejects_removed_and_future_kinds() {
987 for kind in ["remote", "bundled", "wasm"] {
988 let value = serde_json::json!({"kind": kind});
989 assert!(serde_json::from_value::<ModuleDelivery>(value).is_err());
990 }
991 }
992
993 #[test]
994 fn provider_and_autonomous_service_profiles_are_valid() {
995 for responsibility_profile in [
996 ServiceResponsibilityProfile::Provider,
997 ServiceResponsibilityProfile::Autonomous,
998 ] {
999 let release = ModuleRelease::new(
1000 "acme/support-ticket",
1001 "1.2.3",
1002 manifest(),
1003 ModuleDelivery::Service(ServiceModuleDelivery {
1004 service_id: "acme/support-service".to_owned(),
1005 service_release_version: "4.2.0".to_owned(),
1006 service_release_digest: digest("b"),
1007 export: "support".to_owned(),
1008 responsibility_profile,
1009 contract_digests: vec![digest("c")],
1010 }),
1011 )
1012 .unwrap();
1013
1014 assert!(release.validate().is_empty());
1015 }
1016 }
1017
1018 #[test]
1019 fn unknown_delivery_and_secret_fields_are_rejected() {
1020 let release = ModuleRelease::new(
1021 "acme/support-ticket",
1022 "1.2.3",
1023 manifest(),
1024 ModuleDelivery::Linked(LinkedModuleDelivery {
1025 package: "acme-support-ticket".to_owned(),
1026 crate_version: "1.2.3".to_owned(),
1027 archive_checksum: digest("a"),
1028 default_features: false,
1029 features: Vec::new(),
1030 binding: "support_ticket".to_owned(),
1031 attestations: Vec::new(),
1032 migrations: Vec::new(),
1033 }),
1034 )
1035 .unwrap();
1036 let mut value = serde_json::to_value(release).unwrap();
1037 value["delivery"]["endpoint"] = serde_json::json!("https://example.test");
1038 value["delivery"]["credential"] = serde_json::json!("secret");
1039
1040 assert!(serde_json::from_value::<ModuleRelease>(value).is_err());
1041 }
1042
1043 #[test]
1044 fn manifest_rejects_embedded_secret_defaults_and_unknown_fields() {
1045 use crate::{
1046 ModuleConfigActivation, ModuleConfigContract, ModuleConfigField, ModuleConfigFieldType,
1047 ModuleConfigMutability, ModuleConfigScope,
1048 };
1049
1050 let manifest = ModuleManifest::builder("acme/support-ticket")
1051 .config(ModuleConfigContract {
1052 fields: vec![ModuleConfigField {
1053 key: "api_token".to_owned(),
1054 field_type: ModuleConfigFieldType::String,
1055 required: true,
1056 scope: ModuleConfigScope::Service,
1057 sensitive: true,
1058 secret_reference: true,
1059 mutability: ModuleConfigMutability::Static,
1060 activation: ModuleConfigActivation::ServiceRestart,
1061 read_capability: None,
1062 write_capability: None,
1063 default: Some(serde_json::json!("plaintext-secret")),
1064 validation: None,
1065 }],
1066 })
1067 .build();
1068 assert!(lint_module_manifest(&manifest).iter().any(|lint| {
1069 lint.severity == crate::ModuleManifestLintSeverity::Error
1070 && lint.subject == "config api_token default"
1071 }));
1072
1073 let mut value = serde_json::to_value(manifest).unwrap();
1074 value["source"] = serde_json::json!("linked");
1075 assert!(serde_json::from_value::<ModuleManifest>(value).is_err());
1076 }
1077}