Skip to main content

lenso_contracts/
module_release.rs

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}