]> code.octet-stream.net Git - hashgood/blob - src/verify.rs
Update deps
[hashgood] / src / verify.rs
1 use super::{
2 Algorithm, CandidateHash, CandidateHashes, Hash, MatchLevel, MessageLevel, Opt, Verification,
3 VerificationSource,
4 };
5 use std::fs::File;
6 use std::io;
7 use std::io::prelude::*;
8 use std::io::BufReader;
9 use std::path::Path;
10
11 /// Calculate a list of candidate hashes based on the options specified.
12 /// If no hash options have been specified returns None.
13 /// It is assumed to be verified previously that at most one mode has been specified.
14 pub fn get_candidate_hashes(opt: &Opt) -> Result<Option<CandidateHashes>, String> {
15 if let Some(hash_string) = &opt.hash {
16 return Ok(Some(get_by_parameter(hash_string)?));
17 } else if let Some(hash_file) = &opt.hash_file {
18 return Ok(Some(get_from_file(hash_file)?));
19 }
20 Ok(None)
21 }
22
23 /// Generate a candidate hash from the provided command line parameter, or throw an error.
24 fn get_by_parameter(param: &str) -> Result<CandidateHashes, String> {
25 let bytes =
26 hex::decode(&param).map_err(|_| "Provided hash is invalid or truncated hex".to_owned())?;
27 let alg = Algorithm::from_len(bytes.len())?;
28 let candidate = CandidateHash {
29 filename: None,
30 bytes,
31 };
32 Ok(CandidateHashes {
33 alg,
34 hashes: vec![candidate],
35 source: VerificationSource::CommandArgument,
36 })
37 }
38
39 /// Generate a candidate hash from the digests file specified (could be "-" for STDIN), or throw an error.
40 fn get_from_file(path: &Path) -> Result<CandidateHashes, String> {
41 // Get a reader for either standard input or the chosen path
42 let reader: Box<dyn Read> = if path.to_str() == Some("-") {
43 Box::new(std::io::stdin())
44 } else {
45 Box::new(File::open(path).map_err(|_| {
46 format!(
47 "Unable to open check file at path '{}'",
48 path.to_string_lossy()
49 )
50 })?)
51 };
52
53 // Read the first line, trimmed
54 let mut reader = BufReader::new(reader);
55 let mut line = String::new();
56 reader
57 .read_line(&mut line)
58 .map_err(|_| "Error reading from check file".to_owned())?;
59 let line = line.trim().to_owned();
60
61 // Does our first line look like a raw hash on its own? If so, use that
62 if let Some(candidate) = read_raw_candidate_from_file(&line, path) {
63 return Ok(candidate);
64 }
65
66 // Maybe it's a digests file
67 // Reconstruct the full iterator by joining our already-read line with the others
68 let full_lines = vec![Ok(line)].into_iter().chain(reader.lines());
69
70 // Does the entire file look like a coreutils-style digests file? (SHA1SUMS, etc.)
71 if let Some(candidate) = read_coreutils_digests_from_file(full_lines, path) {
72 return Ok(candidate);
73 }
74
75 // If neither of these techniques worked this is a fatal error
76 // The user requested we use this input but we couldn't
77 Err(format!(
78 "Provided check file '{}' was neither a hash nor a valid digests file",
79 path.to_string_lossy()
80 ))
81 }
82
83 fn try_parse_hash(s: &str) -> Option<(Algorithm, Vec<u8>)> {
84 let bytes = match hex::decode(s.trim()) {
85 Ok(bytes) => bytes,
86 _ => return None,
87 };
88 let alg = match Algorithm::from_len(bytes.len()) {
89 Ok(alg) => alg,
90 _ => return None,
91 };
92 Some((alg, bytes))
93 }
94
95 fn read_raw_candidate_from_file(line: &str, path: &Path) -> Option<CandidateHashes> {
96 let (alg, bytes) = try_parse_hash(line)?;
97 Some(CandidateHashes {
98 alg,
99 source: VerificationSource::RawFile(path.to_string_lossy().to_string()),
100 hashes: vec![CandidateHash {
101 bytes,
102 filename: None,
103 }],
104 })
105 }
106
107 fn read_coreutils_digests_from_file<I, S>(lines: I, path: &Path) -> Option<CandidateHashes>
108 where
109 I: Iterator<Item = io::Result<S>>,
110 S: AsRef<str>,
111 {
112 let mut hashes = vec![];
113 let mut alg: Option<Algorithm> = None;
114 for l in lines.flatten() {
115 let l = l.as_ref().trim();
116 // Allow (ignore) blank lines
117 if l.is_empty() {
118 continue;
119 }
120 // Expected format
121 // <valid-hash><space><space-or-*><filename>
122 let (line_alg, bytes, filename) = match l
123 .find(' ')
124 .and_then(|space_pos| {
125 // Char before filename should be space for text or * for binary
126 match l.chars().nth(space_pos + 1) {
127 Some(' ') | Some('*') => (l.get(..space_pos)).zip(l.get(space_pos + 2..)),
128 _ => None,
129 }
130 })
131 .and_then(|(maybe_hash, filename)| {
132 // Filename should be in this position without extra whitespace
133 if filename.trim() == filename {
134 try_parse_hash(maybe_hash).map(|(alg, bytes)| (alg, bytes, filename))
135 } else {
136 None
137 }
138 }) {
139 Some(t) => t,
140 None => {
141 // if we have a line with content we cannot parse, this is an error
142 return None;
143 }
144 };
145 if alg.is_some() && alg != Some(line_alg) {
146 // Different algorithms in the same digest file are not supported
147 return None;
148 } else {
149 // If we are the first line, we define the overall algorithm
150 alg = Some(line_alg);
151 }
152 // So far so good - create an entry for this line
153 hashes.push(CandidateHash {
154 bytes,
155 filename: Some(filename.to_owned()),
156 });
157 }
158
159 // It is a failure if we got zero hashes or we somehow don't know the algorithm
160 if hashes.is_empty() {
161 return None;
162 }
163 let alg = match alg {
164 Some(alg) => alg,
165 _ => return None,
166 };
167
168 // Otherwise all is well and we can return our results
169 Some(CandidateHashes {
170 alg,
171 source: VerificationSource::DigestsFile(path.to_string_lossy().to_string()),
172 hashes,
173 })
174 }
175
176 /// Determine if the calculated hash matches any of the candidates.
177 ///
178 /// Ok result: the hash matches, and if the candidate has a filename, that matches too
179 /// Maybe result: the hash matches but the filename does not
180 /// Fail result: neither of the above
181 pub fn verify_hash<'a>(calculated: &Hash, candidates: &'a CandidateHashes) -> Verification<'a> {
182 let mut ok: Option<&CandidateHash> = None;
183 let mut maybe: Option<&CandidateHash> = None;
184 let mut messages = Vec::new();
185
186 for candidate in &candidates.hashes {
187 if candidate.bytes == calculated.bytes {
188 match candidate.filename {
189 None => ok = Some(candidate),
190 Some(ref candidate_filename) if candidate_filename == &calculated.filename => {
191 ok = Some(candidate)
192 }
193 Some(ref candidate_filename) => {
194 messages.push((
195 MessageLevel::Warning,
196 format!(
197 "The matched hash has filename '{}', which does not match the input.",
198 candidate_filename
199 ),
200 ));
201 maybe = Some(candidate);
202 }
203 }
204 }
205 }
206
207 // Warn that a "successful" MD5 result is not necessarily great
208 if candidates.alg == Algorithm::Md5 && (ok.is_some() || maybe.is_some()) {
209 messages.push((
210 MessageLevel::Note,
211 "MD5 can easily be forged. Use a stronger algorithm if possible.".to_owned(),
212 ))
213 }
214
215 // If we got a full match, great
216 if ok.is_some() {
217 return Verification {
218 match_level: MatchLevel::Ok,
219 comparison_hash: ok,
220 messages,
221 };
222 }
223
224 // Second priority, a "maybe" result
225 if maybe.is_some() {
226 return Verification {
227 match_level: MatchLevel::Maybe,
228 comparison_hash: maybe,
229 messages,
230 };
231 }
232
233 // Otherwise we failed
234 // If we only had one candidate hash, include it
235 let comparison = match candidates.hashes.len() {
236 1 => Some(&candidates.hashes[0]),
237 _ => None,
238 };
239 Verification {
240 match_level: MatchLevel::Fail,
241 comparison_hash: comparison,
242 messages,
243 }
244 }
245
246 #[cfg(test)]
247 mod tests {
248 use super::*;
249
250 #[test]
251 fn test_read_raw_inputs() {
252 let example_path = Path::new("some_file");
253 let valid_md5 = "d229da563da18fe5d58cd95a6467d584";
254 let valid_sha1 = "b314c7ebb7d599944981908b7f3ed33a30e78f3a";
255 let valid_sha1_2 = valid_sha1.to_uppercase();
256 let valid_sha256 = "1eb85fc97224598dad1852b5d6483bbcf0aa8608790dcc657a5a2a761ae9c8c6";
257
258 let invalid1 = "x";
259 let invalid2 = "a";
260 let invalid3 = "d229da563da18fe5d58cd95a6467d58";
261 let invalid4 = "1eb85fc97224598dad1852b5d6483bbcf0aa8608790dcc657a5a2a761ae9c8c67";
262 let invalid5 = "1eb85fc97224598dad1852b5d 483bbcf0aa8608790dcc657a5a2a761ae9c8c6";
263
264 assert!(matches!(
265 read_raw_candidate_from_file(valid_md5, example_path),
266 Some(CandidateHashes {
267 alg: Algorithm::Md5,
268 ..
269 })
270 ));
271 assert!(matches!(
272 read_raw_candidate_from_file(valid_sha1, example_path),
273 Some(CandidateHashes {
274 alg: Algorithm::Sha1,
275 ..
276 })
277 ));
278 assert!(matches!(
279 read_raw_candidate_from_file(&valid_sha1_2, example_path),
280 Some(CandidateHashes {
281 alg: Algorithm::Sha1,
282 ..
283 })
284 ));
285 assert!(matches!(
286 read_raw_candidate_from_file(valid_sha256, example_path),
287 Some(CandidateHashes {
288 alg: Algorithm::Sha256,
289 ..
290 })
291 ));
292
293 for i in &[invalid1, invalid2, invalid3, invalid4, invalid5] {
294 assert!(read_raw_candidate_from_file(*i, example_path).is_none());
295 }
296 }
297
298 #[test]
299 fn test_read_shasums() {
300 let shasums = "4b91f7a387a6edd4a7c0afb2897f1ca968c9695b *cp
301 75eb7420a9f5a260b04a3e8ad51e50f2838a17fc lel.txt
302
303 fe6c26d485a3573a1cb0ad0682f5105325a1905f shasums";
304 let lines = shasums.lines().map(std::io::Result::Ok);
305 let path = Path::new("SHASUMS");
306 let candidates = read_coreutils_digests_from_file(lines, path);
307
308 assert_eq!(
309 candidates,
310 Some(CandidateHashes {
311 alg: Algorithm::Sha1,
312 hashes: vec![
313 CandidateHash {
314 bytes: hex::decode("4b91f7a387a6edd4a7c0afb2897f1ca968c9695b").unwrap(),
315 filename: Some("cp".to_owned()),
316 },
317 CandidateHash {
318 bytes: hex::decode("75eb7420a9f5a260b04a3e8ad51e50f2838a17fc").unwrap(),
319 filename: Some("lel.txt".to_owned()),
320 },
321 CandidateHash {
322 bytes: hex::decode("fe6c26d485a3573a1cb0ad0682f5105325a1905f").unwrap(),
323 filename: Some("shasums".to_owned()),
324 }
325 ],
326 source: VerificationSource::DigestsFile(path.to_string_lossy().to_string()),
327 })
328 );
329 }
330
331 #[test]
332 fn test_invalid_shasums() {
333 let no_format = "4b91f7a387a6edd4a7c0afb2897f1ca968c9695b cp";
334 let invalid_format = "4b91f7a387a6edd4a7c0afb2897f1ca968c9695b .cp";
335 let extra_space = "4b91f7a387a6edd4a7c0afb2897f1ca968c9695b cp";
336
337 for digest in [no_format, invalid_format, extra_space] {
338 let lines = digest.lines().map(std::io::Result::Ok);
339 assert!(
340 read_coreutils_digests_from_file(lines, Path::new("SHASUMS")).is_none(),
341 "Should be invalid digest: {:?}",
342 digest
343 );
344 }
345 }
346 }