openbim_ifcx/layers/
integrity.rs

1//! `integrity` values on imports; see [`check_integrity`].
2
3use std::fmt;
4
5/// Why an `integrity` value rejected a layer.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum IntegrityError {
8    /// The digest of the layer matches no token of the strongest algorithm.
9    Mismatch {
10        expected: String,
11        /// The layer's digest as `<alg>-<base64>`.
12        actual: String,
13    },
14    /// The value is empty or a token is not `<alg>-<base64>`.
15    Malformed { value: String },
16    /// No token uses a supported algorithm.
17    Unsupported { value: String },
18    /// The crate was built without the `integrity` feature, so the value
19    /// cannot be checked.
20    Unavailable { value: String },
21}
22
23impl fmt::Display for IntegrityError {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::Mismatch { expected, actual } => {
27                write!(
28                    f,
29                    "integrity mismatch: expected {expected:?}, got {actual:?}"
30                )
31            }
32            Self::Malformed { value } => write!(f, "malformed integrity value {value:?}"),
33            Self::Unsupported { value } => write!(
34                f,
35                "integrity value {value:?} uses no supported algorithm (sha256, sha384, sha512)"
36            ),
37            Self::Unavailable { value } => write!(
38                f,
39                "cannot check integrity value {value:?}: built without the `integrity` feature"
40            ),
41        }
42    }
43}
44
45impl std::error::Error for IntegrityError {}
46
47/// Checks `bytes` against an import's `integrity` value.
48///
49/// The `ifcx_alpha` draft declares `integrity?: string` on an import and
50/// defines no format; upstream's providers leave checking as a `TODO` and no
51/// upstream example sets the field. This crate reads it as W3C Subresource
52/// Integrity metadata: one or more whitespace-separated `<alg>-<base64>`
53/// tokens, where `<alg>` is `sha256`, `sha384`, or `sha512` and `<base64>` is
54/// the standard, padded base64 digest of the layer's bytes exactly as the
55/// resolver returns them. As in SRI, only tokens of the strongest algorithm
56/// present count, and one matching token suffices; `?options` after a digest
57/// are ignored.
58///
59/// Unlike SRI, which treats unusable metadata as absent, a value that is
60/// empty, malformed, or names only unknown algorithms is an error, so a layer
61/// is never loaded unchecked when its importer asked for a check. Built
62/// without the `integrity` feature, every value fails with
63/// [`IntegrityError::Unavailable`] for the same reason.
64///
65/// ```
66/// use openbim_ifcx::layers::check_integrity;
67///
68/// // sha256 of the empty input.
69/// let value = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
70/// # #[cfg(feature = "integrity")]
71/// assert!(check_integrity(value, b"").is_ok());
72/// assert!(check_integrity(value, b"x").is_err());
73/// ```
74pub fn check_integrity(value: &str, bytes: &[u8]) -> Result<(), IntegrityError> {
75    imp::check(value, bytes)
76}
77
78#[cfg(feature = "integrity")]
79mod imp {
80    use base64::engine::general_purpose::STANDARD;
81    use base64::Engine as _;
82    use sha2::{Digest, Sha256, Sha384, Sha512};
83
84    use super::IntegrityError;
85
86    #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
87    enum Algorithm {
88        Sha256,
89        Sha384,
90        Sha512,
91    }
92
93    impl Algorithm {
94        fn parse(name: &str) -> Option<Self> {
95            match name {
96                "sha256" => Some(Self::Sha256),
97                "sha384" => Some(Self::Sha384),
98                "sha512" => Some(Self::Sha512),
99                _ => None,
100            }
101        }
102
103        fn name(self) -> &'static str {
104            match self {
105                Self::Sha256 => "sha256",
106                Self::Sha384 => "sha384",
107                Self::Sha512 => "sha512",
108            }
109        }
110
111        fn digest(self, bytes: &[u8]) -> Vec<u8> {
112            match self {
113                Self::Sha256 => Sha256::digest(bytes).to_vec(),
114                Self::Sha384 => Sha384::digest(bytes).to_vec(),
115                Self::Sha512 => Sha512::digest(bytes).to_vec(),
116            }
117        }
118    }
119
120    pub(super) fn check(value: &str, bytes: &[u8]) -> Result<(), IntegrityError> {
121        let malformed = || IntegrityError::Malformed {
122            value: value.to_owned(),
123        };
124        let mut tokens = Vec::new();
125        for token in value.split_ascii_whitespace() {
126            let (name, rest) = token.split_once('-').ok_or_else(malformed)?;
127            let digest = rest.split_once('?').map_or(rest, |(digest, _)| digest);
128            let digest = STANDARD.decode(digest).map_err(|_| malformed())?;
129            if let Some(algorithm) = Algorithm::parse(name) {
130                tokens.push((algorithm, digest));
131            }
132        }
133        if value.split_ascii_whitespace().next().is_none() {
134            return Err(malformed());
135        }
136        let Some(strongest) = tokens.iter().map(|(a, _)| *a).max() else {
137            return Err(IntegrityError::Unsupported {
138                value: value.to_owned(),
139            });
140        };
141        let actual = strongest.digest(bytes);
142        if tokens
143            .iter()
144            .any(|(a, digest)| *a == strongest && *digest == actual)
145        {
146            Ok(())
147        } else {
148            Err(IntegrityError::Mismatch {
149                expected: value.to_owned(),
150                actual: format!("{}-{}", strongest.name(), STANDARD.encode(actual)),
151            })
152        }
153    }
154}
155
156#[cfg(not(feature = "integrity"))]
157mod imp {
158    use super::IntegrityError;
159
160    pub(super) fn check(value: &str, _bytes: &[u8]) -> Result<(), IntegrityError> {
161        Err(IntegrityError::Unavailable {
162            value: value.to_owned(),
163        })
164    }
165}
166
167#[cfg(all(test, feature = "integrity"))]
168mod tests {
169    use super::{check_integrity, IntegrityError};
170
171    // Digests of b"abc".
172    const SHA256: &str = "sha256-ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=";
173    const SHA512: &str =
174        "sha512-3a81oZNherrMQXNJriBBMRLm+k6JqX6iCp7u5ktV05ohkpkqJ0/BqDa6PCOj/uu9RU1EI2Q86A4qmslPpUyknw==";
175
176    #[test]
177    fn accepts_matching_digests() {
178        assert_eq!(check_integrity(SHA256, b"abc"), Ok(()));
179        assert_eq!(check_integrity(SHA512, b"abc"), Ok(()));
180        assert_eq!(check_integrity(&format!("{SHA256}?opt"), b"abc"), Ok(()));
181        assert_eq!(check_integrity(&format!(" {SHA256}\n"), b"abc"), Ok(()));
182        assert_eq!(
183            check_integrity(&format!("md5-AAAA {SHA256}"), b"abc"),
184            Ok(())
185        );
186    }
187
188    #[test]
189    fn strongest_algorithm_decides() {
190        let wrong512 = format!("sha512-{}", "A".repeat(86) + "==");
191        let value = format!("{SHA256} {wrong512}");
192        assert!(matches!(
193            check_integrity(&value, b"abc"),
194            Err(IntegrityError::Mismatch { actual, .. }) if actual == SHA512
195        ));
196    }
197
198    #[test]
199    fn rejects_bad_values() {
200        assert!(matches!(
201            check_integrity(SHA256, b"abd"),
202            Err(IntegrityError::Mismatch { actual, .. }) if actual.starts_with("sha256-")
203        ));
204        for value in ["", "  ", "sha256", "sha256-not base64!"] {
205            assert!(
206                matches!(
207                    check_integrity(value, b"abc"),
208                    Err(IntegrityError::Malformed { .. })
209                ),
210                "{value:?}"
211            );
212        }
213        assert!(matches!(
214            check_integrity("md5-AAAA", b"abc"),
215            Err(IntegrityError::Unsupported { .. })
216        ));
217    }
218}