Skip to main content

alint_rules/
file_hash.rs

1//! `file_hash` — assert a file's SHA-256 equals a declared hex
2//! string.
3//!
4//! Use cases: pin generated files so a re-run would fail if the
5//! generator changes; verify that a bundled LICENSE text matches
6//! the canonical Apache/MIT hash; lock down "do not edit" fixtures.
7//!
8//! Check-only. Fix would require knowing what the "right"
9//! content is, which is the generator's job, not alint's.
10
11use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
12use serde::Deserialize;
13use sha2::{Digest, Sha256};
14
15#[derive(Debug, Deserialize)]
16#[serde(deny_unknown_fields)]
17struct Options {
18    /// Expected SHA-256 in lowercase hex (64 chars). Accepting
19    /// uppercase and the `sha256:` prefix keeps the field forgiving.
20    sha256: String,
21}
22
23#[derive(Debug)]
24pub struct FileHashRule {
25    id: String,
26    level: Level,
27    policy_url: Option<String>,
28    message: Option<String>,
29    scope: Scope,
30    expected: [u8; 32],
31}
32
33impl Rule for FileHashRule {
34    fn id(&self) -> &str {
35        &self.id
36    }
37    fn level(&self) -> Level {
38        self.level
39    }
40    fn policy_url(&self) -> Option<&str> {
41        self.policy_url.as_deref()
42    }
43
44    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
45        let mut violations = Vec::new();
46        for entry in ctx.index.files() {
47            if !self.scope.matches(&entry.path) {
48                continue;
49            }
50            let full = ctx.root.join(&entry.path);
51            let Ok(bytes) = std::fs::read(&full) else {
52                continue;
53            };
54            let mut hasher = Sha256::new();
55            hasher.update(&bytes);
56            let actual: [u8; 32] = hasher.finalize().into();
57            if actual != self.expected {
58                let msg = self.message.clone().unwrap_or_else(|| {
59                    format!(
60                        "sha256 mismatch: expected {}, got {}",
61                        encode_hex(&self.expected),
62                        encode_hex(&actual),
63                    )
64                });
65                violations.push(Violation::new(msg).with_path(&entry.path));
66            }
67        }
68        Ok(violations)
69    }
70}
71
72fn parse_sha256(raw: &str) -> std::result::Result<[u8; 32], String> {
73    let trimmed = raw.strip_prefix("sha256:").unwrap_or(raw);
74    if trimmed.len() != 64 {
75        return Err(format!(
76            "sha256 must be 64 hex chars; got {}",
77            trimmed.len()
78        ));
79    }
80    let mut out = [0u8; 32];
81    for (i, chunk) in trimmed.as_bytes().chunks(2).enumerate() {
82        let hi = hex_digit(chunk[0]).ok_or_else(|| "non-hex character".to_string())?;
83        let lo = hex_digit(chunk[1]).ok_or_else(|| "non-hex character".to_string())?;
84        out[i] = (hi << 4) | lo;
85    }
86    Ok(out)
87}
88
89fn hex_digit(b: u8) -> Option<u8> {
90    match b {
91        b'0'..=b'9' => Some(b - b'0'),
92        b'a'..=b'f' => Some(10 + b - b'a'),
93        b'A'..=b'F' => Some(10 + b - b'A'),
94        _ => None,
95    }
96}
97
98fn encode_hex(bytes: &[u8]) -> String {
99    use std::fmt::Write as _;
100    let mut s = String::with_capacity(bytes.len() * 2);
101    for b in bytes {
102        write!(s, "{b:02x}").unwrap();
103    }
104    s
105}
106
107pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
108    let paths = spec
109        .paths
110        .as_ref()
111        .ok_or_else(|| Error::rule_config(&spec.id, "file_hash requires a `paths` field"))?;
112    let opts: Options = spec
113        .deserialize_options()
114        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
115    let expected = parse_sha256(&opts.sha256)
116        .map_err(|e| Error::rule_config(&spec.id, format!("invalid sha256: {e}")))?;
117    if spec.fix.is_some() {
118        return Err(Error::rule_config(
119            &spec.id,
120            "file_hash has no fix op — alint can't synthesize the correct content",
121        ));
122    }
123    Ok(Box::new(FileHashRule {
124        id: spec.id.clone(),
125        level: spec.level,
126        policy_url: spec.policy_url.clone(),
127        message: spec.message.clone(),
128        scope: Scope::from_paths_spec(paths)?,
129        expected,
130    }))
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    // SHA-256 of the empty string.
138    const EMPTY_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
139
140    #[test]
141    fn parses_bare_hex() {
142        let bytes = parse_sha256(EMPTY_HASH).unwrap();
143        assert_eq!(encode_hex(&bytes), EMPTY_HASH);
144    }
145
146    #[test]
147    fn parses_sha256_prefix() {
148        let bytes = parse_sha256(&format!("sha256:{EMPTY_HASH}")).unwrap();
149        assert_eq!(encode_hex(&bytes), EMPTY_HASH);
150    }
151
152    #[test]
153    fn accepts_uppercase_hex() {
154        let upper = EMPTY_HASH.to_ascii_uppercase();
155        let bytes = parse_sha256(&upper).unwrap();
156        assert_eq!(encode_hex(&bytes), EMPTY_HASH);
157    }
158
159    #[test]
160    fn rejects_wrong_length() {
161        assert!(parse_sha256("e3b0c442").is_err());
162    }
163
164    #[test]
165    fn rejects_non_hex_chars() {
166        let bad = format!("zz{}", &EMPTY_HASH[2..]);
167        assert!(parse_sha256(&bad).is_err());
168    }
169}