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 std::path::Path;
12
13use alint_core::{
14    Context, Error, Level, PerFileRule, Result, Rule, RuleSpec, Scope, ScopeFilter, Violation,
15};
16use serde::Deserialize;
17use sha2::{Digest, Sha256};
18
19#[derive(Debug, Deserialize)]
20#[serde(deny_unknown_fields)]
21struct Options {
22    /// Expected SHA-256 in lowercase hex (64 chars). Accepting
23    /// uppercase and the `sha256:` prefix keeps the field forgiving.
24    sha256: String,
25}
26
27#[derive(Debug)]
28pub struct FileHashRule {
29    id: String,
30    level: Level,
31    policy_url: Option<String>,
32    message: Option<String>,
33    scope: Scope,
34    scope_filter: Option<ScopeFilter>,
35    expected: [u8; 32],
36}
37
38impl Rule for FileHashRule {
39    fn id(&self) -> &str {
40        &self.id
41    }
42    fn level(&self) -> Level {
43        self.level
44    }
45    fn policy_url(&self) -> Option<&str> {
46        self.policy_url.as_deref()
47    }
48
49    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
50        let mut violations = Vec::new();
51        for entry in ctx.index.files() {
52            if !self.scope.matches(&entry.path) {
53                continue;
54            }
55            if let Some(filter) = &self.scope_filter
56                && !filter.matches(&entry.path, ctx.index)
57            {
58                continue;
59            }
60            let full = ctx.root.join(&entry.path);
61            let Ok(bytes) = std::fs::read(&full) else {
62                continue;
63            };
64            violations.extend(self.evaluate_file(ctx, &entry.path, &bytes)?);
65        }
66        Ok(violations)
67    }
68
69    fn as_per_file(&self) -> Option<&dyn PerFileRule> {
70        Some(self)
71    }
72
73    fn scope_filter(&self) -> Option<&ScopeFilter> {
74        self.scope_filter.as_ref()
75    }
76}
77
78impl PerFileRule for FileHashRule {
79    fn path_scope(&self) -> &Scope {
80        &self.scope
81    }
82
83    fn evaluate_file(
84        &self,
85        _ctx: &Context<'_>,
86        path: &Path,
87        bytes: &[u8],
88    ) -> Result<Vec<Violation>> {
89        let mut hasher = Sha256::new();
90        hasher.update(bytes);
91        let actual: [u8; 32] = hasher.finalize().into();
92        if actual == self.expected {
93            return Ok(Vec::new());
94        }
95        let msg = self.message.clone().unwrap_or_else(|| {
96            format!(
97                "sha256 mismatch: expected {}, got {}",
98                encode_hex(&self.expected),
99                encode_hex(&actual),
100            )
101        });
102        Ok(vec![
103            Violation::new(msg).with_path(std::sync::Arc::<Path>::from(path)),
104        ])
105    }
106}
107
108fn parse_sha256(raw: &str) -> std::result::Result<[u8; 32], String> {
109    let trimmed = raw.strip_prefix("sha256:").unwrap_or(raw);
110    if trimmed.len() != 64 {
111        return Err(format!(
112            "sha256 must be 64 hex chars; got {}",
113            trimmed.len()
114        ));
115    }
116    let mut out = [0u8; 32];
117    for (i, chunk) in trimmed.as_bytes().chunks(2).enumerate() {
118        let hi = hex_digit(chunk[0]).ok_or_else(|| "non-hex character".to_string())?;
119        let lo = hex_digit(chunk[1]).ok_or_else(|| "non-hex character".to_string())?;
120        out[i] = (hi << 4) | lo;
121    }
122    Ok(out)
123}
124
125fn hex_digit(b: u8) -> Option<u8> {
126    match b {
127        b'0'..=b'9' => Some(b - b'0'),
128        b'a'..=b'f' => Some(10 + b - b'a'),
129        b'A'..=b'F' => Some(10 + b - b'A'),
130        _ => None,
131    }
132}
133
134fn encode_hex(bytes: &[u8]) -> String {
135    use std::fmt::Write as _;
136    let mut s = String::with_capacity(bytes.len() * 2);
137    for b in bytes {
138        write!(s, "{b:02x}").unwrap();
139    }
140    s
141}
142
143pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
144    let paths = spec
145        .paths
146        .as_ref()
147        .ok_or_else(|| Error::rule_config(&spec.id, "file_hash requires a `paths` field"))?;
148    let opts: Options = spec
149        .deserialize_options()
150        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
151    let expected = parse_sha256(&opts.sha256)
152        .map_err(|e| Error::rule_config(&spec.id, format!("invalid sha256: {e}")))?;
153    if spec.fix.is_some() {
154        return Err(Error::rule_config(
155            &spec.id,
156            "file_hash has no fix op — alint can't synthesize the correct content",
157        ));
158    }
159    Ok(Box::new(FileHashRule {
160        id: spec.id.clone(),
161        level: spec.level,
162        policy_url: spec.policy_url.clone(),
163        message: spec.message.clone(),
164        scope: Scope::from_paths_spec(paths)?,
165        scope_filter: spec.parse_scope_filter()?,
166        expected,
167    }))
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    // SHA-256 of the empty string.
175    const EMPTY_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
176
177    #[test]
178    fn parses_bare_hex() {
179        let bytes = parse_sha256(EMPTY_HASH).unwrap();
180        assert_eq!(encode_hex(&bytes), EMPTY_HASH);
181    }
182
183    #[test]
184    fn parses_sha256_prefix() {
185        let bytes = parse_sha256(&format!("sha256:{EMPTY_HASH}")).unwrap();
186        assert_eq!(encode_hex(&bytes), EMPTY_HASH);
187    }
188
189    #[test]
190    fn accepts_uppercase_hex() {
191        let upper = EMPTY_HASH.to_ascii_uppercase();
192        let bytes = parse_sha256(&upper).unwrap();
193        assert_eq!(encode_hex(&bytes), EMPTY_HASH);
194    }
195
196    #[test]
197    fn rejects_wrong_length() {
198        assert!(parse_sha256("e3b0c442").is_err());
199    }
200
201    #[test]
202    fn rejects_non_hex_chars() {
203        let bad = format!("zz{}", &EMPTY_HASH[2..]);
204        assert!(parse_sha256(&bad).is_err());
205    }
206}