Skip to main content

aion_package/
hash.rs

1//! Content-hash computation over the canonical beam set.
2
3use std::{fmt, str::FromStr, time::Duration};
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
6use sha2::{Digest, Sha256};
7
8use crate::{BeamSet, Manifest, PackageError};
9
10const DIGEST_LEN: usize = 32;
11const TEXT_LEN: usize = DIGEST_LEN * 2;
12const WORKFLOW_TIMEOUT_DOMAIN: &[u8] = b"aion.package.version.workflow-timeout.v1";
13
14/// A SHA-256 package version identity.
15///
16/// Legacy identities cover each module's logical name and exact `.beam` bytes
17/// in [`BeamSet`] canonical order. Explicit-timeout identities append a
18/// domain-separated timeout encoding. Archive representation and optional
19/// source inclusion never participate, so deterministic inputs keep one version.
20///
21/// Its stable textual form is 64 lowercase hexadecimal characters. That text is
22/// the package version identifier stored in the manifest and the hash component
23/// embedded in namespaced deployed module names; it contains only `0-9a-f`,
24/// which is safe for a BEAM module-name component.
25#[derive(Clone, Debug, PartialEq, Eq, Hash)]
26pub struct ContentHash([u8; DIGEST_LEN]);
27
28impl ContentHash {
29    /// Creates a content hash from raw SHA-256 digest bytes.
30    #[must_use]
31    pub const fn from_bytes(bytes: [u8; DIGEST_LEN]) -> Self {
32        Self(bytes)
33    }
34
35    /// Returns the raw SHA-256 digest bytes.
36    #[must_use]
37    pub const fn as_bytes(&self) -> &[u8; DIGEST_LEN] {
38        &self.0
39    }
40}
41
42/// Errors produced when parsing a [`ContentHash`] textual form.
43#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
44pub enum ContentHashParseError {
45    /// The text was not exactly 64 ASCII hexadecimal characters.
46    #[error("content hash text must be 64 lowercase hexadecimal characters, found {found} bytes")]
47    InvalidLength {
48        /// Number of bytes found in the supplied text.
49        found: usize,
50    },
51
52    /// The text contained a character outside lowercase hexadecimal.
53    #[error("content hash text contains non-lowercase-hex byte 0x{byte:02x} at byte index {index}")]
54    InvalidCharacter {
55        /// Byte index of the invalid character.
56        index: usize,
57        /// Invalid byte found at `index`.
58        byte: u8,
59    },
60}
61
62/// Computes the package version hash over the canonical BEAM set only.
63///
64/// The SHA-256 algorithm is mandated by the `.aion` format contract so packers
65/// and loaders on different hosts agree. Each module contributes its logical
66/// name and exact bytes in [`BeamSet`] canonical order, with each field framed by
67/// an eight-byte big-endian length prefix. This unambiguous framing prevents a
68/// shifted name/body boundary from producing the same digest.
69#[must_use]
70pub fn content_hash(beams: &BeamSet) -> ContentHash {
71    let mut digest = Sha256::new();
72    update_beams(&mut digest, beams);
73    ContentHash(digest.finalize().into())
74}
75
76/// Computes an explicit-timeout package version from the canonical BEAM set,
77/// then the framed ASCII domain `aion.package.version.workflow-timeout.v1`,
78/// then exactly 12 timeout bytes: seconds as `u64` big-endian followed by
79/// subsecond nanoseconds as `u32` big-endian.
80#[must_use]
81pub fn content_hash_with_timeout(beams: &BeamSet, timeout: Duration) -> ContentHash {
82    let mut digest = Sha256::new();
83    update_beams(&mut digest, beams);
84    update_framed(&mut digest, WORKFLOW_TIMEOUT_DOMAIN);
85    digest.update(timeout.as_secs().to_be_bytes());
86    digest.update(timeout.subsec_nanos().to_be_bytes());
87    ContentHash(digest.finalize().into())
88}
89
90pub(crate) fn has_explicit_timeout_identity(
91    beams: &BeamSet,
92    manifest: &Manifest,
93    hash: &ContentHash,
94) -> bool {
95    hash != &content_hash(beams) && hash == &content_hash_with_timeout(beams, manifest.timeout)
96}
97
98pub(crate) fn verified_content_hash(
99    beams: &BeamSet,
100    manifest: &Manifest,
101) -> Result<ContentHash, PackageError> {
102    let legacy_hash = content_hash(beams);
103    let timeout_hash = content_hash_with_timeout(beams, manifest.timeout);
104    let stored = manifest.version.as_str();
105    if stored == legacy_hash.to_string() {
106        Ok(legacy_hash)
107    } else if stored == timeout_hash.to_string() {
108        Ok(timeout_hash)
109    } else {
110        Err(PackageError::IntegrityMismatch {
111            expected: stored.to_owned(),
112            computed: legacy_hash.to_string(),
113        })
114    }
115}
116
117fn update_beams(digest: &mut Sha256, beams: &BeamSet) {
118    for module in beams.iter() {
119        update_framed(digest, module.name().as_bytes());
120        update_framed(digest, module.bytes());
121    }
122}
123
124fn update_framed(digest: &mut Sha256, bytes: &[u8]) {
125    let length = bytes.len() as u64;
126    digest.update(length.to_be_bytes().as_slice());
127    digest.update(bytes);
128}
129
130impl fmt::Display for ContentHash {
131    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132        for byte in &self.0 {
133            write!(formatter, "{byte:02x}")?;
134        }
135
136        Ok(())
137    }
138}
139
140impl FromStr for ContentHash {
141    type Err = ContentHashParseError;
142
143    fn from_str(text: &str) -> Result<Self, Self::Err> {
144        let bytes = text.as_bytes();
145        if bytes.len() != TEXT_LEN {
146            return Err(ContentHashParseError::InvalidLength { found: bytes.len() });
147        }
148
149        let mut digest = [0_u8; DIGEST_LEN];
150        for (index, pair) in bytes.chunks_exact(2).enumerate() {
151            let high_index = index * 2;
152            let low_index = high_index + 1;
153            digest[index] = (hex_value(pair[0], high_index)? << 4) | hex_value(pair[1], low_index)?;
154        }
155
156        Ok(Self(digest))
157    }
158}
159
160fn hex_value(byte: u8, index: usize) -> Result<u8, ContentHashParseError> {
161    match byte {
162        b'0'..=b'9' => Ok(byte - b'0'),
163        b'a'..=b'f' => Ok(byte - b'a' + 10),
164        _ => Err(ContentHashParseError::InvalidCharacter { index, byte }),
165    }
166}
167
168impl Serialize for ContentHash {
169    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
170    where
171        S: Serializer,
172    {
173        serializer.serialize_str(&self.to_string())
174    }
175}
176
177impl<'de> Deserialize<'de> for ContentHash {
178    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
179    where
180        D: Deserializer<'de>,
181    {
182        deserializer.deserialize_str(ContentHashVisitor)
183    }
184}
185
186struct ContentHashVisitor;
187
188impl de::Visitor<'_> for ContentHashVisitor {
189    type Value = ContentHash;
190
191    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192        formatter.write_str("a 64-character lowercase hexadecimal SHA-256 content hash")
193    }
194
195    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
196    where
197        E: de::Error,
198    {
199        ContentHash::from_str(value).map_err(E::custom)
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use std::time::Duration;
206
207    use super::{ContentHash, content_hash, content_hash_with_timeout};
208    use crate::{BeamModule, BeamSet, PackageError};
209
210    #[test]
211    fn content_hash_is_independent_of_insertion_order() -> Result<(), PackageError> {
212        let first = BeamSet::new(vec![
213            BeamModule::new("workflow/c", vec![3]),
214            BeamModule::new("workflow/a", vec![1]),
215            BeamModule::new("workflow/b", vec![2]),
216        ])?;
217        let second = BeamSet::new(vec![
218            BeamModule::new("workflow/b", vec![2]),
219            BeamModule::new("workflow/c", vec![3]),
220            BeamModule::new("workflow/a", vec![1]),
221        ])?;
222
223        assert_eq!(content_hash(&first), content_hash(&second));
224
225        Ok(())
226    }
227
228    #[test]
229    fn legacy_identity_remains_exactly_the_beams_only_hash() -> Result<(), PackageError> {
230        let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
231        let pre_change_rule = content_hash(&beams);
232        assert_eq!(content_hash(&beams), pre_change_rule);
233        Ok(())
234    }
235
236    #[test]
237    fn explicit_timeout_identity_is_deterministic_and_value_sensitive() -> Result<(), PackageError>
238    {
239        let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
240        let two_hours = content_hash_with_timeout(&beams, Duration::from_secs(7_200));
241        assert_eq!(
242            two_hours,
243            content_hash_with_timeout(&beams, Duration::from_secs(7_200))
244        );
245        assert_ne!(
246            two_hours,
247            content_hash_with_timeout(&beams, Duration::from_secs(21_600))
248        );
249        assert_ne!(
250            two_hours,
251            content_hash_with_timeout(&beams, Duration::new(7_200, 500_000_000))
252        );
253        assert_ne!(two_hours, content_hash(&beams));
254        Ok(())
255    }
256
257    #[test]
258    fn content_hash_changes_when_a_module_byte_changes() -> Result<(), PackageError> {
259        let original = BeamSet::new(vec![
260            BeamModule::new("workflow/a", vec![1, 2, 3]),
261            BeamModule::new("workflow/b", vec![4, 5, 6]),
262        ])?;
263        let changed = BeamSet::new(vec![
264            BeamModule::new("workflow/a", vec![1, 2, 3]),
265            BeamModule::new("workflow/b", vec![4, 5, 7]),
266        ])?;
267
268        assert_ne!(content_hash(&original), content_hash(&changed));
269
270        Ok(())
271    }
272
273    #[test]
274    fn content_hash_changes_when_a_module_name_changes() -> Result<(), PackageError> {
275        let original = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
276        let renamed = BeamSet::new(vec![BeamModule::new("workflow/renamed", vec![1, 2, 3])])?;
277
278        assert_ne!(content_hash(&original), content_hash(&renamed));
279
280        Ok(())
281    }
282
283    #[test]
284    fn content_hash_framing_prevents_name_bytes_boundary_ambiguity() -> Result<(), PackageError> {
285        let first = BeamSet::new(vec![BeamModule::new("ab", b"c".to_vec())])?;
286        let second = BeamSet::new(vec![BeamModule::new("a", b"bc".to_vec())])?;
287
288        assert_ne!(content_hash(&first), content_hash(&second));
289
290        Ok(())
291    }
292
293    #[test]
294    fn content_hash_text_round_trips() -> Result<(), PackageError> {
295        let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![0, 1, 2, 255])])?;
296        let hash = content_hash(&beams);
297        let text = hash.to_string();
298        let parsed = text.parse::<ContentHash>();
299
300        assert_eq!(text.len(), 64);
301        assert!(
302            text.bytes()
303                .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
304        );
305        assert_eq!(parsed, Ok(hash));
306
307        Ok(())
308    }
309
310    #[test]
311    fn content_hash_rejects_uppercase_text() {
312        let text = "A000000000000000000000000000000000000000000000000000000000000000";
313
314        assert!(text.parse::<ContentHash>().is_err());
315    }
316}