1mod authoring;
9mod live;
10mod manifest;
11
12pub use authoring::*;
13pub use live::*;
14pub use manifest::*;
15
16use std::collections::{BTreeMap, BTreeSet};
17
18use arete_hash::{
19 canonicalize_jcs, hash_jcs, hash_raw_bytes, parse_json_bytes_strict, ArtifactFile, HashError,
20 HashId, LiveSpec, ProgramSpec, ProgramSpecV1, StackManifest,
21};
22use serde::{de::DeserializeOwned, Deserialize, Serialize};
23use serde_json::{json, Map, Value};
24use thiserror::Error;
25
26pub const ARTIFACT_VERSION_V1: &str = "1.0.0";
27pub const PROGRAM_SPEC_KIND: &str = "program-spec";
28pub const LIVE_SPEC_KIND: &str = "live-spec";
29pub const STACK_MANIFEST_KIND: &str = "stack-manifest";
30pub const LIVE_SPEC_SCHEMA_V1: &str = "arete.live-spec/v1";
31pub const STACK_MANIFEST_SCHEMA_V1: &str = "arete.stack-manifest/v1";
32pub const LIVE_SPEC_SCHEMA_V2: &str = "arete.live-spec/v2";
33pub const STACK_MANIFEST_SCHEMA_V2: &str = "arete.stack-manifest/v2";
34pub const LEGACY_NORMALIZER_CONTRACT_V1: &str = "arete.legacy-stack-normalizer/v1";
35pub const LIVE_COMPILER_CONTRACT_V1: &str = "arete-live-compiler/v1";
36pub const LIVE_WIRE_CONTRACT_V1: &str = "arete-live-wire/v1";
37
38pub const CURRENT_AST_VERSION: &str = "0.0.5";
39pub const COMPATIBLE_AST_VERSIONS: &[&str] = &["0.0.1", "0.0.2", "0.0.3", "0.0.4"];
40
41#[derive(Debug, Error)]
42pub enum ArtifactError {
43 #[error(transparent)]
44 Hash(#[from] HashError),
45 #[error("invalid artifact JSON: {0}")]
46 InvalidJson(String),
47 #[error("unsupported {artifact} version '{version}'")]
48 UnsupportedVersion {
49 artifact: &'static str,
50 version: String,
51 },
52 #[error("artifact kind must be '{expected}', not '{actual}'")]
53 WrongKind {
54 expected: &'static str,
55 actual: String,
56 },
57 #[error("artifact hash does not match its payload")]
58 HashMismatch,
59 #[error("invalid legacy stack: {0}")]
60 InvalidLegacyStack(String),
61 #[error("invalid artifact: {0}")]
62 InvalidArtifact(String),
63 #[error("public artifact contains private field '{0}'")]
64 PrivateField(String),
65 #[error("artifact I/O failed: {0}")]
66 Io(#[from] std::io::Error),
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase", deny_unknown_fields)]
71pub struct ProgramSpecArtifact {
72 pub artifact_version: String,
73 pub kind: String,
74 pub artifact_hash: HashId<ProgramSpec>,
75 pub payload: ProgramSpecV1,
76}
77
78impl ProgramSpecArtifact {
79 pub fn new(payload: ProgramSpecV1) -> Result<Self, ArtifactError> {
80 let artifact_hash = payload.hash()?;
81 Ok(Self {
82 artifact_version: ARTIFACT_VERSION_V1.to_string(),
83 kind: PROGRAM_SPEC_KIND.to_string(),
84 artifact_hash,
85 payload,
86 })
87 }
88
89 pub fn validate(&self) -> Result<(), ArtifactError> {
90 validate_envelope_version(&self.artifact_version, PROGRAM_SPEC_KIND)?;
91 validate_kind(&self.kind, PROGRAM_SPEC_KIND)?;
92 reject_private_fields(&serde_json::to_value(self).map_err(json_error)?)?;
93 if self.payload.hash()? != self.artifact_hash {
94 return Err(ArtifactError::HashMismatch);
95 }
96 Ok(())
97 }
98
99 pub fn canonical_bytes(&self) -> Result<Vec<u8>, ArtifactError> {
100 self.validate()?;
101 canonicalize_jcs(self).map_err(Into::into)
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase", deny_unknown_fields)]
107pub struct ProgramRequirementV1 {
108 pub program_id: String,
109 pub program_spec_hash: HashId<ProgramSpec>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase", deny_unknown_fields)]
114pub struct LegacyProgramExtensionsV1 {
115 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
116 pub pdas: BTreeMap<String, Value>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub instructions: Vec<Value>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase", deny_unknown_fields)]
123pub struct LiveSpecV1 {
124 pub schema: String,
125 pub compiler_contract_version: String,
126 pub wire_contract_version: String,
127 pub programs: Vec<ProgramRequirementV1>,
128 pub entities: Vec<Value>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub legacy_program_extensions: Option<LegacyProgramExtensionsV1>,
131}
132
133impl LiveSpecV1 {
134 pub fn validate(&self) -> Result<(), ArtifactError> {
135 if self.schema != LIVE_SPEC_SCHEMA_V1 {
136 return Err(ArtifactError::UnsupportedVersion {
137 artifact: LIVE_SPEC_KIND,
138 version: self.schema.clone(),
139 });
140 }
141 if self.compiler_contract_version.is_empty() || self.wire_contract_version.is_empty() {
142 return Err(ArtifactError::InvalidLegacyStack(
143 "live compiler and wire contract versions must not be empty".to_string(),
144 ));
145 }
146 let mut program_hashes = BTreeSet::new();
147 for program in &self.programs {
148 if program.program_id.is_empty()
149 || !program_hashes.insert(program.program_spec_hash.to_string())
150 {
151 return Err(ArtifactError::InvalidLegacyStack(
152 "live program requirements must have unique hashes and non-empty program IDs"
153 .to_string(),
154 ));
155 }
156 }
157 reject_private_fields(&serde_json::to_value(self).map_err(json_error)?)
158 }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase", deny_unknown_fields)]
163pub struct LiveSpecArtifact {
164 pub artifact_version: String,
165 pub kind: String,
166 pub artifact_hash: HashId<LiveSpec>,
167 pub payload: LiveSpecV1,
168}
169
170impl LiveSpecArtifact {
171 pub fn new(payload: LiveSpecV1) -> Result<Self, ArtifactError> {
172 payload.validate()?;
173 let artifact_hash = hash_live_projection(ARTIFACT_VERSION_V1, &payload)?;
174 Ok(Self {
175 artifact_version: ARTIFACT_VERSION_V1.to_string(),
176 kind: LIVE_SPEC_KIND.to_string(),
177 artifact_hash,
178 payload,
179 })
180 }
181
182 pub fn validate(&self) -> Result<(), ArtifactError> {
183 validate_envelope_version(&self.artifact_version, LIVE_SPEC_KIND)?;
184 validate_kind(&self.kind, LIVE_SPEC_KIND)?;
185 self.payload.validate()?;
186 reject_private_fields(&serde_json::to_value(self).map_err(json_error)?)?;
187 let expected = hash_live_projection(&self.artifact_version, &self.payload)?;
188 if expected != self.artifact_hash {
189 return Err(ArtifactError::HashMismatch);
190 }
191 Ok(())
192 }
193
194 pub fn canonical_bytes(&self) -> Result<Vec<u8>, ArtifactError> {
195 self.validate()?;
196 canonicalize_jcs(self).map_err(Into::into)
197 }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase", deny_unknown_fields)]
202pub struct ProgramSpecReferenceV1 {
203 pub program_id: String,
204 pub artifact_hash: HashId<ProgramSpec>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase", deny_unknown_fields)]
209pub struct LiveSpecReferenceV1 {
210 pub artifact_hash: HashId<LiveSpec>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[serde(rename_all = "camelCase", deny_unknown_fields)]
215pub struct SelectedViewV1 {
216 pub live_spec_hash: HashId<LiveSpec>,
217 pub view_id: String,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
221#[serde(rename_all = "camelCase", deny_unknown_fields)]
222pub struct StackManifestV1 {
223 pub schema: String,
224 pub name: String,
225 pub programs: Vec<ProgramSpecReferenceV1>,
226 pub live_specs: Vec<LiveSpecReferenceV1>,
227 pub selected_views: Vec<SelectedViewV1>,
228 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub queries: Vec<Value>,
230 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
231 pub extensions: BTreeMap<String, Value>,
232 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
233 pub metadata: BTreeMap<String, Value>,
234}
235
236impl StackManifestV1 {
237 pub fn validate(&self) -> Result<(), ArtifactError> {
238 if self.schema != STACK_MANIFEST_SCHEMA_V1 {
239 return Err(ArtifactError::UnsupportedVersion {
240 artifact: STACK_MANIFEST_KIND,
241 version: self.schema.clone(),
242 });
243 }
244 if self.name.is_empty() {
245 return Err(ArtifactError::InvalidLegacyStack(
246 "stack manifest name must not be empty".to_string(),
247 ));
248 }
249 reject_private_fields(&serde_json::to_value(self).map_err(json_error)?)
250 }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase", deny_unknown_fields)]
255pub struct StackManifestArtifact {
256 pub artifact_version: String,
257 pub kind: String,
258 pub artifact_hash: HashId<StackManifest>,
259 pub payload: StackManifestV1,
260}
261
262impl StackManifestArtifact {
263 pub fn new(payload: StackManifestV1) -> Result<Self, ArtifactError> {
264 payload.validate()?;
265 let artifact_hash = hash_manifest_projection(ARTIFACT_VERSION_V1, &payload)?;
266 Ok(Self {
267 artifact_version: ARTIFACT_VERSION_V1.to_string(),
268 kind: STACK_MANIFEST_KIND.to_string(),
269 artifact_hash,
270 payload,
271 })
272 }
273
274 pub fn validate(&self) -> Result<(), ArtifactError> {
275 validate_envelope_version(&self.artifact_version, STACK_MANIFEST_KIND)?;
276 validate_kind(&self.kind, STACK_MANIFEST_KIND)?;
277 self.payload.validate()?;
278 reject_private_fields(&serde_json::to_value(self).map_err(json_error)?)?;
279 let expected = hash_manifest_projection(&self.artifact_version, &self.payload)?;
280 if expected != self.artifact_hash {
281 return Err(ArtifactError::HashMismatch);
282 }
283 Ok(())
284 }
285
286 pub fn canonical_bytes(&self) -> Result<Vec<u8>, ArtifactError> {
287 self.validate()?;
288 canonicalize_jcs(self).map_err(Into::into)
289 }
290}
291
292#[derive(Debug, Clone)]
293pub struct LoadedArtifact<A> {
294 pub artifact: A,
295 pub original_bytes: Vec<u8>,
296 pub source_hash: HashId<ArtifactFile>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct LegacySourceProvenance {
302 pub source_hash: HashId<ArtifactFile>,
303 pub declared_ast_version: Option<String>,
304 pub normalized_ast_version: String,
305 pub legacy_content_hash: Option<String>,
306 pub normalizer_contract_version: String,
307}
308
309#[derive(Debug, Clone)]
310pub struct LegacyDecomposition {
311 pub source: LegacySourceProvenance,
312 pub program_specs: Vec<ProgramSpecArtifact>,
313 pub live_spec: LiveSpecArtifact,
314 pub stack_manifest: StackManifestArtifact,
315}
316
317pub fn load_program_spec(
318 bytes: &[u8],
319) -> Result<LoadedArtifact<ProgramSpecArtifact>, ArtifactError> {
320 load_artifact(bytes, ProgramSpecArtifact::validate)
321}
322
323pub fn load_live_spec(bytes: &[u8]) -> Result<LoadedArtifact<LiveSpecArtifact>, ArtifactError> {
324 load_artifact(bytes, LiveSpecArtifact::validate)
325}
326
327pub fn load_stack_manifest(
328 bytes: &[u8],
329) -> Result<LoadedArtifact<StackManifestArtifact>, ArtifactError> {
330 load_artifact(bytes, StackManifestArtifact::validate)
331}
332
333pub fn load_legacy_stack_value(bytes: &[u8]) -> Result<LoadedArtifact<Value>, ArtifactError> {
334 let mut value = parse_json_bytes_strict(bytes)?;
335 reject_private_fields(&value)?;
336 let object = value.as_object_mut().ok_or_else(|| {
337 ArtifactError::InvalidLegacyStack("top-level value must be an object".to_string())
338 })?;
339 let declared_ast_version = object
340 .get("ast_version")
341 .and_then(Value::as_str)
342 .unwrap_or("0.0.1");
343 if declared_ast_version != CURRENT_AST_VERSION
344 && !COMPATIBLE_AST_VERSIONS.contains(&declared_ast_version)
345 {
346 return Err(ArtifactError::UnsupportedVersion {
347 artifact: "legacy-stack",
348 version: declared_ast_version.to_string(),
349 });
350 }
351 object.insert(
352 "ast_version".to_string(),
353 Value::String(CURRENT_AST_VERSION.to_string()),
354 );
355 Ok(LoadedArtifact {
356 artifact: value,
357 original_bytes: bytes.to_vec(),
358 source_hash: hash_raw_bytes::<ArtifactFile>(bytes)?,
359 })
360}
361
362pub fn decompose_legacy_stack(bytes: &[u8]) -> Result<LegacyDecomposition, ArtifactError> {
367 let loaded = load_legacy_stack_value(bytes)?;
368 let original: Value = parse_json_bytes_strict(bytes)?;
369 let declared_ast_version = original
370 .get("ast_version")
371 .and_then(Value::as_str)
372 .map(str::to_string);
373 let legacy_content_hash = original
374 .get("content_hash")
375 .and_then(Value::as_str)
376 .map(str::to_string);
377 let stack = loaded.artifact.as_object().ok_or_else(|| {
378 ArtifactError::InvalidLegacyStack("top-level value must be an object".to_string())
379 })?;
380 let stack_name = required_string(stack, "stack_name")?;
381 let program_ids = string_array(stack.get("program_ids"), "program_ids")?;
382 let program_specs_value = stack
383 .get("program_specs")
384 .cloned()
385 .unwrap_or_else(|| Value::Array(Vec::new()));
386 let program_spec_payloads: Vec<ProgramSpecV1> =
387 serde_json::from_value(program_specs_value).map_err(json_error)?;
388 if program_ids.len() != program_spec_payloads.len() {
389 return Err(ArtifactError::InvalidLegacyStack(format!(
390 "program_ids has {} entries but program_specs has {}; historical inputs require exact ProgramSpec resolution from retained IDLs",
391 program_ids.len(),
392 program_spec_payloads.len()
393 )));
394 }
395
396 let mut program_specs = Vec::with_capacity(program_spec_payloads.len());
397 for (program_id, payload) in program_ids.iter().zip(program_spec_payloads) {
398 if payload.program_id != *program_id {
399 return Err(ArtifactError::InvalidLegacyStack(format!(
400 "ProgramSpec program ID '{}' does not match ordered program ID '{}'",
401 payload.program_id, program_id
402 )));
403 }
404 program_specs.push(ProgramSpecArtifact::new(payload)?);
405 }
406
407 let mut entities = stack
408 .get("entities")
409 .and_then(Value::as_array)
410 .cloned()
411 .ok_or_else(|| {
412 ArtifactError::InvalidLegacyStack("entities must be an array".to_string())
413 })?;
414 let mut selected_view_ids = Vec::new();
415 for entity in &mut entities {
416 normalize_entity(entity, &mut selected_view_ids)?;
417 }
418
419 let programs = program_specs
420 .iter()
421 .map(|artifact| ProgramRequirementV1 {
422 program_id: artifact.payload.program_id.clone(),
423 program_spec_hash: artifact.artifact_hash,
424 })
425 .collect();
426 let pdas = value_map(stack.get("pdas"), "pdas")?;
427 let instructions = stack
428 .get("instructions")
429 .and_then(Value::as_array)
430 .cloned()
431 .unwrap_or_default();
432 let legacy_program_extensions = if pdas.is_empty() && instructions.is_empty() {
433 None
434 } else {
435 Some(LegacyProgramExtensionsV1 { pdas, instructions })
436 };
437 let live_spec = LiveSpecArtifact::new(LiveSpecV1 {
438 schema: LIVE_SPEC_SCHEMA_V1.to_string(),
439 compiler_contract_version: LIVE_COMPILER_CONTRACT_V1.to_string(),
440 wire_contract_version: LIVE_WIRE_CONTRACT_V1.to_string(),
441 programs,
442 entities,
443 legacy_program_extensions,
444 })?;
445
446 let stack_manifest = StackManifestArtifact::new(StackManifestV1 {
447 schema: STACK_MANIFEST_SCHEMA_V1.to_string(),
448 name: stack_name,
449 programs: program_specs
450 .iter()
451 .map(|artifact| ProgramSpecReferenceV1 {
452 program_id: artifact.payload.program_id.clone(),
453 artifact_hash: artifact.artifact_hash,
454 })
455 .collect(),
456 live_specs: vec![LiveSpecReferenceV1 {
457 artifact_hash: live_spec.artifact_hash,
458 }],
459 selected_views: selected_view_ids
460 .into_iter()
461 .map(|view_id| SelectedViewV1 {
462 live_spec_hash: live_spec.artifact_hash,
463 view_id,
464 })
465 .collect(),
466 queries: Vec::new(),
467 extensions: BTreeMap::new(),
468 metadata: BTreeMap::new(),
469 })?;
470
471 Ok(LegacyDecomposition {
472 source: LegacySourceProvenance {
473 source_hash: loaded.source_hash,
474 declared_ast_version,
475 normalized_ast_version: CURRENT_AST_VERSION.to_string(),
476 legacy_content_hash,
477 normalizer_contract_version: LEGACY_NORMALIZER_CONTRACT_V1.to_string(),
478 },
479 program_specs,
480 live_spec,
481 stack_manifest,
482 })
483}
484
485fn load_artifact<A: DeserializeOwned>(
486 bytes: &[u8],
487 validate: impl FnOnce(&A) -> Result<(), ArtifactError>,
488) -> Result<LoadedArtifact<A>, ArtifactError> {
489 let value = parse_json_bytes_strict(bytes)?;
490 let artifact = serde_json::from_value(value).map_err(json_error)?;
491 validate(&artifact)?;
492 Ok(LoadedArtifact {
493 artifact,
494 original_bytes: bytes.to_vec(),
495 source_hash: hash_raw_bytes::<ArtifactFile>(bytes)?,
496 })
497}
498
499#[derive(Serialize)]
500#[serde(rename_all = "camelCase")]
501struct ArtifactProjection<'a, P> {
502 artifact_version: &'a str,
503 kind: &'static str,
504 payload: &'a P,
505}
506
507fn hash_live_projection(
508 artifact_version: &str,
509 payload: &LiveSpecV1,
510) -> Result<HashId<LiveSpec>, ArtifactError> {
511 hash_jcs(&ArtifactProjection {
512 artifact_version,
513 kind: LIVE_SPEC_KIND,
514 payload,
515 })
516 .map_err(Into::into)
517}
518
519fn hash_manifest_projection(
520 artifact_version: &str,
521 payload: &StackManifestV1,
522) -> Result<HashId<StackManifest>, ArtifactError> {
523 hash_jcs(&ArtifactProjection {
524 artifact_version,
525 kind: STACK_MANIFEST_KIND,
526 payload,
527 })
528 .map_err(Into::into)
529}
530
531pub(crate) fn validate_envelope_version(
532 version: &str,
533 artifact: &'static str,
534) -> Result<(), ArtifactError> {
535 let components = version.split('.').collect::<Vec<_>>();
536 let valid = components.len() == 3
537 && components.iter().all(|component| {
538 !component.is_empty() && component.chars().all(|c| c.is_ascii_digit())
539 });
540 if !valid || components[0] != "1" {
541 return Err(ArtifactError::UnsupportedVersion {
542 artifact,
543 version: version.to_string(),
544 });
545 }
546 Ok(())
547}
548
549pub(crate) fn validate_kind(actual: &str, expected: &'static str) -> Result<(), ArtifactError> {
550 if actual != expected {
551 return Err(ArtifactError::WrongKind {
552 expected,
553 actual: actual.to_string(),
554 });
555 }
556 Ok(())
557}
558
559fn normalize_entity(
560 entity: &mut Value,
561 selected_view_ids: &mut Vec<String>,
562) -> Result<(), ArtifactError> {
563 let object = entity.as_object_mut().ok_or_else(|| {
564 ArtifactError::InvalidLegacyStack("every entity must be an object".to_string())
565 })?;
566 object.remove("ast_version");
567 object.remove("idl");
568 object.remove("content_hash");
569 let state_name = required_string(object, "state_name")?;
570 let primary_keys = object
571 .get("identity")
572 .and_then(Value::as_object)
573 .and_then(|identity| identity.get("primary_keys"))
574 .and_then(Value::as_array)
575 .ok_or_else(|| {
576 ArtifactError::InvalidLegacyStack(format!(
577 "entity '{state_name}' must declare identity.primary_keys"
578 ))
579 })?;
580 let primary_key = primary_keys
581 .first()
582 .and_then(Value::as_str)
583 .map(str::to_string)
584 .ok_or_else(|| {
585 ArtifactError::InvalidLegacyStack(format!(
586 "entity '{state_name}' must declare a string primary key"
587 ))
588 })?;
589 let views = object
590 .entry("views".to_string())
591 .or_insert_with(|| Value::Array(Vec::new()))
592 .as_array_mut()
593 .ok_or_else(|| {
594 ArtifactError::InvalidLegacyStack(format!(
595 "entity '{state_name}' views must be an array"
596 ))
597 })?;
598 let default_views = [
599 json!({
600 "id": format!("{state_name}/state"),
601 "source": { "Entity": { "name": state_name } },
602 "pipeline": [],
603 "output": { "Keyed": { "key_field": {
604 "segments": primary_key.split('.').collect::<Vec<_>>(),
605 "offsets": null
606 } } }
607 }),
608 json!({
609 "id": format!("{state_name}/list"),
610 "source": { "Entity": { "name": state_name } },
611 "pipeline": [],
612 "output": "Collection"
613 }),
614 ];
615 for expected in default_views {
616 let expected_id = expected["id"].as_str().expect("view ID");
617 match views
618 .iter()
619 .find(|view| view.get("id").and_then(Value::as_str) == Some(expected_id))
620 {
621 Some(existing) if existing != &expected => {
622 return Err(ArtifactError::InvalidLegacyStack(format!(
623 "entity '{state_name}' defines conflicting default view '{expected_id}'"
624 )));
625 }
626 Some(_) => {}
627 None => views.push(expected),
628 }
629 }
630 for view in views {
631 let view_id = view.get("id").and_then(Value::as_str).ok_or_else(|| {
632 ArtifactError::InvalidLegacyStack(format!(
633 "entity '{state_name}' contains a view without an ID"
634 ))
635 })?;
636 selected_view_ids.push(view_id.to_string());
637 }
638 Ok(())
639}
640
641fn required_string(object: &Map<String, Value>, field: &str) -> Result<String, ArtifactError> {
642 object
643 .get(field)
644 .and_then(Value::as_str)
645 .filter(|value| !value.is_empty())
646 .map(str::to_string)
647 .ok_or_else(|| {
648 ArtifactError::InvalidLegacyStack(format!("{field} must be a non-empty string"))
649 })
650}
651
652fn string_array(value: Option<&Value>, field: &str) -> Result<Vec<String>, ArtifactError> {
653 let Some(value) = value else {
654 return Ok(Vec::new());
655 };
656 value
657 .as_array()
658 .ok_or_else(|| ArtifactError::InvalidLegacyStack(format!("{field} must be an array")))?
659 .iter()
660 .map(|entry| {
661 entry.as_str().map(str::to_string).ok_or_else(|| {
662 ArtifactError::InvalidLegacyStack(format!("{field} must contain only strings"))
663 })
664 })
665 .collect()
666}
667
668fn value_map(value: Option<&Value>, field: &str) -> Result<BTreeMap<String, Value>, ArtifactError> {
669 let Some(value) = value else {
670 return Ok(BTreeMap::new());
671 };
672 let object = value
673 .as_object()
674 .ok_or_else(|| ArtifactError::InvalidLegacyStack(format!("{field} must be an object")))?;
675 Ok(object
676 .iter()
677 .map(|(key, value)| (key.clone(), value.clone()))
678 .collect())
679}
680
681pub(crate) fn reject_private_fields(value: &Value) -> Result<(), ArtifactError> {
682 const FORBIDDEN: &[&str] = &[
683 "platform_parser",
684 "platformParser",
685 "platform_decoder_bundle",
686 "platformDecoderBundle",
687 "augmented_specs",
688 "augmentedSpecs",
689 "decoder_binding_id",
690 "decoderBindingId",
691 "decoder_content_hash",
692 "decoderContentHash",
693 "artifact_ref",
694 "artifactRef",
695 ];
696 match value {
697 Value::Object(object) => {
698 for (key, nested) in object {
699 if FORBIDDEN.contains(&key.as_str()) {
700 return Err(ArtifactError::PrivateField(key.clone()));
701 }
702 reject_private_fields(nested)?;
703 }
704 }
705 Value::Array(values) => {
706 for nested in values {
707 reject_private_fields(nested)?;
708 }
709 }
710 _ => {}
711 }
712 Ok(())
713}
714
715pub(crate) fn json_error(error: serde_json::Error) -> ArtifactError {
716 ArtifactError::InvalidJson(error.to_string())
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722 use arete_hash::{CanonicalIdlDocument, HashId};
723
724 fn program_spec() -> ProgramSpecV1 {
725 let idl = br#"{
726 "address":"11111111111111111111111111111111",
727 "metadata":{"name":"system","version":"1.0.0","spec":"0.1.0"},
728 "instructions":[],"accounts":[],"types":[],"events":[],"errors":[]
729 }"#;
730 let document = CanonicalIdlDocument::parse(idl, None).expect("canonical IDL");
731 ProgramSpecV1::from_document(&document)
732 }
733
734 fn legacy_stack() -> Vec<u8> {
735 serde_json::to_vec(&json!({
736 "ast_version": "0.0.5",
737 "stack_name": "SystemStack",
738 "program_ids": ["11111111111111111111111111111111"],
739 "idls": [],
740 "program_specs": [program_spec()],
741 "entities": [{
742 "ast_version": "0.0.5",
743 "state_name": "SystemState",
744 "program_id": "11111111111111111111111111111111",
745 "idl": null,
746 "identity": {"primary_keys": ["id.address"], "lookup_indexes": []},
747 "handlers": [],
748 "sections": [],
749 "field_mappings": {},
750 "resolver_hooks": [],
751 "instruction_hooks": [],
752 "resolver_specs": [],
753 "computed_fields": [],
754 "computed_field_specs": [],
755 "content_hash": "legacy-entity-hash",
756 "views": []
757 }],
758 "pdas": {},
759 "instructions": [],
760 "content_hash": "legacy-stack-hash"
761 }))
762 .expect("legacy JSON")
763 }
764
765 #[test]
766 fn program_spec_envelope_preserves_frozen_program_hash() {
767 let payload = program_spec();
768 let expected = payload.hash().expect("ProgramSpec hash");
769 let artifact = ProgramSpecArtifact::new(payload).expect("artifact");
770 assert_eq!(artifact.artifact_hash, expected);
771 artifact.validate().expect("valid artifact");
772 }
773
774 #[test]
775 fn decomposition_is_deterministic_and_adds_explicit_default_views() {
776 let bytes = legacy_stack();
777 let first = decompose_legacy_stack(&bytes).expect("first decomposition");
778 let second = decompose_legacy_stack(&bytes).expect("second decomposition");
779 assert_eq!(
780 first.live_spec.artifact_hash,
781 second.live_spec.artifact_hash
782 );
783 assert_eq!(
784 first.stack_manifest.artifact_hash,
785 second.stack_manifest.artifact_hash
786 );
787 assert_eq!(
788 first.program_specs[0].artifact_hash,
789 program_spec().hash().unwrap()
790 );
791 assert_eq!(first.stack_manifest.payload.selected_views.len(), 2);
792 assert!(first.live_spec.payload.entities[0].get("idl").is_none());
793 assert!(first.live_spec.payload.entities[0]
794 .get("content_hash")
795 .is_none());
796 }
797
798 #[test]
799 fn loaders_reject_unknown_major_wrong_hash_and_private_fields() {
800 let decomposition = decompose_legacy_stack(&legacy_stack()).expect("decomposition");
801 let mut value = serde_json::to_value(&decomposition.live_spec).unwrap();
802 value["artifactVersion"] = Value::String("2.0.0".to_string());
803 assert!(matches!(
804 load_live_spec(&serde_json::to_vec(&value).unwrap()),
805 Err(ArtifactError::UnsupportedVersion { .. })
806 ));
807
808 value["artifactVersion"] = Value::String("1.0.0".to_string());
809 value["artifactHash"] = Value::String(HashId::<LiveSpec>::from_digest([7; 32]).to_string());
810 assert!(matches!(
811 load_live_spec(&serde_json::to_vec(&value).unwrap()),
812 Err(ArtifactError::HashMismatch)
813 ));
814
815 let mut private_stack: Value = serde_json::from_slice(&legacy_stack()).unwrap();
816 private_stack["entities"][0]["decoderBindingId"] = Value::String("private".into());
817 assert!(matches!(
818 decompose_legacy_stack(&serde_json::to_vec(&private_stack).unwrap()),
819 Err(ArtifactError::PrivateField(_))
820 ));
821 }
822
823 #[test]
824 fn exact_input_bytes_are_preserved_for_audit() {
825 let bytes = legacy_stack();
826 let loaded = load_legacy_stack_value(&bytes).expect("legacy source");
827 assert_eq!(loaded.original_bytes, bytes);
828 assert_eq!(
829 loaded.source_hash,
830 hash_raw_bytes::<ArtifactFile>(&loaded.original_bytes).unwrap()
831 );
832 }
833}