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";
13const WORKFLOW_TIMEOUTS_DOMAIN: &[u8] = b"aion.package.version.workflow-timeouts.v3";
14
15/// A SHA-256 package version identity.
16///
17/// Legacy identities cover each module's logical name and exact `.beam` bytes
18/// in [`BeamSet`] canonical order. Explicit-timeout identities append a
19/// domain-separated timeout encoding. Archive representation and optional
20/// source inclusion never participate, so deterministic inputs keep one version.
21///
22/// Its stable textual form is 64 lowercase hexadecimal characters. That text is
23/// the package version identifier stored in the manifest and the hash component
24/// embedded in namespaced deployed module names; it contains only `0-9a-f`,
25/// which is safe for a BEAM module-name component.
26#[derive(Clone, Debug, PartialEq, Eq, Hash)]
27pub struct ContentHash([u8; DIGEST_LEN]);
28
29impl ContentHash {
30    /// Creates a content hash from raw SHA-256 digest bytes.
31    #[must_use]
32    pub const fn from_bytes(bytes: [u8; DIGEST_LEN]) -> Self {
33        Self(bytes)
34    }
35
36    /// Returns the raw SHA-256 digest bytes.
37    #[must_use]
38    pub const fn as_bytes(&self) -> &[u8; DIGEST_LEN] {
39        &self.0
40    }
41}
42
43/// Errors produced when parsing a [`ContentHash`] textual form.
44#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
45pub enum ContentHashParseError {
46    /// The text was not exactly 64 ASCII hexadecimal characters.
47    #[error("content hash text must be 64 lowercase hexadecimal characters, found {found} bytes")]
48    InvalidLength {
49        /// Number of bytes found in the supplied text.
50        found: usize,
51    },
52
53    /// The text contained a character outside lowercase hexadecimal.
54    #[error("content hash text contains non-lowercase-hex byte 0x{byte:02x} at byte index {index}")]
55    InvalidCharacter {
56        /// Byte index of the invalid character.
57        index: usize,
58        /// Invalid byte found at `index`.
59        byte: u8,
60    },
61}
62
63/// Computes the package version hash over the canonical BEAM set only.
64///
65/// The SHA-256 algorithm is mandated by the `.aion` format contract so packers
66/// and loaders on different hosts agree. Each module contributes its logical
67/// name and exact bytes in [`BeamSet`] canonical order, with each field framed by
68/// an eight-byte big-endian length prefix. This unambiguous framing prevents a
69/// shifted name/body boundary from producing the same digest.
70#[must_use]
71pub fn content_hash(beams: &BeamSet) -> ContentHash {
72    let mut digest = Sha256::new();
73    update_beams(&mut digest, beams);
74    ContentHash(digest.finalize().into())
75}
76
77/// Computes an explicit-timeout package version from the canonical BEAM set,
78/// then the framed ASCII domain `aion.package.version.workflow-timeout.v1`,
79/// then exactly 12 timeout bytes: seconds as `u64` big-endian followed by
80/// subsecond nanoseconds as `u32` big-endian.
81///
82/// This single-entry form is retained for external callers; the per-entry
83/// [`content_hash_with_timeouts`] is the authority the loader trusts, because it
84/// binds every workflow entry's timeout — not only the primary — into identity.
85#[must_use]
86pub fn content_hash_with_timeout(beams: &BeamSet, timeout: Duration) -> ContentHash {
87    let mut digest = Sha256::new();
88    update_beams(&mut digest, beams);
89    update_framed(&mut digest, WORKFLOW_TIMEOUT_DOMAIN);
90    digest.update(timeout.as_secs().to_be_bytes());
91    digest.update(timeout.subsec_nanos().to_be_bytes());
92    ContentHash(digest.finalize().into())
93}
94
95/// Computes the per-entry timeout-bearing package version over the canonical
96/// BEAM set, then the framed ASCII domain
97/// `aion.package.version.workflow-timeouts.v3`, then a UNIFORM canonical encoding
98/// that treats the primary entry and every additional entry alike: the total
99/// entry count as `u64` big-endian, then — for the primary first and each
100/// additional entry in manifest order — the entry's framed routing identity
101/// followed by its authored timeout.
102///
103/// The framed routing identity is `manifest.entry_module` for the primary (the
104/// module the loader selects at `load.rs`) and `workflow_type` for each
105/// additional entry (its start/child-spawn routing name). Binding the primary's
106/// routing identity is what closes the v2 gap: re-pointing `entry_module` to a
107/// different module in the same beam closure re-routes entry selection, so it
108/// MUST change identity or an authenticated timeout could be reassigned to
109/// another workflow entry under an unchanged version.
110///
111/// Each authored timeout is encoded presence-first (a single `1`/`0` byte),
112/// followed — only when present — by seconds as `u64` big-endian and subsecond
113/// nanoseconds as `u32` big-endian. Binding presence AND value AND the entry's
114/// own routing identity, for every entry uniformly, means no entry's timeout or
115/// routing can be swapped, added, or removed without changing the version hash:
116/// declaredness is an authenticated per-entry property, never a package-wide
117/// inference from the primary alone.
118///
119/// This supersedes the pre-release `.v2` layout (which bound only the primary's
120/// timeout, not its routing identity); a `.v2`-stamped archive therefore decodes
121/// as wholly undeclared, exactly like any non-matching identity.
122#[must_use]
123pub fn content_hash_with_timeouts(beams: &BeamSet, manifest: &Manifest) -> ContentHash {
124    let mut digest = Sha256::new();
125    update_beams(&mut digest, beams);
126    update_framed(&mut digest, WORKFLOW_TIMEOUTS_DOMAIN);
127    let entry_count = 1 + manifest.additional_workflows.len() as u64;
128    digest.update(entry_count.to_be_bytes());
129    // The primary entry, framed by the module the loader routes to.
130    update_framed(&mut digest, manifest.entry_module.as_bytes());
131    update_timeout_field(&mut digest, manifest.timeout);
132    for entry in &manifest.additional_workflows {
133        update_framed(&mut digest, entry.workflow_type.as_bytes());
134        update_timeout_field(&mut digest, entry.timeout);
135    }
136    ContentHash(digest.finalize().into())
137}
138
139/// Whether this package's version identity commits to explicitly authored
140/// per-entry workflow timeouts.
141///
142/// True only when the stored content hash is the domain-separated per-entry
143/// timeout-bearing `.v3` identity ([`content_hash_with_timeouts`]) — never the
144/// beams-only legacy identity, and never a superseded pre-release `.v1`/`.v2`
145/// identity. A legacy (beams-only) archive, a pre-release single-value archive,
146/// or one whose routing/additional entries were not uniformly bound therefore
147/// reads as wholly NOT declared: no entry can arm a deadline. The check is
148/// tamper-evident: the timeout value returned by [`crate::Package`] for any
149/// entry is provably the one baked into the version hash, so a hand-edited or
150/// injected per-entry timeout that was not part of the identity cannot fake
151/// declaredness.
152pub(crate) fn has_explicit_timeout_identity(
153    beams: &BeamSet,
154    manifest: &Manifest,
155    hash: &ContentHash,
156) -> bool {
157    hash != &content_hash(beams) && hash == &content_hash_with_timeouts(beams, manifest)
158}
159
160/// Verifies the stored manifest version against the recomputed identities and
161/// returns the matching hash, or an integrity error.
162///
163/// Three forms load. The beams-only legacy identity and the per-entry `.v3`
164/// timeout-bearing identity both hold for freshly written archives; only the
165/// `.v3` form makes [`has_explicit_timeout_identity`] true (declaring).
166///
167/// The third is a migration accommodation: a pre-release `.v1` single-value
168/// identity ([`content_hash_with_timeout`], stamped only when a primary timeout
169/// was present) is accepted as INTEGRITY-VALID BUT WHOLLY UNDECLARING. Its beam
170/// closure is still authenticated by the `.v1` hash, so loading it is honest;
171/// but it did not bind routing identity or additional entries under the current
172/// law, so it is untrustworthy as a per-entry declaration and every entry reads
173/// undeclared (nothing arms). This lets a `.v1`-stamped deployment recover on
174/// restart instead of being skipped, without ever arming a deadline whose
175/// authorship the current identity cannot vouch for. A `.v2` archive (never
176/// released, and which likewise did not bind routing identity) is deliberately
177/// NOT accommodated: it matches none of these forms and is rejected.
178pub(crate) fn verified_content_hash(
179    beams: &BeamSet,
180    manifest: &Manifest,
181) -> Result<ContentHash, PackageError> {
182    let legacy_hash = content_hash(beams);
183    let stored = manifest.version.as_str();
184    if stored == legacy_hash.to_string() {
185        return Ok(legacy_hash);
186    }
187    let timeouts_hash = content_hash_with_timeouts(beams, manifest);
188    if stored == timeouts_hash.to_string() {
189        return Ok(timeouts_hash);
190    }
191    // Migration: a pre-release `.v1` single-primary-timeout archive is accepted
192    // as integrity-valid but non-declaring. `has_explicit_timeout_identity`
193    // returns false for it (it is not the `.v3` hash), so it loads yet arms
194    // nothing.
195    if let Some(primary) = manifest.timeout {
196        let v1_hash = content_hash_with_timeout(beams, primary);
197        if stored == v1_hash.to_string() {
198            return Ok(v1_hash);
199        }
200    }
201    Err(PackageError::IntegrityMismatch {
202        expected: stored.to_owned(),
203        computed: legacy_hash.to_string(),
204    })
205}
206
207/// Frames one entry's optional authored timeout into the digest: a presence
208/// byte, then seconds (`u64` big-endian) and subsecond nanoseconds (`u32`
209/// big-endian) only when a timeout is present. An absent timeout contributes
210/// exactly the `0` presence byte, so presence and value are both bound.
211fn update_timeout_field(digest: &mut Sha256, timeout: Option<Duration>) {
212    match timeout {
213        Some(timeout) => {
214            digest.update([1_u8]);
215            digest.update(timeout.as_secs().to_be_bytes());
216            digest.update(timeout.subsec_nanos().to_be_bytes());
217        }
218        None => digest.update([0_u8]),
219    }
220}
221
222fn update_beams(digest: &mut Sha256, beams: &BeamSet) {
223    for module in beams.iter() {
224        update_framed(digest, module.name().as_bytes());
225        update_framed(digest, module.bytes());
226    }
227}
228
229fn update_framed(digest: &mut Sha256, bytes: &[u8]) {
230    let length = bytes.len() as u64;
231    digest.update(length.to_be_bytes().as_slice());
232    digest.update(bytes);
233}
234
235impl fmt::Display for ContentHash {
236    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
237        for byte in &self.0 {
238            write!(formatter, "{byte:02x}")?;
239        }
240
241        Ok(())
242    }
243}
244
245impl FromStr for ContentHash {
246    type Err = ContentHashParseError;
247
248    fn from_str(text: &str) -> Result<Self, Self::Err> {
249        let bytes = text.as_bytes();
250        if bytes.len() != TEXT_LEN {
251            return Err(ContentHashParseError::InvalidLength { found: bytes.len() });
252        }
253
254        let mut digest = [0_u8; DIGEST_LEN];
255        for (index, pair) in bytes.chunks_exact(2).enumerate() {
256            let high_index = index * 2;
257            let low_index = high_index + 1;
258            digest[index] = (hex_value(pair[0], high_index)? << 4) | hex_value(pair[1], low_index)?;
259        }
260
261        Ok(Self(digest))
262    }
263}
264
265fn hex_value(byte: u8, index: usize) -> Result<u8, ContentHashParseError> {
266    match byte {
267        b'0'..=b'9' => Ok(byte - b'0'),
268        b'a'..=b'f' => Ok(byte - b'a' + 10),
269        _ => Err(ContentHashParseError::InvalidCharacter { index, byte }),
270    }
271}
272
273impl Serialize for ContentHash {
274    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
275    where
276        S: Serializer,
277    {
278        serializer.serialize_str(&self.to_string())
279    }
280}
281
282impl<'de> Deserialize<'de> for ContentHash {
283    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
284    where
285        D: Deserializer<'de>,
286    {
287        deserializer.deserialize_str(ContentHashVisitor)
288    }
289}
290
291struct ContentHashVisitor;
292
293impl de::Visitor<'_> for ContentHashVisitor {
294    type Value = ContentHash;
295
296    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
297        formatter.write_str("a 64-character lowercase hexadecimal SHA-256 content hash")
298    }
299
300    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
301    where
302        E: de::Error,
303    {
304        ContentHash::from_str(value).map_err(E::custom)
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use std::time::Duration;
311
312    use serde_json::json;
313
314    use super::{
315        ContentHash, content_hash, content_hash_with_timeout, content_hash_with_timeouts,
316        has_explicit_timeout_identity, verified_content_hash,
317    };
318    use crate::{
319        BeamModule, BeamSet, CURRENT_FORMAT_VERSION, Manifest, ManifestVersion, PackageError,
320        WorkflowEntry,
321    };
322
323    fn manifest_with(primary: Option<Duration>, additional: Vec<WorkflowEntry>) -> Manifest {
324        Manifest {
325            entry_module: "workflow/a".to_owned(),
326            entry_function: "run".to_owned(),
327            input_schema: json!({ "type": "object" }),
328            output_schema: json!({ "type": "object" }),
329            timeout: primary,
330            activities: Vec::new(),
331            version: ManifestVersion::new("unstamped"),
332            format_version: CURRENT_FORMAT_VERSION,
333            additional_workflows: additional,
334        }
335    }
336
337    fn additional_entry(workflow_type: &str, timeout: Option<Duration>) -> WorkflowEntry {
338        WorkflowEntry {
339            workflow_type: workflow_type.to_owned(),
340            entry_module: "workflow/a".to_owned(),
341            entry_function: format!("{workflow_type}_run"),
342            input_schema: json!({ "type": "object" }),
343            output_schema: json!({ "type": "object" }),
344            timeout,
345            internal: true,
346        }
347    }
348
349    #[test]
350    fn content_hash_is_independent_of_insertion_order() -> Result<(), PackageError> {
351        let first = BeamSet::new(vec![
352            BeamModule::new("workflow/c", vec![3]),
353            BeamModule::new("workflow/a", vec![1]),
354            BeamModule::new("workflow/b", vec![2]),
355        ])?;
356        let second = BeamSet::new(vec![
357            BeamModule::new("workflow/b", vec![2]),
358            BeamModule::new("workflow/c", vec![3]),
359            BeamModule::new("workflow/a", vec![1]),
360        ])?;
361
362        assert_eq!(content_hash(&first), content_hash(&second));
363
364        Ok(())
365    }
366
367    #[test]
368    fn legacy_identity_remains_exactly_the_beams_only_hash() -> Result<(), PackageError> {
369        let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
370        let pre_change_rule = content_hash(&beams);
371        assert_eq!(content_hash(&beams), pre_change_rule);
372        Ok(())
373    }
374
375    #[test]
376    fn explicit_timeout_identity_is_deterministic_and_value_sensitive() -> Result<(), PackageError>
377    {
378        let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
379        let two_hours = content_hash_with_timeout(&beams, Duration::from_secs(7_200));
380        assert_eq!(
381            two_hours,
382            content_hash_with_timeout(&beams, Duration::from_secs(7_200))
383        );
384        assert_ne!(
385            two_hours,
386            content_hash_with_timeout(&beams, Duration::from_secs(21_600))
387        );
388        assert_ne!(
389            two_hours,
390            content_hash_with_timeout(&beams, Duration::new(7_200, 500_000_000))
391        );
392        assert_ne!(two_hours, content_hash(&beams));
393        Ok(())
394    }
395
396    #[test]
397    fn per_entry_identity_binds_every_additional_entry_timeout() -> Result<(), PackageError> {
398        let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
399        let base = manifest_with(
400            Some(Duration::from_secs(60)),
401            vec![additional_entry("child", Some(Duration::from_secs(30)))],
402        );
403
404        // Changing an additional entry's timeout value changes the identity.
405        let changed_value = manifest_with(
406            Some(Duration::from_secs(60)),
407            vec![additional_entry("child", Some(Duration::from_secs(31)))],
408        );
409        assert_ne!(
410            content_hash_with_timeouts(&beams, &base),
411            content_hash_with_timeouts(&beams, &changed_value),
412        );
413
414        // Adding an unbound additional timeout (the mixed-archive attack) changes
415        // the identity: it cannot ride the primary's declaredness.
416        let injected = manifest_with(
417            Some(Duration::from_secs(60)),
418            vec![additional_entry("child", Some(Duration::from_secs(3_600)))],
419        );
420        assert_ne!(
421            content_hash_with_timeouts(&beams, &base),
422            content_hash_with_timeouts(&beams, &injected),
423        );
424
425        // Presence alone (Some vs None) changes the identity.
426        let absent = manifest_with(
427            Some(Duration::from_secs(60)),
428            vec![additional_entry("child", None)],
429        );
430        assert_ne!(
431            content_hash_with_timeouts(&beams, &base),
432            content_hash_with_timeouts(&beams, &absent),
433        );
434        Ok(())
435    }
436
437    #[test]
438    fn v3_identity_binds_the_primary_routing_identity() -> Result<(), PackageError> {
439        // Two modules in one closure. Re-pointing the primary `entry_module`
440        // from one to the other re-routes entry selection, so it MUST change the
441        // version identity — otherwise an authenticated primary timeout could be
442        // reassigned to a different selected workflow (the v2 blocker).
443        let beams = BeamSet::new(vec![
444            BeamModule::new("workflow/a", vec![1, 2, 3]),
445            BeamModule::new("workflow/b", vec![4, 5, 6]),
446        ])?;
447        let on_a = manifest_with(Some(Duration::from_secs(60)), Vec::new());
448        let mut on_b = on_a.clone();
449        on_b.entry_module = "workflow/b".to_owned();
450        assert_ne!(
451            content_hash_with_timeouts(&beams, &on_a),
452            content_hash_with_timeouts(&beams, &on_b),
453            "re-routing the primary entry_module changes identity",
454        );
455        // The stored `.v3` hash for A does not authenticate B's selection: with
456        // B routed, A's stored identity reads as undeclared.
457        let stored_for_a = content_hash_with_timeouts(&beams, &on_a);
458        assert!(!has_explicit_timeout_identity(&beams, &on_b, &stored_for_a));
459        assert!(has_explicit_timeout_identity(&beams, &on_a, &stored_for_a));
460        Ok(())
461    }
462
463    #[test]
464    fn v1_single_value_archive_loads_but_reads_undeclared() -> Result<(), PackageError> {
465        // A pre-release `.v1` single-primary-timeout archive is accepted by
466        // `verified_content_hash` (its beam closure is authenticated) but is
467        // wholly undeclared: nothing arms. This keeps a `.v1`-stamped deployment
468        // loadable on restart without arming a timeout the current identity law
469        // cannot vouch for.
470        let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
471        let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
472        let v1 = content_hash_with_timeout(&beams, Duration::from_secs(60));
473        manifest.version = ManifestVersion::new(v1.to_string());
474        assert_eq!(verified_content_hash(&beams, &manifest)?, v1);
475        assert!(!has_explicit_timeout_identity(&beams, &manifest, &v1));
476        Ok(())
477    }
478
479    #[test]
480    fn non_v1_non_v3_timeout_identity_is_rejected() -> Result<(), PackageError> {
481        // Any non-legacy, non-`.v1`, non-`.v3` stored value (the pre-release
482        // `.v2` shape among them) matches none of the accepted forms and is
483        // rejected rather than silently loaded.
484        let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
485        let mut manifest = manifest_with(Some(Duration::from_secs(60)), Vec::new());
486        manifest.version = ManifestVersion::new("f".repeat(64));
487        assert!(matches!(
488            verified_content_hash(&beams, &manifest),
489            Err(PackageError::IntegrityMismatch { .. })
490        ));
491        Ok(())
492    }
493
494    #[test]
495    fn mixed_archive_with_injected_additional_timeout_reads_as_undeclared()
496    -> Result<(), PackageError> {
497        // A package whose stored hash bound ONLY the primary timeout (the old
498        // single-entry identity) but which carries an additional entry with an
499        // unauthenticated `Some(1h)` must read as wholly undeclared under the
500        // per-entry identity: the stored hash matches neither the legacy nor the
501        // per-entry timeout-bearing hash.
502        let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
503        let manifest = manifest_with(
504            Some(Duration::from_secs(60)),
505            vec![additional_entry("child", Some(Duration::from_secs(3_600)))],
506        );
507        // The attacker stamps the primary-only identity as the version.
508        let primary_only = content_hash_with_timeout(&beams, Duration::from_secs(60));
509        assert!(
510            !has_explicit_timeout_identity(&beams, &manifest, &primary_only),
511            "an injected additional timeout cannot ride the primary-only identity"
512        );
513        // The beams-only legacy identity is likewise undeclared.
514        assert!(!has_explicit_timeout_identity(
515            &beams,
516            &manifest,
517            &content_hash(&beams)
518        ));
519        // Only the full per-entry identity authenticates every entry.
520        assert!(has_explicit_timeout_identity(
521            &beams,
522            &manifest,
523            &content_hash_with_timeouts(&beams, &manifest)
524        ));
525        Ok(())
526    }
527
528    #[test]
529    fn content_hash_changes_when_a_module_byte_changes() -> Result<(), PackageError> {
530        let original = BeamSet::new(vec![
531            BeamModule::new("workflow/a", vec![1, 2, 3]),
532            BeamModule::new("workflow/b", vec![4, 5, 6]),
533        ])?;
534        let changed = BeamSet::new(vec![
535            BeamModule::new("workflow/a", vec![1, 2, 3]),
536            BeamModule::new("workflow/b", vec![4, 5, 7]),
537        ])?;
538
539        assert_ne!(content_hash(&original), content_hash(&changed));
540
541        Ok(())
542    }
543
544    #[test]
545    fn content_hash_changes_when_a_module_name_changes() -> Result<(), PackageError> {
546        let original = BeamSet::new(vec![BeamModule::new("workflow/a", vec![1, 2, 3])])?;
547        let renamed = BeamSet::new(vec![BeamModule::new("workflow/renamed", vec![1, 2, 3])])?;
548
549        assert_ne!(content_hash(&original), content_hash(&renamed));
550
551        Ok(())
552    }
553
554    #[test]
555    fn content_hash_framing_prevents_name_bytes_boundary_ambiguity() -> Result<(), PackageError> {
556        let first = BeamSet::new(vec![BeamModule::new("ab", b"c".to_vec())])?;
557        let second = BeamSet::new(vec![BeamModule::new("a", b"bc".to_vec())])?;
558
559        assert_ne!(content_hash(&first), content_hash(&second));
560
561        Ok(())
562    }
563
564    #[test]
565    fn content_hash_text_round_trips() -> Result<(), PackageError> {
566        let beams = BeamSet::new(vec![BeamModule::new("workflow/a", vec![0, 1, 2, 255])])?;
567        let hash = content_hash(&beams);
568        let text = hash.to_string();
569        let parsed = text.parse::<ContentHash>();
570
571        assert_eq!(text.len(), 64);
572        assert!(
573            text.bytes()
574                .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
575        );
576        assert_eq!(parsed, Ok(hash));
577
578        Ok(())
579    }
580
581    #[test]
582    fn content_hash_rejects_uppercase_text() {
583        let text = "A000000000000000000000000000000000000000000000000000000000000000";
584
585        assert!(text.parse::<ContentHash>().is_err());
586    }
587}