Skip to main content

canwu_sim/runtime/
manifest.rs

1use super::{
2    CanwuError, ErrorCode, RunConfiguration, RunConfigurationSnapshot, Scenario, canonical_hash,
3    is_canonical_hash, policy,
4};
5use serde::{Deserialize, Serialize};
6
7pub const RUN_MANIFEST_FORMAT_VERSION: u32 = 1;
8
9/// Stable identity for a scenario, ruleset, content pack, run policy,
10/// localization contract, or source ledger.
11#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
12pub struct ArtifactManifest {
13    pub namespace: String,
14    pub name: String,
15    pub version: String,
16    pub semantic_hash: String,
17}
18
19impl ArtifactManifest {
20    pub fn new(
21        namespace: impl Into<String>,
22        name: impl Into<String>,
23        version: impl Into<String>,
24        semantic_hash: impl Into<String>,
25    ) -> Result<Self, CanwuError> {
26        let manifest = Self {
27            namespace: namespace.into(),
28            name: name.into(),
29            version: version.into(),
30            semantic_hash: semantic_hash.into(),
31        };
32        validate_artifact(&manifest, "artifact")?;
33        Ok(manifest)
34    }
35
36    pub fn from_bytes(
37        namespace: impl Into<String>,
38        name: impl Into<String>,
39        version: impl Into<String>,
40        bytes: &[u8],
41    ) -> Result<Self, CanwuError> {
42        let mut hasher = blake3::Hasher::new();
43        hasher.update(b"canwu.artifact-bytes.v1");
44        hasher.update(&[0]);
45        hasher.update(bytes);
46        Self::new(
47            namespace,
48            name,
49            version,
50            hasher.finalize().to_hex().to_string(),
51        )
52    }
53
54    pub fn for_scenario(
55        namespace: impl Into<String>,
56        name: impl Into<String>,
57        version: impl Into<String>,
58        scenario: &Scenario,
59    ) -> Result<Self, CanwuError> {
60        Self::new(namespace, name, version, scenario_semantic_hash(scenario)?)
61    }
62
63    pub fn for_run_configuration(
64        namespace: impl Into<String>,
65        name: impl Into<String>,
66        version: impl Into<String>,
67        configuration: &RunConfiguration,
68    ) -> Result<Self, CanwuError> {
69        let mut configuration = configuration.clone();
70        configuration.canonicalize();
71        configuration.validate()?;
72        Self::new(namespace, name, version, configuration.semantic_hash()?)
73    }
74}
75
76#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
77#[serde(tag = "provenance", rename_all = "snake_case")]
78/// The exact non-executable environment bound to a simulation run.
79pub enum RunManifest {
80    Declared {
81        format_version: u32,
82        scenario: ArtifactManifest,
83        #[serde(default)]
84        rules: Vec<ArtifactManifest>,
85        #[serde(default)]
86        content: Vec<ArtifactManifest>,
87        #[serde(default)]
88        localization_contracts: Vec<ArtifactManifest>,
89        run_configuration: Box<ArtifactManifest>,
90        #[serde(default)]
91        sources: Vec<ArtifactManifest>,
92    },
93    MigratedLegacy {
94        format_version: u32,
95        source_engine_version: String,
96        source_snapshot_format: u32,
97        checkpoint_hash: String,
98    },
99}
100
101impl RunManifest {
102    pub fn for_scenario(
103        namespace: impl Into<String>,
104        name: impl Into<String>,
105        version: impl Into<String>,
106        scenario: &Scenario,
107    ) -> Result<Self, CanwuError> {
108        let scenario = ArtifactManifest::for_scenario(namespace, name, version, scenario)?;
109        let run_configuration = default_run_configuration_manifest()?;
110        Ok(Self::Declared {
111            format_version: RUN_MANIFEST_FORMAT_VERSION,
112            scenario,
113            rules: Vec::new(),
114            content: Vec::new(),
115            localization_contracts: Vec::new(),
116            run_configuration: Box::new(run_configuration),
117            sources: Vec::new(),
118        })
119    }
120
121    #[must_use]
122    pub fn declared(scenario: ArtifactManifest, run_configuration: ArtifactManifest) -> Self {
123        Self::Declared {
124            format_version: RUN_MANIFEST_FORMAT_VERSION,
125            scenario,
126            rules: Vec::new(),
127            content: Vec::new(),
128            localization_contracts: Vec::new(),
129            run_configuration: Box::new(run_configuration),
130            sources: Vec::new(),
131        }
132    }
133
134    pub(crate) fn migrated_legacy(
135        source_engine_version: String,
136        source_snapshot_format: u32,
137        checkpoint_hash: String,
138    ) -> Self {
139        Self::MigratedLegacy {
140            format_version: RUN_MANIFEST_FORMAT_VERSION,
141            source_engine_version,
142            source_snapshot_format,
143            checkpoint_hash,
144        }
145    }
146}
147
148pub(crate) fn canonicalize(manifest: &mut RunManifest) {
149    if let RunManifest::Declared {
150        rules,
151        content,
152        localization_contracts,
153        sources,
154        ..
155    } = manifest
156    {
157        rules.sort();
158        content.sort();
159        localization_contracts.sort();
160        sources.sort();
161    }
162}
163
164pub(crate) fn validate(
165    manifest: &RunManifest,
166    scenario: Option<&Scenario>,
167    allow_legacy: bool,
168) -> Result<(), CanwuError> {
169    match manifest {
170        RunManifest::Declared {
171            format_version,
172            scenario: scenario_manifest,
173            rules,
174            content,
175            localization_contracts,
176            run_configuration,
177            sources,
178        } => {
179            if *format_version != RUN_MANIFEST_FORMAT_VERSION {
180                return invalid_manifest(format!(
181                    "run manifest format {format_version} is unsupported"
182                ));
183            }
184            validate_artifact(scenario_manifest, "scenario")?;
185            validate_artifact(run_configuration, "run configuration")?;
186            validate_artifact_list(rules, "rules")?;
187            validate_artifact_list(content, "content")?;
188            validate_artifact_list(localization_contracts, "localization contract")?;
189            validate_artifact_list(sources, "source")?;
190            if let Some(scenario) = scenario
191                && scenario_manifest.semantic_hash != scenario_semantic_hash(scenario)?
192            {
193                return invalid_manifest(
194                    "scenario manifest hash does not match the admitted scenario",
195                );
196            }
197            Ok(())
198        }
199        RunManifest::MigratedLegacy {
200            format_version,
201            source_engine_version,
202            source_snapshot_format,
203            checkpoint_hash,
204        } => {
205            if !allow_legacy {
206                return invalid_manifest(
207                    "new simulations require a declared scenario and run configuration manifest",
208                );
209            }
210            if *format_version != RUN_MANIFEST_FORMAT_VERSION
211                || source_engine_version.trim().is_empty()
212                || source_engine_version != source_engine_version.trim()
213                || !matches!(source_snapshot_format, 2 | 3)
214                || !is_canonical_hash(checkpoint_hash)
215            {
216                return invalid_manifest("migrated legacy run provenance is invalid");
217            }
218            Ok(())
219        }
220    }
221}
222
223pub(crate) fn hash(manifest: &RunManifest) -> Result<String, CanwuError> {
224    canonical_hash("canwu.run-manifest.v1", manifest)
225}
226
227pub(crate) fn validate_run_configuration(
228    manifest: &RunManifest,
229    configuration: &RunConfigurationSnapshot,
230) -> Result<(), CanwuError> {
231    configuration.validate()?;
232    match (manifest, configuration) {
233        (
234            RunManifest::Declared {
235                run_configuration, ..
236            },
237            RunConfigurationSnapshot::Declared(_) | RunConfigurationSnapshot::CompatibilityV1,
238        ) => {
239            if configuration.semantic_hash()?.as_deref()
240                != Some(run_configuration.semantic_hash.as_str())
241            {
242                return invalid_manifest(
243                    "run configuration snapshot does not match its manifest identity",
244                );
245            }
246            Ok(())
247        }
248        (
249            RunManifest::Declared {
250                run_configuration, ..
251            },
252            RunConfigurationSnapshot::ManifestOnlyV1,
253        ) => {
254            if run_configuration.semantic_hash == policy::compatibility_configuration_hash()? {
255                return invalid_manifest(
256                    "the default run configuration must use compatibility-v1 provenance",
257                );
258            }
259            Ok(())
260        }
261        (RunManifest::MigratedLegacy { .. }, RunConfigurationSnapshot::LegacyUnspecified) => Ok(()),
262        (RunManifest::Declared { .. }, RunConfigurationSnapshot::LegacyUnspecified) => {
263            invalid_manifest("declared runs cannot use an identity-unbound run configuration")
264        }
265        (RunManifest::MigratedLegacy { .. }, _) => invalid_manifest(
266            "legacy migrations cannot silently inherit a current run configuration",
267        ),
268    }
269}
270
271fn scenario_semantic_hash(scenario: &Scenario) -> Result<String, CanwuError> {
272    let mut canonical = scenario.clone();
273    canonical.world.people.sort_by_key(|value| value.id);
274    canonical.world.governments.sort_by_key(|value| value.id);
275    canonical.world.territories.sort_by_key(|value| value.id);
276    canonical.world.routes.sort_by_key(|value| value.id);
277    canonical.world.armies.sort_by_key(|value| value.id);
278    canonical
279        .domain_records
280        .sort_by(|left, right| left.reference.cmp(&right.reference));
281    canonical_hash("canwu.scenario.v1", &canonical)
282}
283
284fn default_run_configuration_manifest() -> Result<ArtifactManifest, CanwuError> {
285    ArtifactManifest::new(
286        "canwu.core",
287        "default-run-configuration",
288        "1",
289        policy::compatibility_configuration_hash()?,
290    )
291}
292
293fn validate_artifact_list(manifests: &[ArtifactManifest], label: &str) -> Result<(), CanwuError> {
294    let mut previous = None;
295    for manifest in manifests {
296        validate_artifact(manifest, label)?;
297        if previous.is_some_and(|value: &ArtifactManifest| {
298            value >= manifest
299                || (value.namespace == manifest.namespace && value.name == manifest.name)
300        }) {
301            return invalid_manifest(format!(
302                "{label} manifests must have unique identities and canonical order"
303            ));
304        }
305        previous = Some(manifest);
306    }
307    Ok(())
308}
309
310fn validate_artifact(manifest: &ArtifactManifest, label: &str) -> Result<(), CanwuError> {
311    if !canonical_text(&manifest.namespace)
312        || !canonical_text(&manifest.name)
313        || !canonical_text(&manifest.version)
314        || !is_canonical_hash(&manifest.semantic_hash)
315    {
316        return invalid_manifest(format!(
317            "{label} manifests require canonical namespace, name, version, and semantic hash"
318        ));
319    }
320    Ok(())
321}
322
323fn canonical_text(value: &str) -> bool {
324    !value.is_empty() && value == value.trim()
325}
326
327fn invalid_manifest<T>(message: impl Into<String>) -> Result<T, CanwuError> {
328    Err(CanwuError::new(ErrorCode::InvalidRunManifest, message))
329}