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