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 pub program_specs: Vec<&'a ProgramSpecArtifact>,
25 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 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
182pub 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
407pub 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}