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