Skip to main content

arete_artifacts/
manifest.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use arete_hash::{hash_jcs, HashId, LiveSpec, ProgramSpec, StackManifest};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::{
8    json_error, reject_private_fields, validate_envelope_version, validate_kind, ArtifactError,
9    LiveSpecArtifactV2, ProgramSpecArtifact, ARTIFACT_VERSION_V1, STACK_MANIFEST_KIND,
10    STACK_MANIFEST_SCHEMA_V2,
11};
12
13#[derive(Debug, Clone)]
14pub struct ResolvedLiveSpecV2<'a> {
15    pub alias: String,
16    pub artifact: &'a LiveSpecArtifactV2,
17    pub program_specs: Vec<&'a ProgramSpecArtifact>,
18    pub selected_views: Vec<String>,
19}
20
21#[derive(Debug, Clone)]
22pub struct ResolvedStackCompositionV2<'a> {
23    /// ProgramSpecs in portable manifest order.
24    pub program_specs: Vec<&'a ProgramSpecArtifact>,
25    /// LiveSpecs in portable manifest alias order.
26    pub live_specs: Vec<ResolvedLiveSpecV2<'a>>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct ProgramSpecReferenceV2 {
32    pub program_id: String,
33    pub artifact_hash: HashId<ProgramSpec>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "camelCase", deny_unknown_fields)]
38pub struct LiveSpecReferenceV2 {
39    pub alias: String,
40    pub artifact_hash: HashId<LiveSpec>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "camelCase", deny_unknown_fields)]
45pub struct SelectedViewV2 {
46    pub live_alias: String,
47    pub view_id: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct StackManifestV2 {
53    pub schema: String,
54    pub name: String,
55    pub programs: Vec<ProgramSpecReferenceV2>,
56    pub live_specs: Vec<LiveSpecReferenceV2>,
57    pub selected_views: Vec<SelectedViewV2>,
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub queries: Vec<Value>,
60    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
61    pub extensions: BTreeMap<String, Value>,
62    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
63    pub metadata: BTreeMap<String, Value>,
64}
65
66impl StackManifestV2 {
67    pub fn new(
68        name: impl Into<String>,
69        programs: Vec<ProgramSpecReferenceV2>,
70        live_specs: Vec<LiveSpecReferenceV2>,
71        selected_views: Vec<SelectedViewV2>,
72    ) -> Self {
73        Self {
74            schema: STACK_MANIFEST_SCHEMA_V2.to_string(),
75            name: name.into(),
76            programs,
77            live_specs,
78            selected_views,
79            queries: Vec::new(),
80            extensions: BTreeMap::new(),
81            metadata: BTreeMap::new(),
82        }
83    }
84
85    pub fn validate(&self) -> Result<(), ArtifactError> {
86        if self.schema != STACK_MANIFEST_SCHEMA_V2 {
87            return Err(ArtifactError::UnsupportedVersion {
88                artifact: STACK_MANIFEST_KIND,
89                version: self.schema.clone(),
90            });
91        }
92        if self.name.is_empty() {
93            return Err(ArtifactError::InvalidArtifact(
94                "stack manifest name must not be empty".to_string(),
95            ));
96        }
97        let mut program_hashes = BTreeSet::new();
98        for program in &self.programs {
99            if program.program_id.is_empty()
100                || !program_hashes.insert(program.artifact_hash.to_string())
101            {
102                return Err(ArtifactError::InvalidArtifact(
103                    "manifest ProgramSpec references must have unique hashes and non-empty program IDs"
104                        .to_string(),
105                ));
106            }
107        }
108        let mut live_aliases = BTreeSet::new();
109        for live in &self.live_specs {
110            if !is_portable_alias(&live.alias) || !live_aliases.insert(live.alias.as_str()) {
111                return Err(ArtifactError::InvalidArtifact(
112                    "LiveSpec references must have unique portable aliases".to_string(),
113                ));
114            }
115        }
116        let mut selected = BTreeSet::new();
117        for view in &self.selected_views {
118            if !live_aliases.contains(view.live_alias.as_str())
119                || view.view_id.is_empty()
120                || !selected.insert((view.live_alias.as_str(), view.view_id.as_str()))
121            {
122                return Err(ArtifactError::InvalidArtifact(
123                    "selected views must uniquely reference a declared LiveSpec alias".to_string(),
124                ));
125            }
126        }
127        reject_private_fields(&serde_json::to_value(self).map_err(json_error)?)
128    }
129
130    /// Validate the selected-view allowlist against exact aliased LiveSpec inputs.
131    pub fn validate_selected_views<'a>(
132        &self,
133        live_specs: impl IntoIterator<Item = (&'a str, &'a LiveSpecArtifactV2)>,
134    ) -> Result<(), ArtifactError> {
135        self.validate()?;
136        let mut supplied = BTreeMap::new();
137        for (alias, live) in live_specs {
138            if supplied.insert(alias, live).is_some() {
139                return Err(ArtifactError::InvalidArtifact(format!(
140                    "LiveSpec alias '{alias}' was supplied more than once"
141                )));
142            }
143        }
144        if supplied.len() != self.live_specs.len() {
145            return Err(ArtifactError::InvalidArtifact(
146                "exactly the manifest's aliased LiveSpecs must be supplied".to_string(),
147            ));
148        }
149        for reference in &self.live_specs {
150            let live = supplied.get(reference.alias.as_str()).ok_or_else(|| {
151                ArtifactError::InvalidArtifact(format!(
152                    "missing LiveSpec alias '{}'",
153                    reference.alias
154                ))
155            })?;
156            live.validate()?;
157            if live.artifact_hash != reference.artifact_hash {
158                return Err(ArtifactError::InvalidArtifact(format!(
159                    "LiveSpec alias '{}' has the wrong artifact hash",
160                    reference.alias
161                )));
162            }
163        }
164        for selected in &self.selected_views {
165            let live = supplied[selected.live_alias.as_str()];
166            if selected_view_owner(live, &selected.view_id).is_none() {
167                return Err(ArtifactError::InvalidArtifact(format!(
168                    "selected view '{}:{}' does not exist",
169                    selected.live_alias, selected.view_id
170                )));
171            }
172        }
173        validate_client_names(
174            self.live_specs
175                .iter()
176                .map(|reference| (reference.alias.as_str(), supplied[reference.alias.as_str()])),
177            &self.selected_views,
178        )
179    }
180}
181
182/// Validate and resolve one portable V2 composition. Supplied LiveSpecs are
183/// positional because alias order is part of the manifest contract; supplied
184/// ProgramSpecs are resolved by typed hash and may be in any filesystem order.
185pub fn resolve_stack_composition_v2<'a>(
186    manifest: &StackManifestArtifactV2,
187    live_specs: &'a [(String, LiveSpecArtifactV2)],
188    program_specs: &'a [ProgramSpecArtifact],
189) -> Result<ResolvedStackCompositionV2<'a>, ArtifactError> {
190    manifest.validate()?;
191    if live_specs.len() != manifest.payload.live_specs.len() {
192        return Err(ArtifactError::InvalidArtifact(
193            "exactly the manifest's ordered aliased LiveSpecs must be supplied".to_string(),
194        ));
195    }
196
197    for ((alias, live), reference) in live_specs.iter().zip(&manifest.payload.live_specs) {
198        live.validate()?;
199        if alias != &reference.alias || live.artifact_hash != reference.artifact_hash {
200            return Err(ArtifactError::InvalidArtifact(format!(
201                "LiveSpec at manifest position for alias '{}' must have exact alias and hash",
202                reference.alias
203            )));
204        }
205    }
206
207    manifest.payload.validate_selected_views(
208        live_specs
209            .iter()
210            .map(|(alias, live)| (alias.as_str(), live)),
211    )?;
212
213    let mut supplied_programs = BTreeMap::new();
214    for program in program_specs {
215        program.validate()?;
216        if supplied_programs
217            .insert(program.artifact_hash.to_string(), program)
218            .is_some()
219        {
220            return Err(ArtifactError::InvalidArtifact(format!(
221                "ProgramSpec {} was supplied more than once",
222                program.artifact_hash
223            )));
224        }
225    }
226
227    let mut required_programs = BTreeMap::<String, String>::new();
228    for (_, live) in live_specs {
229        for requirement in &live.payload.programs {
230            let hash = requirement.program_spec_hash.to_string();
231            if let Some(existing) =
232                required_programs.insert(hash.clone(), requirement.program_id.clone())
233            {
234                if existing != requirement.program_id {
235                    return Err(ArtifactError::InvalidArtifact(format!(
236                        "ProgramSpec {hash} is required with conflicting program IDs '{existing}' and '{}'",
237                        requirement.program_id
238                    )));
239                }
240            }
241        }
242    }
243
244    let manifest_programs = manifest
245        .payload
246        .programs
247        .iter()
248        .map(|reference| (reference.artifact_hash.to_string(), reference))
249        .collect::<BTreeMap<_, _>>();
250    for (hash, required_id) in &required_programs {
251        let reference = manifest_programs.get(hash).ok_or_else(|| {
252            ArtifactError::InvalidArtifact(format!(
253                "StackManifest is missing LiveSpec-required ProgramSpec {hash}"
254            ))
255        })?;
256        if required_id != &reference.program_id {
257            return Err(ArtifactError::InvalidArtifact(format!(
258                "StackManifest ProgramSpec {hash} has program ID '{}', not '{required_id}'",
259                reference.program_id
260            )));
261        }
262    }
263
264    if supplied_programs.len() != manifest.payload.programs.len() {
265        return Err(ArtifactError::InvalidArtifact(
266            "supplied ProgramSpecs must exactly match the StackManifest".to_string(),
267        ));
268    }
269    let ordered_programs = manifest
270        .payload
271        .programs
272        .iter()
273        .map(|reference| {
274            let hash = reference.artifact_hash.to_string();
275            let program = supplied_programs.get(&hash).copied().ok_or_else(|| {
276                ArtifactError::InvalidArtifact(format!("missing required ProgramSpec {hash}"))
277            })?;
278            if program.payload.program_id != reference.program_id {
279                return Err(ArtifactError::InvalidArtifact(format!(
280                    "ProgramSpec {hash} has program ID '{}', not '{}'",
281                    program.payload.program_id, reference.program_id
282                )));
283            }
284            Ok(program)
285        })
286        .collect::<Result<Vec<_>, ArtifactError>>()?;
287
288    let resolved_lives = live_specs
289        .iter()
290        .map(|(alias, live)| {
291            let programs = live
292                .payload
293                .programs
294                .iter()
295                .map(|requirement| {
296                    supplied_programs
297                        .get(&requirement.program_spec_hash.to_string())
298                        .copied()
299                        .ok_or_else(|| {
300                            ArtifactError::InvalidArtifact(format!(
301                                "LiveSpec alias '{alias}' requires missing ProgramSpec {}",
302                                requirement.program_spec_hash
303                            ))
304                        })
305                })
306                .collect::<Result<Vec<_>, _>>()?;
307            let selected_views = manifest
308                .payload
309                .selected_views
310                .iter()
311                .filter(|selected| selected.live_alias == *alias)
312                .map(|selected| selected.view_id.clone())
313                .collect();
314            Ok(ResolvedLiveSpecV2 {
315                alias: alias.clone(),
316                artifact: live,
317                program_specs: programs,
318                selected_views,
319            })
320        })
321        .collect::<Result<Vec<_>, ArtifactError>>()?;
322
323    Ok(ResolvedStackCompositionV2 {
324        program_specs: ordered_programs,
325        live_specs: resolved_lives,
326    })
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
330#[serde(rename_all = "camelCase", deny_unknown_fields)]
331pub struct StackManifestArtifactV2 {
332    pub artifact_version: String,
333    pub kind: String,
334    pub artifact_hash: HashId<StackManifest>,
335    pub payload: StackManifestV2,
336}
337
338impl StackManifestArtifactV2 {
339    pub fn new(payload: StackManifestV2) -> Result<Self, ArtifactError> {
340        payload.validate()?;
341        let artifact_hash = hash_jcs(&ManifestProjection {
342            artifact_version: ARTIFACT_VERSION_V1,
343            kind: STACK_MANIFEST_KIND,
344            payload: &payload,
345        })?;
346        Ok(Self {
347            artifact_version: ARTIFACT_VERSION_V1.to_string(),
348            kind: STACK_MANIFEST_KIND.to_string(),
349            artifact_hash,
350            payload,
351        })
352    }
353
354    pub fn validate(&self) -> Result<(), ArtifactError> {
355        validate_envelope_version(&self.artifact_version, STACK_MANIFEST_KIND)?;
356        validate_kind(&self.kind, STACK_MANIFEST_KIND)?;
357        self.payload.validate()?;
358        let expected = hash_jcs(&ManifestProjection {
359            artifact_version: &self.artifact_version,
360            kind: STACK_MANIFEST_KIND,
361            payload: &self.payload,
362        })?;
363        if expected != self.artifact_hash {
364            return Err(ArtifactError::HashMismatch);
365        }
366        Ok(())
367    }
368
369    pub fn canonical_bytes(&self) -> Result<Vec<u8>, ArtifactError> {
370        self.validate()?;
371        arete_hash::canonicalize_jcs(self).map_err(Into::into)
372    }
373}
374
375#[derive(Serialize)]
376#[serde(rename_all = "camelCase")]
377struct ManifestProjection<'a> {
378    artifact_version: &'a str,
379    kind: &'static str,
380    payload: &'a StackManifestV2,
381}
382
383pub fn load_stack_manifest_v2(
384    bytes: &[u8],
385) -> Result<crate::LoadedArtifact<StackManifestArtifactV2>, ArtifactError> {
386    let value = arete_hash::parse_json_bytes_strict(bytes)?;
387    let artifact: StackManifestArtifactV2 = serde_json::from_value(value).map_err(json_error)?;
388    artifact.validate()?;
389    Ok(crate::LoadedArtifact {
390        artifact,
391        original_bytes: bytes.to_vec(),
392        source_hash: arete_hash::hash_raw_bytes(bytes)?,
393    })
394}
395
396fn is_portable_alias(alias: &str) -> bool {
397    !alias.is_empty()
398        && alias.len() <= 64
399        && alias
400            .chars()
401            .any(|character| character.is_ascii_alphanumeric())
402        && alias
403            .chars()
404            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
405}
406
407/// Comparison key shared by TypeScript property and Rust identifier generation.
408/// It intentionally erases case and separators so collisions fail before codegen.
409pub fn normalized_client_name(value: &str) -> String {
410    value
411        .chars()
412        .filter(|character| character.is_ascii_alphanumeric())
413        .map(|character| character.to_ascii_lowercase())
414        .collect()
415}
416
417fn selected_view_owner<'a>(
418    live: &'a LiveSpecArtifactV2,
419    view_id: &str,
420) -> Option<(&'a crate::PortableEntity, &'a crate::PortableView)> {
421    live.payload.entities.iter().find_map(|entity| {
422        entity
423            .views
424            .iter()
425            .find(|view| view.id == view_id)
426            .map(|view| (entity, view))
427    })
428}
429
430fn validate_client_names<'a>(
431    live_specs: impl IntoIterator<Item = (&'a str, &'a LiveSpecArtifactV2)>,
432    selected_views: &[SelectedViewV2],
433) -> Result<(), ArtifactError> {
434    let lives = live_specs.into_iter().collect::<Vec<_>>();
435    reject_normalized_collisions("LiveSpec alias", lives.iter().map(|(alias, _)| *alias))?;
436
437    for (alias, live) in lives {
438        reject_normalized_collisions(
439            &format!("entity in LiveSpec alias '{alias}'"),
440            live.payload
441                .entities
442                .iter()
443                .map(|entity| entity.state_name.as_str()),
444        )?;
445
446        let selected = selected_views
447            .iter()
448            .filter(|selected| selected.live_alias == alias)
449            .collect::<Vec<_>>();
450        for entity in &live.payload.entities {
451            let prefix = format!("{}/", entity.state_name);
452            let members = selected
453                .iter()
454                .filter_map(|selected| {
455                    selected_view_owner(live, &selected.view_id)
456                        .filter(|(owner, _)| owner.state_name == entity.state_name)
457                        .map(|_| selected.view_id.as_str())
458                })
459                .map(|view_id| {
460                    let member = view_id.strip_prefix(&prefix).unwrap_or_default();
461                    if member.is_empty() || member.contains('/') {
462                        Err(ArtifactError::InvalidArtifact(format!(
463                            "selected view '{alias}:{view_id}' cannot be represented as a client member"
464                        )))
465                    } else {
466                        Ok(member)
467                    }
468                })
469                .collect::<Result<Vec<_>, _>>()?;
470            reject_normalized_collisions(
471                &format!("view member for '{}:{}'", alias, entity.state_name),
472                members,
473            )?;
474        }
475    }
476    Ok(())
477}
478
479fn reject_normalized_collisions<'a>(
480    kind: &str,
481    names: impl IntoIterator<Item = &'a str>,
482) -> Result<(), ArtifactError> {
483    let mut normalized = BTreeMap::<String, &str>::new();
484    for name in names {
485        let key = normalized_client_name(name);
486        if key.is_empty() {
487            return Err(ArtifactError::InvalidArtifact(format!(
488                "{kind} '{name}' has no language-safe identifier"
489            )));
490        }
491        if let Some(existing) = normalized.insert(key.clone(), name) {
492            return Err(ArtifactError::InvalidArtifact(format!(
493                "{kind} names '{existing}' and '{name}' collide after language-safe normalization as '{key}'"
494            )));
495        }
496    }
497    Ok(())
498}