1use std::collections::{BTreeMap, BTreeSet};
2use std::fs::OpenOptions;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6
7use arete_hash::{InstructionDefinitionV1, LiveSpec, PdaDefinitionV1, ProgramSpec, ProgramSpecV1};
8use serde::de::DeserializeOwned;
9use serde::Serialize;
10
11use crate::{
12 ArtifactError, LegacyDecomposition, LiveSpecArtifact, LiveSpecArtifactV2, LiveSpecReferenceV2,
13 LiveSpecV2, PortableEntity, PortableFieldPath, PortableView, PortableViewOutput,
14 PortableViewSource, ProgramAdapterV2, ProgramRequirementV2, ProgramSpecArtifact,
15 ProgramSpecReferenceV2, SelectedViewV2, StackManifestArtifact, StackManifestArtifactV2,
16 StackManifestV2,
17};
18
19pub const DEFAULT_LIVE_ALIAS: &str = "live";
20
21#[derive(Debug, Clone)]
22pub struct StackAuthoringV2 {
23 pub name: String,
24 pub programs: Vec<ProgramSpecV1>,
25 pub entities: Vec<PortableEntity>,
26 pub pda_overrides: BTreeMap<String, BTreeMap<String, PdaDefinitionV1>>,
27 pub instruction_overrides: Vec<InstructionDefinitionV1>,
28 pub live_alias: String,
29}
30
31impl StackAuthoringV2 {
32 pub fn new(
33 name: impl Into<String>,
34 programs: Vec<ProgramSpecV1>,
35 entities: Vec<PortableEntity>,
36 ) -> Self {
37 Self {
38 name: name.into(),
39 programs,
40 entities,
41 pda_overrides: BTreeMap::new(),
42 instruction_overrides: Vec::new(),
43 live_alias: DEFAULT_LIVE_ALIAS.to_string(),
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
49pub struct AuthoredStackV2 {
50 pub program_specs: Vec<ProgramSpecArtifact>,
51 pub live_spec: Option<LiveSpecArtifactV2>,
52 pub stack_manifest: StackManifestArtifactV2,
53}
54
55#[derive(Debug, Clone)]
56pub struct NormalizedLegacyStackV2 {
57 pub legacy: LegacyDecomposition,
58 pub live_spec: LiveSpecArtifactV2,
59 pub stack_manifest: StackManifestArtifactV2,
60}
61
62pub fn program_spec_v1(payload: ProgramSpecV1) -> Result<ProgramSpecArtifact, ArtifactError> {
63 ProgramSpecArtifact::new(payload)
64}
65
66pub fn default_views(entity: &PortableEntity) -> Vec<PortableView> {
67 let mut views = Vec::new();
68 if let Some(primary_key) = entity.identity.primary_keys.first() {
69 views.push(PortableView {
70 id: format!("{}/state", entity.state_name),
71 source: PortableViewSource::Entity {
72 name: entity.state_name.clone(),
73 },
74 pipeline: Vec::new(),
75 output: PortableViewOutput::Keyed {
76 key_field: PortableFieldPath::new(primary_key.split('.')),
77 },
78 });
79 }
80 views.push(PortableView {
81 id: format!("{}/list", entity.state_name),
82 source: PortableViewSource::Entity {
83 name: entity.state_name.clone(),
84 },
85 pipeline: Vec::new(),
86 output: PortableViewOutput::Collection,
87 });
88 views
89}
90
91pub fn with_default_views(mut entity: PortableEntity) -> Result<PortableEntity, ArtifactError> {
92 let mut seen_primary_keys = BTreeSet::new();
93 entity
94 .identity
95 .primary_keys
96 .retain(|primary_key| seen_primary_keys.insert(primary_key.clone()));
97 for expected in default_views(&entity) {
98 match entity.views.iter().find(|view| view.id == expected.id) {
99 Some(existing) if existing != &expected => {
100 return Err(ArtifactError::InvalidArtifact(format!(
101 "entity '{}' defines conflicting default view '{}'",
102 entity.state_name, expected.id
103 )));
104 }
105 Some(_) => {}
106 None => entity.views.push(expected),
107 }
108 }
109 entity.validate()?;
110 Ok(entity)
111}
112
113pub fn selected_views(alias: &str, live: &LiveSpecV2) -> Vec<SelectedViewV2> {
114 live.entities
115 .iter()
116 .flat_map(|entity| {
117 entity.views.iter().map(|view| SelectedViewV2 {
118 live_alias: alias.to_string(),
119 view_id: view.id.clone(),
120 })
121 })
122 .collect()
123}
124
125pub fn live_spec_v2(
126 programs: &[ProgramSpecArtifact],
127 entities: Vec<PortableEntity>,
128 program_adapters: Vec<ProgramAdapterV2>,
129) -> Result<LiveSpecArtifactV2, ArtifactError> {
130 let entities = entities
131 .into_iter()
132 .map(with_default_views)
133 .collect::<Result<Vec<_>, _>>()?;
134 let requirements = programs
135 .iter()
136 .map(|program| ProgramRequirementV2 {
137 program_id: program.payload.program_id.clone(),
138 program_spec_hash: program.artifact_hash,
139 })
140 .collect();
141 LiveSpecArtifactV2::new(LiveSpecV2::new(requirements, entities, program_adapters))
142}
143
144pub fn stack_manifest_v2(
145 name: impl Into<String>,
146 programs: &[ProgramSpecArtifact],
147 live_specs: Vec<(String, &LiveSpecArtifactV2)>,
148 selected_views: Vec<SelectedViewV2>,
149) -> Result<StackManifestArtifactV2, ArtifactError> {
150 compose_stack_manifest_v2(name, programs, live_specs, selected_views)
151}
152
153pub fn compose_stack_manifest_v2(
156 name: impl Into<String>,
157 programs: &[ProgramSpecArtifact],
158 live_specs: Vec<(String, &LiveSpecArtifactV2)>,
159 selected_views: Vec<SelectedViewV2>,
160) -> Result<StackManifestArtifactV2, ArtifactError> {
161 let manifest = StackManifestV2::new(
162 name,
163 programs
164 .iter()
165 .map(|program| ProgramSpecReferenceV2 {
166 program_id: program.payload.program_id.clone(),
167 artifact_hash: program.artifact_hash,
168 })
169 .collect(),
170 live_specs
171 .iter()
172 .map(|(alias, live)| LiveSpecReferenceV2 {
173 alias: alias.clone(),
174 artifact_hash: live.artifact_hash,
175 })
176 .collect(),
177 selected_views,
178 );
179 let artifact = StackManifestArtifactV2::new(manifest)?;
180 let owned_lives = live_specs
181 .iter()
182 .map(|(alias, live)| (alias.clone(), (*live).clone()))
183 .collect::<Vec<_>>();
184 crate::resolve_stack_composition_v2(&artifact, &owned_lives, programs)?;
185 Ok(artifact)
186}
187
188pub fn author_stack_v2(input: StackAuthoringV2) -> Result<AuthoredStackV2, ArtifactError> {
189 if input.name.is_empty() {
190 return Err(ArtifactError::InvalidArtifact(
191 "stack name must not be empty".to_string(),
192 ));
193 }
194 let program_specs = input
195 .programs
196 .into_iter()
197 .map(program_spec_v1)
198 .collect::<Result<Vec<_>, _>>()?;
199 let adapters = derive_program_adapters(
200 &program_specs,
201 &input.pda_overrides,
202 &input.instruction_overrides,
203 )?;
204
205 if input.entities.is_empty() {
206 let stack_manifest = stack_manifest_v2(input.name, &program_specs, Vec::new(), Vec::new())?;
207 return Ok(AuthoredStackV2 {
208 program_specs,
209 live_spec: None,
210 stack_manifest,
211 });
212 }
213
214 let live_spec = live_spec_v2(&program_specs, input.entities, adapters)?;
215 let selected = selected_views(&input.live_alias, &live_spec.payload);
216 let stack_manifest = stack_manifest_v2(
217 input.name,
218 &program_specs,
219 vec![(input.live_alias, &live_spec)],
220 selected,
221 )?;
222 Ok(AuthoredStackV2 {
223 program_specs,
224 live_spec: Some(live_spec),
225 stack_manifest,
226 })
227}
228
229pub fn normalize_live_spec_v1(
230 live: &LiveSpecArtifact,
231 programs: &[ProgramSpecArtifact],
232) -> Result<LiveSpecArtifactV2, ArtifactError> {
233 live.validate()?;
234 let required = live
235 .payload
236 .programs
237 .iter()
238 .map(|requirement| {
239 (
240 requirement.program_spec_hash.to_string(),
241 requirement.program_id.as_str(),
242 )
243 })
244 .collect::<BTreeMap<_, _>>();
245 if programs.len() != required.len()
246 || programs.iter().any(|program| {
247 required.get(&program.artifact_hash.to_string()).copied()
248 != Some(program.payload.program_id.as_str())
249 })
250 {
251 return Err(ArtifactError::InvalidArtifact(
252 "V1 LiveSpec ProgramSpec dependencies do not match the supplied artifacts".to_string(),
253 ));
254 }
255
256 let entities = live
257 .payload
258 .entities
259 .iter()
260 .map(transcode)
261 .collect::<Result<Vec<PortableEntity>, _>>()?;
262 let (pdas, instructions) = match &live.payload.legacy_program_extensions {
263 Some(extensions) => (
264 transcode(&extensions.pdas)?,
265 transcode(&extensions.instructions)?,
266 ),
267 None => (BTreeMap::new(), Vec::new()),
268 };
269 let adapters = derive_program_adapters(programs, &pdas, &instructions)?;
270 live_spec_v2(programs, entities, adapters)
271}
272
273pub fn normalize_legacy_stack_v2(bytes: &[u8]) -> Result<NormalizedLegacyStackV2, ArtifactError> {
274 let legacy = crate::decompose_legacy_stack(bytes)?;
275 let live_spec = normalize_live_spec_v1(&legacy.live_spec, &legacy.program_specs)?;
276 let stack_manifest = normalize_stack_manifest_v1(
277 &legacy.stack_manifest,
278 &legacy.program_specs,
279 &[(
280 legacy.live_spec.artifact_hash,
281 DEFAULT_LIVE_ALIAS.to_string(),
282 &live_spec,
283 )],
284 )?;
285 Ok(NormalizedLegacyStackV2 {
286 legacy,
287 live_spec,
288 stack_manifest,
289 })
290}
291
292pub fn normalize_stack_manifest_v1(
293 manifest: &StackManifestArtifact,
294 programs: &[ProgramSpecArtifact],
295 live_specs: &[(arete_hash::HashId<LiveSpec>, String, &LiveSpecArtifactV2)],
296) -> Result<StackManifestArtifactV2, ArtifactError> {
297 manifest.validate()?;
298 let program_refs = manifest
299 .payload
300 .programs
301 .iter()
302 .map(|reference| {
303 (
304 reference.artifact_hash.to_string(),
305 reference.program_id.as_str(),
306 )
307 })
308 .collect::<Vec<_>>();
309 let supplied_programs = programs
310 .iter()
311 .map(|program| {
312 (
313 program.artifact_hash.to_string(),
314 program.payload.program_id.as_str(),
315 )
316 })
317 .collect::<Vec<_>>();
318 if program_refs != supplied_programs {
319 return Err(ArtifactError::InvalidArtifact(
320 "V1 StackManifest ProgramSpec order does not match supplied artifacts".to_string(),
321 ));
322 }
323
324 let normalized_by_source_hash = live_specs
325 .iter()
326 .map(|(source_hash, alias, live)| (source_hash.to_string(), (alias.as_str(), *live)))
327 .collect::<BTreeMap<_, _>>();
328 if normalized_by_source_hash.len() != manifest.payload.live_specs.len()
329 || manifest.payload.live_specs.iter().any(|reference| {
330 !normalized_by_source_hash.contains_key(&reference.artifact_hash.to_string())
331 })
332 {
333 return Err(ArtifactError::InvalidArtifact(
334 "V1 StackManifest LiveSpec references do not match supplied normalized artifacts"
335 .to_string(),
336 ));
337 }
338 let ordered_lives = manifest
339 .payload
340 .live_specs
341 .iter()
342 .map(|reference| {
343 let (alias, live) = normalized_by_source_hash[&reference.artifact_hash.to_string()];
344 (alias.to_string(), live)
345 })
346 .collect::<Vec<_>>();
347 let selected = manifest
348 .payload
349 .selected_views
350 .iter()
351 .map(|selected| {
352 normalized_by_source_hash
353 .get(&selected.live_spec_hash.to_string())
354 .map(|(alias, _)| SelectedViewV2 {
355 live_alias: (*alias).to_string(),
356 view_id: selected.view_id.clone(),
357 })
358 .ok_or_else(|| {
359 ArtifactError::InvalidArtifact(format!(
360 "selected V1 view '{}' references an unknown LiveSpec",
361 selected.view_id
362 ))
363 })
364 })
365 .collect::<Result<Vec<_>, _>>()?;
366 stack_manifest_v2(
367 manifest.payload.name.clone(),
368 programs,
369 ordered_lives,
370 selected,
371 )
372}
373
374pub fn write_authored_stack_v2(
375 directory: &Path,
376 stack_name: &str,
377 artifacts: &AuthoredStackV2,
378) -> Result<Vec<PathBuf>, ArtifactError> {
379 validate_file_stem(stack_name)?;
380 let mut files = Vec::new();
381 let mut program_file_names = BTreeSet::new();
382 for program in &artifacts.program_specs {
383 let name = &program.payload.idl_snapshot.snapshot.name;
384 validate_file_stem(name)?;
385 if !program_file_names.insert(name.as_str()) {
386 return Err(ArtifactError::InvalidArtifact(format!(
387 "multiple ProgramSpecs would write '{name}.program-spec.json'"
388 )));
389 }
390 files.push((
391 directory.join(format!("{name}.program-spec.json")),
392 program.canonical_bytes()?,
393 ));
394 }
395 if let Some(live) = &artifacts.live_spec {
396 files.push((
397 directory.join(format!("{stack_name}.live-spec.json")),
398 live.canonical_bytes()?,
399 ));
400 }
401 files.push((
402 directory.join(format!("{stack_name}.stack-manifest.json")),
403 artifacts.stack_manifest.canonical_bytes()?,
404 ));
405
406 std::fs::create_dir_all(directory)?;
407 let mut written = Vec::with_capacity(files.len());
408 for (path, bytes) in files {
409 atomic_write(&path, &bytes)?;
410 written.push(path);
411 }
412 Ok(written)
413}
414
415pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ArtifactError> {
416 static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(0);
417
418 let parent = path.parent().unwrap_or_else(|| Path::new("."));
419 std::fs::create_dir_all(parent)?;
420 let file_name = path
421 .file_name()
422 .and_then(|name| name.to_str())
423 .ok_or_else(|| {
424 ArtifactError::InvalidArtifact(format!(
425 "artifact path '{}' has no UTF-8 filename",
426 path.display()
427 ))
428 })?;
429 let temp_path = parent.join(format!(
430 ".{file_name}.tmp-{}-{}",
431 std::process::id(),
432 NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed)
433 ));
434 let result = (|| -> Result<(), std::io::Error> {
435 let mut file = OpenOptions::new()
436 .write(true)
437 .create_new(true)
438 .open(&temp_path)?;
439 file.write_all(bytes)?;
440 file.sync_all()?;
441 std::fs::rename(&temp_path, path)?;
442 Ok(())
443 })();
444 if result.is_err() {
445 let _ = std::fs::remove_file(&temp_path);
446 }
447 result.map_err(Into::into)
448}
449
450fn derive_program_adapters(
451 programs: &[ProgramSpecArtifact],
452 pda_overrides: &BTreeMap<String, BTreeMap<String, PdaDefinitionV1>>,
453 instruction_overrides: &[InstructionDefinitionV1],
454) -> Result<Vec<ProgramAdapterV2>, ArtifactError> {
455 let program_names = programs
456 .iter()
457 .map(|program| program.payload.idl_snapshot.snapshot.name.as_str())
458 .collect::<BTreeSet<_>>();
459 if let Some(unknown) = pda_overrides
460 .keys()
461 .find(|name| !program_names.contains(name.as_str()))
462 {
463 return Err(ArtifactError::InvalidArtifact(format!(
464 "PDA overrides reference unknown program '{unknown}'"
465 )));
466 }
467
468 let mut adapters = Vec::new();
469 for program in programs {
470 let program_name = &program.payload.idl_snapshot.snapshot.name;
471 let pdas = pda_overrides
472 .get(program_name)
473 .into_iter()
474 .flat_map(|overrides| overrides.iter())
475 .filter(|(name, value)| program.payload.pdas.get(*name) != Some(*value))
476 .map(|(name, value)| (name.clone(), value.clone()))
477 .collect::<BTreeMap<_, _>>();
478
479 let mut instruction_resolutions = Vec::new();
480 for override_instruction in instruction_overrides.iter().filter(|instruction| {
481 instruction.program_id.as_deref() == Some(program.payload.program_id.as_str())
482 || (instruction.program_id.is_none() && programs.len() == 1)
483 }) {
484 let base = program
485 .payload
486 .instructions
487 .iter()
488 .find(|instruction| instruction.name == override_instruction.name)
489 .ok_or_else(|| {
490 ArtifactError::InvalidArtifact(format!(
491 "instruction override '{}.{}' has no ProgramSpec instruction",
492 program_name, override_instruction.name
493 ))
494 })?;
495 let mut reconciled = base.clone();
496 let mut accounts = BTreeMap::new();
497 for override_account in &override_instruction.accounts {
498 let base_account = reconciled
499 .accounts
500 .iter_mut()
501 .find(|account| account.name == override_account.name)
502 .ok_or_else(|| {
503 ArtifactError::InvalidArtifact(format!(
504 "instruction override '{}.{}' contains unknown account '{}'",
505 program_name, override_instruction.name, override_account.name
506 ))
507 })?;
508 if base_account.resolution != override_account.resolution {
509 accounts.insert(
510 override_account.name.clone(),
511 override_account.resolution.clone(),
512 );
513 base_account.resolution = override_account.resolution.clone();
514 }
515 }
516 if reconciled != *override_instruction {
517 return Err(ArtifactError::InvalidArtifact(format!(
518 "instruction override '{}.{}' changes fields other than account resolution",
519 program_name, override_instruction.name
520 )));
521 }
522 if !accounts.is_empty() {
523 instruction_resolutions.push(crate::InstructionResolutionAdapterV2 {
524 instruction: override_instruction.name.clone(),
525 accounts,
526 });
527 }
528 }
529 instruction_resolutions.sort_by(|left, right| left.instruction.cmp(&right.instruction));
530 if !pdas.is_empty() || !instruction_resolutions.is_empty() {
531 adapters.push(ProgramAdapterV2 {
532 program_spec_hash: program.artifact_hash,
533 pdas,
534 instruction_resolutions,
535 });
536 }
537 }
538
539 let recognized = programs
540 .iter()
541 .flat_map(|program| {
542 program.payload.instructions.iter().map(move |instruction| {
543 (
544 program.payload.program_id.as_str(),
545 instruction.name.as_str(),
546 )
547 })
548 })
549 .collect::<BTreeSet<_>>();
550 for instruction in instruction_overrides {
551 let matches = instruction.program_id.as_deref().map_or_else(
552 || {
553 programs.len() == 1
554 && recognized.contains(&(
555 programs[0].payload.program_id.as_str(),
556 instruction.name.as_str(),
557 ))
558 },
559 |program_id| recognized.contains(&(program_id, instruction.name.as_str())),
560 );
561 if !matches {
562 return Err(ArtifactError::InvalidArtifact(format!(
563 "instruction override '{}' does not match a ProgramSpec instruction",
564 instruction.name
565 )));
566 }
567 }
568 Ok(adapters)
569}
570
571fn transcode<T: Serialize, U: DeserializeOwned>(value: &T) -> Result<U, ArtifactError> {
572 serde_json::from_value(serde_json::to_value(value).map_err(crate::json_error)?)
573 .map_err(crate::json_error)
574}
575
576fn validate_file_stem(value: &str) -> Result<(), ArtifactError> {
577 if value.is_empty()
578 || !value
579 .chars()
580 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
581 {
582 return Err(ArtifactError::InvalidArtifact(format!(
583 "'{value}' is not safe for an artifact filename"
584 )));
585 }
586 Ok(())
587}
588
589#[allow(dead_code)]
590fn _type_markers(_: arete_hash::HashId<ProgramSpec>, _: arete_hash::HashId<LiveSpec>) {}