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