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