greentic_component/manifest/
mod.rs

1use std::collections::HashSet;
2use std::path::{Component, Path, PathBuf};
3
4use jsonschema::{Validator, validator_for};
5use once_cell::sync::Lazy;
6use regex::Regex;
7use semver::Version;
8use serde::Serialize;
9use serde_json::Value;
10use thiserror::Error;
11
12use crate::capabilities::{
13    Capabilities, ComponentConfigurators, ComponentProfiles, validate_capabilities,
14};
15use crate::limits::Limits;
16use crate::provenance::Provenance;
17use crate::telemetry::TelemetrySpec;
18use greentic_types::component::ComponentOperation;
19use greentic_types::flow::FlowKind;
20use greentic_types::{SecretKey, SecretRequirement};
21
22static RAW_SCHEMA: &str = include_str!("../../schemas/v1/component.manifest.schema.json");
23
24static COMPILED_SCHEMA: Lazy<Validator> = Lazy::new(|| {
25    let value: Value =
26        serde_json::from_str(RAW_SCHEMA).expect("component manifest schema must be valid JSON");
27    validator_for(&value).expect("component manifest schema must compile")
28});
29
30static OPERATION_PATTERN: Lazy<Regex> =
31    Lazy::new(|| Regex::new(r"^[a-z][a-z0-9_.:-]*$").expect("valid operation regex"));
32
33#[derive(Debug, Clone, Serialize, PartialEq)]
34pub struct ComponentManifest {
35    pub id: ManifestId,
36    pub name: String,
37    pub version: Version,
38    #[serde(default)]
39    pub supports: Vec<FlowKind>,
40    pub world: World,
41    #[serde(default)]
42    pub capabilities: Capabilities,
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub secret_requirements: Vec<SecretRequirement>,
45    pub profiles: ComponentProfiles,
46    #[serde(default)]
47    pub configurators: Option<ComponentConfigurators>,
48    #[serde(default)]
49    pub limits: Option<Limits>,
50    #[serde(default)]
51    pub telemetry: Option<TelemetrySpec>,
52    pub describe_export: DescribeExport,
53    pub operations: Vec<ComponentOperation>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub default_operation: Option<String>,
56    #[serde(default)]
57    pub provenance: Option<Provenance>,
58    pub artifacts: Artifacts,
59    pub hashes: Hashes,
60}
61
62impl ComponentManifest {
63    pub fn wasm_artifact_path(&self, root: &Path) -> PathBuf {
64        root.join(&self.artifacts.component_wasm)
65    }
66}
67
68#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
69#[serde(transparent)]
70pub struct ManifestId(String);
71
72impl ManifestId {
73    fn parse(id: String) -> Result<Self, ManifestError> {
74        if id.trim().is_empty() {
75            return Err(ManifestError::EmptyField("id"));
76        }
77        Ok(Self(id))
78    }
79
80    pub fn as_str(&self) -> &str {
81        &self.0
82    }
83}
84
85impl std::fmt::Display for ManifestId {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str(&self.0)
88    }
89}
90
91#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
92#[serde(transparent)]
93pub struct World(String);
94
95impl World {
96    fn parse(world: String) -> Result<Self, ManifestError> {
97        if world.trim().is_empty() {
98            return Err(ManifestError::InvalidWorld { world });
99        }
100        Ok(Self(world))
101    }
102
103    pub fn as_str(&self) -> &str {
104        &self.0
105    }
106}
107
108impl std::fmt::Display for World {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.write_str(&self.0)
111    }
112}
113
114#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
115#[serde(transparent)]
116pub struct DescribeExport(String);
117
118impl DescribeExport {
119    fn parse(export: String) -> Result<Self, ManifestError> {
120        if export.trim().is_empty() {
121            return Err(ManifestError::InvalidDescribeExport {
122                export,
123                reason: "describe_export cannot be empty".into(),
124            });
125        }
126        Ok(Self(export))
127    }
128
129    pub fn as_str(&self) -> &str {
130        &self.0
131    }
132
133    pub fn kind(&self) -> DescribeKind {
134        if self.0.contains(':') && self.0.contains('/') {
135            DescribeKind::WitWorld
136        } else {
137            DescribeKind::Export
138        }
139    }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum DescribeKind {
144    Export,
145    WitWorld,
146}
147
148#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
149pub struct Artifacts {
150    component_wasm: PathBuf,
151}
152
153impl Artifacts {
154    pub fn component_wasm(&self) -> &Path {
155        &self.component_wasm
156    }
157}
158
159#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
160pub struct Hashes {
161    pub component_wasm: WasmHash,
162}
163
164#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
165#[serde(transparent)]
166pub struct WasmHash(String);
167
168impl WasmHash {
169    fn parse(hash: String) -> Result<Self, ManifestError> {
170        let Some(rest) = hash.strip_prefix("blake3:") else {
171            return Err(ManifestError::InvalidHashFormat { hash });
172        };
173        if rest.len() != 64 || !rest.chars().all(|c| c.is_ascii_hexdigit()) {
174            return Err(ManifestError::InvalidHashFormat {
175                hash: format!("blake3:{rest}"),
176            });
177        }
178        Ok(Self(format!("blake3:{rest}")))
179    }
180
181    pub fn algorithm(&self) -> &str {
182        "blake3"
183    }
184
185    pub fn digest(&self) -> &str {
186        &self.0[7..]
187    }
188
189    pub fn as_str(&self) -> &str {
190        &self.0
191    }
192}
193
194pub fn schema() -> &'static str {
195    RAW_SCHEMA
196}
197
198pub fn parse_manifest(raw: &str) -> Result<ComponentManifest, ManifestError> {
199    let mut value: Value = serde_json::from_str(raw)?;
200    normalize_state_delete(&mut value);
201    validate_value(&value)?;
202    let raw_manifest: RawManifest = serde_json::from_value(value)?;
203    raw_manifest.try_into()
204}
205
206pub fn validate_manifest(raw: &str) -> Result<(), ManifestError> {
207    let value: Value = serde_json::from_str(raw)?;
208    validate_value(&value)
209}
210
211fn validate_value(value: &Value) -> Result<(), ManifestError> {
212    let errors: Vec<String> = COMPILED_SCHEMA
213        .iter_errors(value)
214        .map(|err| err.to_string())
215        .collect();
216    if errors.is_empty() {
217        Ok(())
218    } else {
219        Err(ManifestError::Schema(errors.join(", ")))
220    }
221}
222
223fn normalize_state_delete(value: &mut Value) {
224    let state = value
225        .get_mut("capabilities")
226        .and_then(|caps| caps.get_mut("host"))
227        .and_then(|host| host.get_mut("state"));
228    if let Some(state) = state {
229        let delete_enabled = state
230            .get("delete")
231            .and_then(|value| value.as_bool())
232            .unwrap_or(false);
233        if delete_enabled {
234            state
235                .as_object_mut()
236                .map(|obj| obj.insert("write".to_string(), Value::Bool(true)));
237        }
238    }
239}
240
241#[derive(Debug, Error)]
242pub enum ManifestError {
243    #[error("manifest json parse failed: {0}")]
244    Json(#[from] serde_json::Error),
245    #[error("manifest schema validation failed: {0}")]
246    Schema(String),
247    #[error("world identifier is invalid: `{world}`")]
248    InvalidWorld { world: String },
249    #[error("manifest field `{0}` cannot be empty")]
250    EmptyField(&'static str),
251    #[error("component must expose at least one operation")]
252    MissingOperations,
253    #[error("operation `{operation}` is invalid")]
254    InvalidOperation { operation: String },
255    #[error("duplicate operation `{0}` detected")]
256    DuplicateOperation(String),
257    #[error("default_operation `{operation}` must match one of the declared operations")]
258    InvalidDefaultOperation { operation: String },
259    #[error("component must support at least one flow kind")]
260    MissingSupports,
261    #[error("profiles.supported must include at least one profile identifier")]
262    MissingProfiles,
263    #[error("profiles.default `{default}` must be one of the supported profiles")]
264    InvalidProfileDefault { default: String },
265    #[error("invalid semantic version `{version}`: {source}")]
266    InvalidVersion {
267        version: String,
268        #[source]
269        source: semver::Error,
270    },
271    #[error("invalid describe export `{export}`: {reason}")]
272    InvalidDescribeExport { export: String, reason: String },
273    #[error("component wasm path must be relative (got `{path}`)")]
274    InvalidArtifactPath { path: String },
275    #[error("component wasm hash must be blake3:<hex> (got `{hash}`)")]
276    InvalidHashFormat { hash: String },
277    #[error("capability validation failed: {0}")]
278    Capability(String),
279    #[error("duplicate secret requirement `{0}` detected")]
280    DuplicateSecretRequirement(String),
281    #[error("secret requirement `{key}` is invalid: {reason}")]
282    InvalidSecretRequirement { key: String, reason: String },
283    #[error("limits invalid: {0}")]
284    Limits(String),
285    #[error("provenance invalid: {0}")]
286    Provenance(String),
287}
288
289#[derive(Debug, serde::Deserialize)]
290struct RawManifest {
291    id: String,
292    name: String,
293    version: String,
294    world: String,
295    #[serde(default)]
296    supports: Vec<FlowKind>,
297    #[serde(default)]
298    capabilities: Capabilities,
299    #[serde(default)]
300    secret_requirements: Vec<SecretRequirement>,
301    #[serde(default)]
302    profiles: ComponentProfiles,
303    #[serde(default)]
304    configurators: Option<ComponentConfigurators>,
305    #[serde(default)]
306    limits: Option<Limits>,
307    #[serde(default)]
308    telemetry: Option<TelemetrySpec>,
309    describe_export: String,
310    operations: Vec<ComponentOperation>,
311    #[serde(default)]
312    default_operation: Option<String>,
313    #[serde(default)]
314    provenance: Option<Provenance>,
315    artifacts: RawArtifacts,
316    hashes: RawHashes,
317}
318
319impl TryFrom<RawManifest> for ComponentManifest {
320    type Error = ManifestError;
321
322    fn try_from(raw: RawManifest) -> Result<Self, Self::Error> {
323        if raw.name.trim().is_empty() {
324            return Err(ManifestError::EmptyField("name"));
325        }
326
327        let id = ManifestId::parse(raw.id)?;
328        let world = World::parse(raw.world)?;
329        let version =
330            Version::parse(&raw.version).map_err(|source| ManifestError::InvalidVersion {
331                version: raw.version,
332                source,
333            })?;
334        let describe_export = DescribeExport::parse(raw.describe_export)?;
335        let artifacts = Artifacts::try_from(raw.artifacts)?;
336        let hashes = Hashes::try_from(raw.hashes)?;
337
338        if raw.supports.is_empty() {
339            return Err(ManifestError::MissingSupports);
340        }
341
342        validate_profiles(&raw.profiles)?;
343
344        if let Some(configurators) = &raw.configurators {
345            validate_configurators(configurators)?;
346        }
347
348        validate_capabilities(&raw.capabilities)
349            .map_err(|err| ManifestError::Capability(err.to_string()))?;
350
351        validate_secret_requirements(&raw.secret_requirements)?;
352
353        if let Some(limits) = &raw.limits {
354            limits
355                .validate()
356                .map_err(|err| ManifestError::Limits(err.to_string()))?;
357        }
358
359        if let Some(provenance) = &raw.provenance {
360            provenance
361                .validate()
362                .map_err(|err| ManifestError::Provenance(err.to_string()))?;
363        }
364
365        if raw.operations.is_empty() {
366            return Err(ManifestError::MissingOperations);
367        }
368        let mut seen_operations = HashSet::new();
369        for operation in &raw.operations {
370            if !seen_operations.insert(&operation.name) {
371                return Err(ManifestError::DuplicateOperation(operation.name.clone()));
372            }
373            if !OPERATION_PATTERN.is_match(&operation.name) {
374                return Err(ManifestError::InvalidOperation {
375                    operation: operation.name.clone(),
376                });
377            }
378        }
379        if let Some(default_operation) = &raw.default_operation
380            && !raw
381                .operations
382                .iter()
383                .any(|op| op.name == *default_operation)
384        {
385            return Err(ManifestError::InvalidDefaultOperation {
386                operation: default_operation.clone(),
387            });
388        }
389
390        Ok(Self {
391            id,
392            name: raw.name,
393            version,
394            world,
395            supports: raw.supports,
396            capabilities: raw.capabilities,
397            secret_requirements: raw.secret_requirements,
398            profiles: raw.profiles,
399            configurators: raw.configurators,
400            limits: raw.limits,
401            telemetry: raw.telemetry,
402            describe_export,
403            operations: raw.operations,
404            default_operation: raw.default_operation,
405            provenance: raw.provenance,
406            artifacts,
407            hashes,
408        })
409    }
410}
411
412#[derive(Debug, serde::Deserialize)]
413struct RawArtifacts {
414    component_wasm: String,
415}
416
417impl TryFrom<RawArtifacts> for Artifacts {
418    type Error = ManifestError;
419
420    fn try_from(value: RawArtifacts) -> Result<Self, Self::Error> {
421        ensure_relative(&value.component_wasm)?;
422        Ok(Artifacts {
423            component_wasm: PathBuf::from(value.component_wasm),
424        })
425    }
426}
427
428#[derive(Debug, serde::Deserialize)]
429struct RawHashes {
430    component_wasm: String,
431}
432
433impl TryFrom<RawHashes> for Hashes {
434    type Error = ManifestError;
435
436    fn try_from(value: RawHashes) -> Result<Self, Self::Error> {
437        Ok(Hashes {
438            component_wasm: WasmHash::parse(value.component_wasm)?,
439        })
440    }
441}
442
443fn ensure_relative(path: &str) -> Result<(), ManifestError> {
444    let path_buf = PathBuf::from(path);
445    if path_buf.is_absolute() {
446        return Err(ManifestError::InvalidArtifactPath {
447            path: path.to_string(),
448        });
449    }
450    if matches!(path_buf.components().next(), Some(Component::Prefix(_))) {
451        return Err(ManifestError::InvalidArtifactPath {
452            path: path.to_string(),
453        });
454    }
455    Ok(())
456}
457
458fn validate_secret_requirements(requirements: &[SecretRequirement]) -> Result<(), ManifestError> {
459    let mut seen = std::collections::HashSet::new();
460    for req in requirements {
461        if !seen.insert(req.key.as_str().to_string()) {
462            return Err(ManifestError::DuplicateSecretRequirement(
463                req.key.as_str().to_string(),
464            ));
465        }
466
467        SecretKey::new(req.key.as_str()).map_err(|err| {
468            ManifestError::InvalidSecretRequirement {
469                key: req.key.as_str().to_string(),
470                reason: err.to_string(),
471            }
472        })?;
473
474        let scope = req
475            .scope
476            .as_ref()
477            .ok_or_else(|| ManifestError::InvalidSecretRequirement {
478                key: req.key.as_str().to_string(),
479                reason: "scope must include env and tenant".into(),
480            })?;
481
482        if scope.env.trim().is_empty() {
483            return Err(ManifestError::InvalidSecretRequirement {
484                key: req.key.as_str().to_string(),
485                reason: "scope.env must not be empty".into(),
486            });
487        }
488        if scope.tenant.trim().is_empty() {
489            return Err(ManifestError::InvalidSecretRequirement {
490                key: req.key.as_str().to_string(),
491                reason: "scope.tenant must not be empty".into(),
492            });
493        }
494        if let Some(team) = &scope.team
495            && team.trim().is_empty()
496        {
497            return Err(ManifestError::InvalidSecretRequirement {
498                key: req.key.as_str().to_string(),
499                reason: "scope.team must not be empty when provided".into(),
500            });
501        }
502
503        if req.format.is_none() {
504            return Err(ManifestError::InvalidSecretRequirement {
505                key: req.key.as_str().to_string(),
506                reason: "format must be specified".into(),
507            });
508        }
509
510        if let Some(schema) = &req.schema
511            && !schema.is_object()
512        {
513            return Err(ManifestError::InvalidSecretRequirement {
514                key: req.key.as_str().to_string(),
515                reason: "schema must be an object when provided".into(),
516            });
517        }
518    }
519    Ok(())
520}
521
522fn validate_profiles(profiles: &ComponentProfiles) -> Result<(), ManifestError> {
523    if profiles.supported.is_empty() {
524        return Err(ManifestError::MissingProfiles);
525    }
526    if let Some(default) = &profiles.default
527        && !profiles.supported.iter().any(|entry| entry == default)
528    {
529        return Err(ManifestError::InvalidProfileDefault {
530            default: default.clone(),
531        });
532    }
533    Ok(())
534}
535
536fn validate_configurators(_configurators: &ComponentConfigurators) -> Result<(), ManifestError> {
537    // Flow identifiers are validated by greentic-types, so no additional checks are required.
538    Ok(())
539}