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/// Verify a file's hash against an expected value.
56///
57/// Supports formats:
58/// - `sha256:<hex>` or `sha512:<hex>` (prefixed)
59/// - bare hex string (auto-detected by length: 64=SHA-256, 128=SHA-512)
60/// - `<hash>  <filename>` (sha256sum output format — hash portion extracted)
61///
62/// # Errors
63///
64/// Returns error if the file cannot be read or the hash format is unrecognized.
65pub fn verify_file_hash(path: &Path, expected: &str) -> Result<HashVerifyResult, HashError> {
66    let content = fs::read(path)?;
67    let expected = expected.trim();
68
69    // Parse hash file format: "<hash>  <filename>" or "<hash> <filename>"
70    let expected = if expected.contains(' ') {
71        expected.split_whitespace().next().unwrap_or(expected)
72    } else {
73        expected
74    };
75
76    // Detect algorithm from prefix or length (case-insensitive prefix)
77    let expected_lower = expected.to_lowercase();
78    let (algorithm, expected_hex) = if let Some(hex) = expected_lower.strip_prefix("sha256:") {
79        ("SHA-256", hex.to_string())
80    } else if let Some(hex) = expected_lower.strip_prefix("sha512:") {
81        ("SHA-512", hex.to_string())
82    } else {
83        match expected.len() {
84            64 => ("SHA-256", expected.to_string()),
85            128 => ("SHA-512", expected.to_string()),
86            _ => {
87                return Err(HashError::UnrecognizedFormat {
88                    length: expected.len(),
89                });
90            }
91        }
92    };
93
94    let actual_hex = match algorithm {
95        "SHA-256" => {
96            let mut hasher = Sha256::new();
97            hasher.update(&content);
98            hasher
99                .finalize()
100                .iter()
101                .map(|b| format!("{b:02x}"))
102                .collect::<String>()
103        }
104        "SHA-512" => {
105            let mut hasher = Sha512::new();
106            hasher.update(&content);
107            hasher
108                .finalize()
109                .iter()
110                .map(|b| format!("{b:02x}"))
111                .collect::<String>()
112        }
113        _ => unreachable!(),
114    };
115
116    let expected_lower = expected_hex.to_lowercase();
117    let verified = actual_hex == expected_lower;
118
119    Ok(HashVerifyResult {
120        verified,
121        algorithm: algorithm.to_string(),
122        expected: expected_lower,
123        actual: actual_hex,
124    })
125}
126
127/// Read a hash from a `.sha256` sidecar file.
128///
129/// Expects format: `<hex>  <filename>` or bare `<hex>`.
130///
131/// # Errors
132///
133/// Returns error if the file cannot be read.
134pub fn read_hash_file(path: &Path) -> Result<String, HashError> {
135    let content = fs::read_to_string(path)?;
136    let trimmed = content.trim();
137    let hash = trimmed.split_whitespace().next().unwrap_or(trimmed);
138    Ok(hash.to_string())
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::io::Write;
145    use tempfile::NamedTempFile;
146
147    #[test]
148    fn verify_sha256_match() {
149        let mut f = NamedTempFile::new().unwrap();
150        f.write_all(b"hello world").unwrap();
151        f.flush().unwrap();
152
153        // SHA-256 of "hello world"
154        let expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
155        let result = verify_file_hash(f.path(), expected).unwrap();
156        assert!(result.verified);
157        assert_eq!(result.algorithm, "SHA-256");
158    }
159
160    #[test]
161    fn verify_sha256_mismatch() {
162        let mut f = NamedTempFile::new().unwrap();
163        f.write_all(b"hello world").unwrap();
164        f.flush().unwrap();
165
166        let expected = "0000000000000000000000000000000000000000000000000000000000000000";
167        let result = verify_file_hash(f.path(), expected).unwrap();
168        assert!(!result.verified);
169    }
170
171    #[test]
172    fn verify_prefixed_sha256() {
173        let mut f = NamedTempFile::new().unwrap();
174        f.write_all(b"hello world").unwrap();
175        f.flush().unwrap();
176
177        let expected = "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
178        let result = verify_file_hash(f.path(), expected).unwrap();
179        assert!(result.verified);
180    }
181
182    #[test]
183    fn verify_sha256sum_file_format() {
184        let mut f = NamedTempFile::new().unwrap();
185        f.write_all(b"hello world").unwrap();
186        f.flush().unwrap();
187
188        let expected =
189            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9  somefile.json";
190        let result = verify_file_hash(f.path(), expected).unwrap();
191        assert!(result.verified);
192    }
193
194    #[test]
195    fn verify_bad_length() {
196        let mut f = NamedTempFile::new().unwrap();
197        f.write_all(b"test").unwrap();
198        f.flush().unwrap();
199
200        let result = verify_file_hash(f.path(), "abcdef");
201        assert!(result.is_err());
202    }
203
204    #[test]
205    fn read_hash_file_format() {
206        let mut f = NamedTempFile::new().unwrap();
207        writeln!(f, "abcd1234  sbom.json").unwrap();
208        f.flush().unwrap();
209
210        let hash = read_hash_file(f.path()).unwrap();
211        assert_eq!(hash, "abcd1234");
212    }
213}