1use std::{fmt, time::Duration};
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::PackageError;
9
10#[derive(Clone, Debug, PartialEq, Eq, Hash)]
19pub struct ManifestDigest([u8; 32]);
20
21impl ManifestDigest {
22 #[must_use]
24 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
25 Self(bytes)
26 }
27
28 #[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
44pub const CURRENT_FORMAT_VERSION: u32 = 1;
46
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ManifestVersion(pub String);
54
55impl ManifestVersion {
56 #[must_use]
58 pub fn new(version: impl Into<String>) -> Self {
59 Self(version.into())
60 }
61
62 #[must_use]
64 pub fn as_str(&self) -> &str {
65 &self.0
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
71pub struct DeclaredActivity {
72 #[serde(rename = "activity_type")]
77 pub activity_type: String,
78}
79
80#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
86pub struct Manifest {
87 #[serde(rename = "entry_module")]
89 pub entry_module: String,
90 #[serde(rename = "entry_function")]
92 pub entry_function: String,
93 #[serde(rename = "input_schema")]
95 pub input_schema: serde_json::Value,
96 #[serde(rename = "output_schema")]
98 pub output_schema: serde_json::Value,
99 #[serde(rename = "timeout")]
101 pub timeout: Duration,
102 #[serde(rename = "activities")]
104 pub activities: Vec<DeclaredActivity>,
105 #[serde(rename = "version")]
107 pub version: ManifestVersion,
108 #[serde(rename = "format_version")]
112 pub format_version: u32,
113}
114
115impl Manifest {
116 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 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 #[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}