1use 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 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 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}