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