Skip to main content

sbom_tools/verification/
hash.rs

1//! File hash verification.
2//!
3//! Verifies SBOM file integrity against SHA-256, SHA-512, or other hash values.
4
5use std::fmt;
6use std::fs;
7use std::path::Path;
8
9use sha2::{Digest, Sha256, Sha512};
10
11/// Errors that can occur during hash verification
12#[derive(Debug, thiserror::Error)]
13pub enum HashError {
14    /// File I/O error
15    #[error(transparent)]
16    Io(#[from] std::io::Error),
17    /// Hash format not recognized
18    #[error(
19        "unrecognized hash format (length {length}), expected sha256:<hex> or sha512:<hex>, \
20         or a 64-char (SHA-256) or 128-char (SHA-512) hex string"
21    )]
22    UnrecognizedFormat {
23        /// Length of the provided hash string
24        length: usize,
25    },
26}
27
28/// Result of a file hash verification
29#[derive(Debug, Clone)]
30pub struct HashVerifyResult {
31    /// Whether the hash matched
32    pub verified: bool,
33    /// Algorithm used
34    pub algorithm: String,
35    /// Expected hash value
36    pub expected: String,
37    /// Actual computed hash value
38    pub actual: String,
39}
40
41impl fmt::Display for HashVerifyResult {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        if self.verified {
44            write!(f, "OK: {} hash verified", self.algorithm)
45        } else {
46            write!(
47                f,
48                "MISMATCH: {} hash\n  expected: {}\n  actual:   {}",
49                self.algorithm, self.expected, self.actual
50            )
51        }
52    }
53}
54
55/// Compute the SHA-256 of a file, streaming in fixed-size chunks.
56///
57/// Weight files reach tens of gigabytes, so this never buffers the whole file:
58/// it reads through a 1 MiB buffer and feeds the digest incrementally.
59///
60/// # Errors
61///
62/// Returns [`HashError::Io`] if the file cannot be opened or read.
63pub fn compute_file_sha256(path: &Path) -> Result<String, HashError> {
64    use std::io::Read;
65
66    let mut file = fs::File::open(path)?;
67    let mut hasher = Sha256::new();
68    let mut buf = vec![0u8; 1 << 20];
69    loop {
70        let n = file.read(&mut buf)?;
71        if n == 0 {
72            break;
73        }
74        hasher.update(&buf[..n]);
75    }
76    Ok(hasher
77        .finalize()
78        .iter()
79        .map(|b| format!("{b:02x}"))
80        .collect())
81}
82
83/// Verify a file's hash against an expected value.
84///
85/// Supports formats:
86/// - `sha256:<hex>` or `sha512:<hex>` (prefixed)
87/// - bare hex string (auto-detected by length: 64=SHA-256, 128=SHA-512)
88/// - `<hash>  <filename>` (sha256sum output format — hash portion extracted)
89///
90/// # Errors
91///
92/// Returns error if the file cannot be read or the hash format is unrecognized.
93pub fn verify_file_hash(path: &Path, expected: &str) -> Result<HashVerifyResult, HashError> {
94    let content = fs::read(path)?;
95    let expected = expected.trim();
96
97    // Parse hash file format: "<hash>  <filename>" or "<hash> <filename>"
98    let expected = if expected.contains(' ') {
99        expected.split_whitespace().next().unwrap_or(expected)
100    } else {
101        expected
102    };
103
104    // Detect algorithm from prefix or length (case-insensitive prefix)
105    let expected_lower = expected.to_lowercase();
106    let (algorithm, expected_hex) = if let Some(hex) = expected_lower.strip_prefix("sha256:") {
107        ("SHA-256", hex.to_string())
108    } else if let Some(hex) = expected_lower.strip_prefix("sha512:") {
109        ("SHA-512", hex.to_string())
110    } else {
111        match expected.len() {
112            64 => ("SHA-256", expected.to_string()),
113            128 => ("SHA-512", expected.to_string()),
114            _ => {
115                return Err(HashError::UnrecognizedFormat {
116                    length: expected.len(),
117                });
118            }
119        }
120    };
121
122    let actual_hex = match algorithm {
123        "SHA-256" => {
124            let mut hasher = Sha256::new();
125            hasher.update(&content);
126            hasher
127                .finalize()
128                .iter()
129                .map(|b| format!("{b:02x}"))
130                .collect::<String>()
131        }
132        "SHA-512" => {
133            let mut hasher = Sha512::new();
134            hasher.update(&content);
135            hasher
136                .finalize()
137                .iter()
138                .map(|b| format!("{b:02x}"))
139                .collect::<String>()
140        }
141        _ => unreachable!(),
142    };
143
144    let expected_lower = expected_hex.to_lowercase();
145    let verified = actual_hex == expected_lower;
146
147    Ok(HashVerifyResult {
148        verified,
149        algorithm: algorithm.to_string(),
150        expected: expected_lower,
151        actual: actual_hex,
152    })
153}
154
155/// Read a hash from a `.sha256` sidecar file.
156///
157/// Expects format: `<hex>  <filename>` or bare `<hex>`.
158///
159/// # Errors
160///
161/// Returns error if the file cannot be read.
162pub fn read_hash_file(path: &Path) -> Result<String, HashError> {
163    let content = fs::read_to_string(path)?;
164    let trimmed = content.trim();
165    let hash = trimmed.split_whitespace().next().unwrap_or(trimmed);
166    Ok(hash.to_string())
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::io::Write;
173    use tempfile::NamedTempFile;
174
175    #[test]
176    fn verify_sha256_match() {
177        let mut f = NamedTempFile::new().unwrap();
178        f.write_all(b"hello world").unwrap();
179        f.flush().unwrap();
180
181        // SHA-256 of "hello world"
182        let expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
183        let result = verify_file_hash(f.path(), expected).unwrap();
184        assert!(result.verified);
185        assert_eq!(result.algorithm, "SHA-256");
186    }
187
188    #[test]
189    fn verify_sha256_mismatch() {
190        let mut f = NamedTempFile::new().unwrap();
191        f.write_all(b"hello world").unwrap();
192        f.flush().unwrap();
193
194        let expected = "0000000000000000000000000000000000000000000000000000000000000000";
195        let result = verify_file_hash(f.path(), expected).unwrap();
196        assert!(!result.verified);
197    }
198
199    #[test]
200    fn verify_prefixed_sha256() {
201        let mut f = NamedTempFile::new().unwrap();
202        f.write_all(b"hello world").unwrap();
203        f.flush().unwrap();
204
205        let expected = "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
206        let result = verify_file_hash(f.path(), expected).unwrap();
207        assert!(result.verified);
208    }
209
210    #[test]
211    fn verify_sha256sum_file_format() {
212        let mut f = NamedTempFile::new().unwrap();
213        f.write_all(b"hello world").unwrap();
214        f.flush().unwrap();
215
216        let expected =
217            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9  somefile.json";
218        let result = verify_file_hash(f.path(), expected).unwrap();
219        assert!(result.verified);
220    }
221
222    #[test]
223    fn verify_bad_length() {
224        let mut f = NamedTempFile::new().unwrap();
225        f.write_all(b"test").unwrap();
226        f.flush().unwrap();
227
228        let result = verify_file_hash(f.path(), "abcdef");
229        assert!(result.is_err());
230    }
231
232    #[test]
233    fn read_hash_file_format() {
234        let mut f = NamedTempFile::new().unwrap();
235        writeln!(f, "abcd1234  sbom.json").unwrap();
236        f.flush().unwrap();
237
238        let hash = read_hash_file(f.path()).unwrap();
239        assert_eq!(hash, "abcd1234");
240    }
241}