Skip to main content

arete_artifacts/
authoring.rs

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
153/// Compose independently authored LiveSpecs without changing the singular
154/// semantics of [`author_stack_v2`].
155pub 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>) {}