1use 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 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, ctx.index) {
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_spec(spec)?,
153 expected,
154 }))
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 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}