Skip to main content

lenso_contracts/
module_release.rs

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}