Skip to main content

aion_package/
manifest.rs

1//! Typed `manifest.json` model and `.aion` format-version checks.
2
3use std::{fmt, time::Duration};
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::PackageError;
9
10/// Canonical SHA-256 digest of one manifest's serialized JSON form.
11///
12/// The package content hash deliberately covers the canonical beam set only
13/// (`manifest.json` is excluded), so two archives with identical beams but
14/// different manifests carry the same [`crate::ContentHash`]. This digest is
15/// the tripwire that detects exactly that divergence: the engine catalog
16/// retains it per loaded version and refuses an idempotent re-load whose
17/// incoming manifest digest differs.
18#[derive(Clone, Debug, PartialEq, Eq, Hash)]
19pub struct ManifestDigest([u8; 32]);
20
21impl ManifestDigest {
22    /// Creates a manifest digest from raw SHA-256 digest bytes.
23    #[must_use]
24    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
25        Self(bytes)
26    }
27
28    /// Returns the raw SHA-256 digest bytes.
29    #[must_use]
30    pub const fn as_bytes(&self) -> &[u8; 32] {
31        &self.0
32    }
33}
34
35impl fmt::Display for ManifestDigest {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        for byte in &self.0 {
38            write!(formatter, "{byte:02x}")?;
39        }
40        Ok(())
41    }
42}
43
44/// Current `.aion` manifest and archive-layout schema version supported by this crate.
45pub const CURRENT_FORMAT_VERSION: u32 = 1;
46
47/// Textual content-hash version stored in `manifest.json`.
48///
49/// The content hash is computed and stamped by later package-building code; this
50/// type keeps the manifest field distinct from unrelated strings while this
51/// module remains side-effect free.
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ManifestVersion(pub String);
54
55impl ManifestVersion {
56    /// Creates a manifest version value from the hash's stable textual form.
57    #[must_use]
58    pub fn new(version: impl Into<String>) -> Self {
59        Self(version.into())
60    }
61
62    /// Returns the stored textual content-hash version.
63    #[must_use]
64    pub fn as_str(&self) -> &str {
65        &self.0
66    }
67}
68
69/// Activity declaration recorded in the package manifest.
70#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
71pub struct DeclaredActivity {
72    /// Stable `activity_type` key naming an activity type invoked by workflow code.
73    ///
74    /// This follows the current `aion-core` event convention, where scheduled
75    /// activity types are represented as strings.
76    #[serde(rename = "activity_type")]
77    pub activity_type: String,
78}
79
80/// Typed on-disk `manifest.json` descriptor for a `.aion` package.
81///
82/// The public field names are the stable JSON keys written into `manifest.json`:
83/// `entry_module`, `entry_function`, `input_schema`, `output_schema`, `timeout`,
84/// `activities`, `version`, and `format_version`.
85#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
86pub struct Manifest {
87    /// Stable `entry_module` key naming the logical workflow entry module.
88    #[serde(rename = "entry_module")]
89    pub entry_module: String,
90    /// Stable `entry_function` key naming the exported workflow entry function.
91    #[serde(rename = "entry_function")]
92    pub entry_function: String,
93    /// Stable `input_schema` key containing a JSON-Schema document for input payloads.
94    #[serde(rename = "input_schema")]
95    pub input_schema: serde_json::Value,
96    /// Stable `output_schema` key containing a JSON-Schema document for result payloads.
97    #[serde(rename = "output_schema")]
98    pub output_schema: serde_json::Value,
99    /// Stable `timeout` key containing the workflow timeout as a serde-encoded duration.
100    #[serde(rename = "timeout")]
101    pub timeout: Duration,
102    /// Stable `activities` key listing activity types declared by the workflow.
103    #[serde(rename = "activities")]
104    pub activities: Vec<DeclaredActivity>,
105    /// Stable `version` key containing the package content hash textual value.
106    #[serde(rename = "version")]
107    pub version: ManifestVersion,
108    /// Stable `format_version` key identifying the `.aion` format schema version.
109    ///
110    /// This lets future layout changes be detected rather than silently misread.
111    #[serde(rename = "format_version")]
112    pub format_version: u32,
113}
114
115impl Manifest {
116    /// Checks whether this manifest declares a supported `.aion` format version.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`PackageError::UnknownFormatVersion`] when `format_version` is
121    /// not [`CURRENT_FORMAT_VERSION`].
122    pub fn check_format_version(&self) -> Result<(), PackageError> {
123        if self.format_version == CURRENT_FORMAT_VERSION {
124            Ok(())
125        } else {
126            Err(PackageError::UnknownFormatVersion {
127                found: self.format_version,
128            })
129        }
130    }
131
132    /// Computes the canonical SHA-256 digest of this manifest.
133    ///
134    /// The digest covers the manifest's stable serialized JSON form (the same
135    /// field names and ordering written into `manifest.json`), so any
136    /// semantic difference — entry function, schemas, timeout, declared
137    /// activities — produces a different digest even when the beam set (and
138    /// therefore the content hash) is unchanged.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`PackageError::ManifestSerialise`] when the manifest cannot be
143    /// serialized to JSON.
144    pub fn canonical_digest(&self) -> Result<ManifestDigest, PackageError> {
145        let bytes = serde_json::to_vec(self)
146            .map_err(|source| PackageError::ManifestSerialise { source })?;
147        let mut digest = Sha256::new();
148        digest.update(&bytes);
149        Ok(ManifestDigest(digest.finalize().into()))
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use std::time::Duration;
156
157    use serde_json::json;
158
159    use super::{CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion};
160    use crate::PackageError;
161
162    fn sample_manifest() -> Manifest {
163        Manifest {
164            entry_module: "workflow/order".to_owned(),
165            entry_function: "run".to_owned(),
166            input_schema: json!({
167                "$schema": "https://json-schema.org/draft/2020-12/schema",
168                "type": "object",
169                "required": ["order_id"],
170                "properties": {
171                    "order_id": { "type": "string" },
172                    "retry": { "type": "boolean" }
173                }
174            }),
175            output_schema: json!({
176                "$schema": "https://json-schema.org/draft/2020-12/schema",
177                "type": "object",
178                "required": ["status"],
179                "properties": {
180                    "status": { "enum": ["accepted", "rejected"] },
181                    "total": { "type": "number" }
182                }
183            }),
184            timeout: Duration::new(30, 250_000_000),
185            activities: vec![
186                DeclaredActivity {
187                    activity_type: "charge_card".to_owned(),
188                },
189                DeclaredActivity {
190                    activity_type: "send_receipt".to_owned(),
191                },
192            ],
193            version: ManifestVersion::new(
194                "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
195            ),
196            format_version: CURRENT_FORMAT_VERSION,
197        }
198    }
199
200    #[test]
201    fn manifest_round_trips_losslessly_through_json() -> Result<(), serde_json::Error> {
202        let manifest = sample_manifest();
203
204        let json = serde_json::to_string(&manifest)?;
205        let decoded: Manifest = serde_json::from_str(&json)?;
206
207        assert_eq!(decoded, manifest);
208        Ok(())
209    }
210
211    #[test]
212    fn manifest_with_schemas_and_declared_activities_round_trips() -> Result<(), serde_json::Error>
213    {
214        let manifest = sample_manifest();
215
216        let json = serde_json::to_string(&manifest)?;
217        let decoded: Manifest = serde_json::from_str(&json)?;
218
219        assert_eq!(
220            decoded.input_schema["properties"]["order_id"]["type"],
221            "string"
222        );
223        assert_eq!(
224            decoded.output_schema["properties"]["status"]["enum"][0],
225            "accepted"
226        );
227        assert_eq!(decoded.activities.len(), 2);
228        assert_eq!(decoded, manifest);
229        Ok(())
230    }
231
232    #[test]
233    fn supported_format_version_passes() -> Result<(), PackageError> {
234        sample_manifest().check_format_version()
235    }
236
237    /// Identical manifests digest identically; any semantic change (entry
238    /// function here) changes the digest even though the beam set — and
239    /// therefore the content hash — is untouched.
240    #[test]
241    fn canonical_digest_detects_manifest_divergence() -> Result<(), PackageError> {
242        let manifest = sample_manifest();
243        let same = sample_manifest();
244        let mut diverged = sample_manifest();
245        diverged.entry_function = "start".to_owned();
246
247        assert_eq!(manifest.canonical_digest()?, same.canonical_digest()?);
248        assert_ne!(manifest.canonical_digest()?, diverged.canonical_digest()?);
249        Ok(())
250    }
251
252    #[test]
253    fn canonical_digest_renders_as_lowercase_hex() -> Result<(), PackageError> {
254        let digest = sample_manifest().canonical_digest()?;
255        let text = digest.to_string();
256
257        assert_eq!(text.len(), 64);
258        assert!(
259            text.bytes()
260                .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
261        );
262        Ok(())
263    }
264
265    #[test]
266    fn unsupported_format_version_returns_typed_error() {
267        let mut manifest = sample_manifest();
268        manifest.format_version = CURRENT_FORMAT_VERSION + 1;
269
270        let result = manifest.check_format_version();
271
272        assert!(matches!(
273            result,
274            Err(PackageError::UnknownFormatVersion { found }) if found == CURRENT_FORMAT_VERSION + 1
275        ));
276    }
277
278    #[test]
279    fn manifest_json_keys_are_stable() -> Result<(), serde_json::Error> {
280        let manifest = sample_manifest();
281
282        let json = serde_json::to_value(&manifest)?;
283
284        assert!(json.get("entry_module").is_some());
285        assert!(json.get("entry_function").is_some());
286        assert!(json.get("input_schema").is_some());
287        assert!(json.get("output_schema").is_some());
288        assert!(json.get("timeout").is_some());
289        assert!(json.get("activities").is_some());
290        assert!(json.get("version").is_some());
291        assert!(json.get("format_version").is_some());
292        assert_eq!(json["activities"][0]["activity_type"], "charge_card");
293        Ok(())
294    }
295}