Skip to main content

ojak/portable/
hashlink.rs

1//! Media of portable objects (FEP-ef61): named by what they are, not where.
2//!
3//! A portable object that refers to an image or any other resource carries
4//! its SHA-256 digest, `digestMultibase`, and names it, as FEP-ef61 advises,
5//! by a hashlink, `hl:zQm…`, the same digest as a URI. A gateway serves it
6//! at `/.well-known/apgateway/hl:zQm…`, and whoever fetches it checks what
7//! came back against the digest, so a gateway is again a location and not
8//! an authority.
9
10use alloc::string::String;
11use alloc::vec::Vec;
12use core::fmt;
13use sha2::{Digest, Sha256};
14
15/// The multihash code of SHA-256, and the length of its digest.
16const SHA2_256: [u8; 2] = [0x12, 0x20];
17
18/// A resource a portable object refers to, as a gateway serves it.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct Media {
21    /// Its media type, `image/png`.
22    pub content_type: String,
23    pub bytes: Vec<u8>,
24}
25
26/// A SHA-256 digest as a hashlink names it and `digestMultibase` writes it:
27/// a multihash, in base58btc.
28#[derive(Clone, Debug, Eq, PartialEq, Hash)]
29pub struct Hashlink {
30    digest: [u8; 32],
31}
32
33impl Hashlink {
34    /// The hashlink of `bytes`.
35    #[must_use]
36    pub fn of(bytes: &[u8]) -> Self {
37        Self {
38            digest: Sha256::digest(bytes).into(),
39        }
40    }
41
42    /// Read a hashlink, `hl:zQm…`. `None` when it is not one of a SHA-256
43    /// digest, or carries metadata, which gateways do not serve by.
44    #[must_use]
45    pub fn parse(hashlink: &str) -> Option<Self> {
46        let (scheme, digest) = hashlink.split_once(':')?;
47        if !scheme.eq_ignore_ascii_case("hl") {
48            return None;
49        }
50        Self::from_multibase(digest)
51    }
52
53    /// Read a `digestMultibase`, `zQm…`. `None` when it is not a SHA-256
54    /// multihash in base58btc, the only digest FEP-ef61 allows.
55    #[must_use]
56    pub fn from_multibase(multibase: &str) -> Option<Self> {
57        let encoded = multibase.strip_prefix('z')?;
58        let decoded: Vec<u8> = bs58::decode(encoded).into_vec().ok()?;
59        let digest = decoded.strip_prefix(&SHA2_256)?;
60        Some(Self {
61            digest: digest.try_into().ok()?,
62        })
63    }
64
65    /// The digest itself.
66    #[must_use]
67    pub fn digest(&self) -> &[u8; 32] {
68        &self.digest
69    }
70
71    /// The digest as `digestMultibase` writes it, `zQm…`.
72    #[must_use]
73    pub fn multibase(&self) -> String {
74        let mut multihash = Vec::with_capacity(34);
75        multihash.extend_from_slice(&SHA2_256);
76        multihash.extend_from_slice(&self.digest);
77        let mut multibase = String::from("z");
78        multibase.push_str(&bs58::encode(multihash).into_string());
79        multibase
80    }
81
82    /// Whether `bytes` are what this names.
83    #[must_use]
84    pub fn matches(&self, bytes: &[u8]) -> bool {
85        Self::of(bytes) == *self
86    }
87}
88
89impl fmt::Display for Hashlink {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        write!(f, "hl:{}", self.multibase())
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    /// FEP-ef61's own example: the same digest, as a hashlink and as a
100    /// `digestMultibase`.
101    const EXAMPLE: &str = "zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n";
102
103    #[test]
104    fn a_hashlink_is_its_digest() {
105        let link = Hashlink::parse(&alloc::format!("hl:{EXAMPLE}")).unwrap();
106        assert_eq!(link, Hashlink::from_multibase(EXAMPLE).unwrap());
107        assert_eq!(link.multibase(), EXAMPLE);
108        assert_eq!(link.to_string(), alloc::format!("hl:{EXAMPLE}"));
109    }
110
111    #[test]
112    fn bytes_are_what_their_digest_names() {
113        let link = Hashlink::of(b"hello");
114        assert!(link.matches(b"hello"));
115        assert!(!link.matches(b"hello!"));
116        // The SHA-256 multihash of "hello", in base58btc.
117        assert_eq!(
118            link.multibase(),
119            "zQmRN6wdp1S2A5EtjW9A3M1vKSBuQQGcgvuhoMUoEz4iiT5"
120        );
121        assert_eq!(Hashlink::parse(&link.to_string()), Some(link));
122    }
123
124    #[test]
125    fn what_is_not_a_hashlink() {
126        for hashlink in [
127            "",
128            "hl:",
129            EXAMPLE,
130            &alloc::format!("https:{EXAMPLE}"),
131            &alloc::format!("hl:{}", &EXAMPLE[1..]),
132            &alloc::format!(
133                "hl:{EXAMPLE}:zCwPSdabLuj3jue1qYujzunnKwpL4myKdyeqySyFhnzZ8qdfW3bb6W8dVdRu"
134            ),
135            // SHA-1, and a truncated SHA-256.
136            "hl:z5dtAYsVQU6SojhprzGZSsEvMGpE1VW",
137            "hl:zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1",
138        ] {
139            assert!(Hashlink::parse(hashlink).is_none(), "{hashlink}");
140        }
141    }
142}