greentic_component/manifest/
mod.rs

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