1use std::io::Read;
4use std::path::Path;
5
6use sha2::{Digest, Sha256, Sha512};
7
8use crate::error::{Error, Result};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum HashAlgo {
12 Sha256,
13 Sha512,
14 Blake3,
15}
16
17pub fn hash_bytes(bytes: &[u8], algo: HashAlgo) -> String {
18 match algo {
19 HashAlgo::Sha256 => hex::encode(Sha256::digest(bytes)),
20 HashAlgo::Sha512 => hex::encode(Sha512::digest(bytes)),
21 HashAlgo::Blake3 => blake3::hash(bytes).to_hex().to_string(),
22 }
23}
24
25pub fn hash_file(path: &Path, algo: HashAlgo) -> Result<String> {
27 let mut f = std::fs::File::open(path).map_err(|e| Error::io(path, e))?;
28 let mut buf = [0u8; 64 * 1024];
29 match algo {
30 HashAlgo::Sha256 => {
31 let mut h = Sha256::new();
32 loop {
33 let n = f.read(&mut buf).map_err(|e| Error::io(path, e))?;
34 if n == 0 {
35 break;
36 }
37 h.update(&buf[..n]);
38 }
39 Ok(hex::encode(h.finalize()))
40 }
41 HashAlgo::Sha512 => {
42 let mut h = Sha512::new();
43 loop {
44 let n = f.read(&mut buf).map_err(|e| Error::io(path, e))?;
45 if n == 0 {
46 break;
47 }
48 h.update(&buf[..n]);
49 }
50 Ok(hex::encode(h.finalize()))
51 }
52 HashAlgo::Blake3 => {
53 let mut h = blake3::Hasher::new();
54 loop {
55 let n = f.read(&mut buf).map_err(|e| Error::io(path, e))?;
56 if n == 0 {
57 break;
58 }
59 h.update(&buf[..n]);
60 }
61 Ok(h.finalize().to_hex().to_string())
62 }
63 }
64}
65
66pub fn verify_file(path: &Path, expected: &str, algo: HashAlgo, name: &str) -> Result<()> {
68 let actual = hash_file(path, algo)?;
69 if actual.eq_ignore_ascii_case(expected.trim()) {
70 Ok(())
71 } else {
72 Err(Error::ChecksumMismatch {
73 name: name.to_string(),
74 expected: expected.to_string(),
75 actual,
76 })
77 }
78}
79
80pub fn find_shasum<'a>(body: &'a str, filename: &str) -> Option<&'a str> {
83 for line in body.lines() {
84 let line = line.trim();
85 if line.is_empty() {
86 continue;
87 }
88 let mut it = line.split_whitespace();
89 let hash = it.next()?;
90 let name = it.next()?;
91 let name = name.trim_start_matches('*');
93 let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
94 if name == filename || base == filename {
95 return Some(hash);
96 }
97 }
98 None
99}
100
101pub fn parse_sha256_token(body: &str) -> Option<String> {
104 let token = body.split_whitespace().next()?;
105 if token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()) {
106 Some(token.to_string())
107 } else {
108 None
109 }
110}
111
112pub fn parse_sri(integrity: &str) -> Option<super::Checksum> {
116 use base64::Engine;
117 let mut best: Option<super::Checksum> = None;
118 for token in integrity.split_whitespace() {
119 let (algo_str, b64) = token.split_once('-')?;
120 let algo = match algo_str {
121 "sha512" => HashAlgo::Sha512,
122 "sha256" => HashAlgo::Sha256,
123 _ => continue,
124 };
125 let raw = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
126 let hex = hex::encode(raw);
127 let cs = super::Checksum { algo, hex };
128 match &best {
130 Some(b) if b.algo == HashAlgo::Sha512 => {}
131 _ => best = Some(cs),
132 }
133 }
134 best
135}
136
137pub async fn discover_asset_checksum(
146 client: &reqwest::Client,
147 asset_url: &str,
148) -> Option<super::Checksum> {
149 for suffix in [".sha256", ".sha256sum", ".sha256.txt"] {
151 let url = format!("{asset_url}{suffix}");
152 if let Ok(body) = crate::http::get_text(client, &url).await {
153 if let Some(hex) = parse_sha256_token(&body) {
154 return Some(super::Checksum {
155 algo: HashAlgo::Sha256,
156 hex,
157 });
158 }
159 }
160 }
161 let (dir, file) = asset_url.rsplit_once('/')?;
163 for manifest in [
164 "SHASUMS256.txt",
165 "SHA256SUMS",
166 "sha256sums.txt",
167 "checksums.txt",
168 ] {
169 let url = format!("{dir}/{manifest}");
170 if let Ok(body) = crate::http::get_text(client, &url).await {
171 if let Some(hex) = find_shasum(&body, file) {
172 return Some(super::Checksum {
173 algo: HashAlgo::Sha256,
174 hex: hex.to_string(),
175 });
176 }
177 }
178 }
179 None
180}
181
182pub fn verify_minisign(file: &Path, signature: &str, public_key_b64: &str) -> Result<()> {
187 let bytes = std::fs::read(file).map_err(|e| Error::io(file, e))?;
188 verify_minisign_bytes(&bytes, signature, public_key_b64)
189}
190
191pub fn verify_minisign_bytes(bytes: &[u8], signature: &str, public_key_b64: &str) -> Result<()> {
193 let pk = minisign_verify::PublicKey::from_base64(public_key_b64.trim())
194 .map_err(|e| Error::other(format!("invalid minisign public key: {e}")))?;
195 let sig = minisign_verify::Signature::decode(signature)
196 .map_err(|e| Error::other(format!("invalid minisign signature: {e}")))?;
197 pk.verify(bytes, &sig, false)
199 .map_err(|e| Error::other(format!("minisign verification failed: {e}")))?;
200 Ok(())
201}
202
203pub struct TrustedKey {
205 pub public_key: &'static str,
207 pub manifest: &'static str,
209 pub signature: &'static str,
210}
211
212pub fn trusted_key(repo_id: &str) -> Option<TrustedKey> {
215 match repo_id {
216 "github:jdx/mise" => Some(TrustedKey {
218 public_key: "RWTC3g8W3z4RZK3V3qv7fa1QY4JEWyBtqIHW+85QlJpZc5yG+uNYNBSZ",
219 manifest: "SHASUMS256.txt",
220 signature: "SHASUMS256.txt.minisig",
221 }),
222 _ => None,
223 }
224}
225
226pub async fn signed_manifest_checksum(
235 client: &reqwest::Client,
236 repo_id: &str,
237 dir_url: &str,
238 filename: &str,
239) -> Result<Option<super::Checksum>> {
240 let key = match trusted_key(repo_id) {
241 Some(k) => k,
242 None => return Ok(None),
243 };
244 let dir = dir_url.trim_end_matches('/');
245 let manifest_url = format!("{dir}/{}", key.manifest);
246 let sig_url = format!("{dir}/{}", key.signature);
247
248 let manifest = match crate::http::get_text(client, &manifest_url).await {
249 Ok(m) => m,
250 Err(_) => return Ok(None), };
252 let signature = match crate::http::get_text(client, &sig_url).await {
253 Ok(s) => s,
254 Err(_) => return Ok(None),
255 };
256
257 verify_minisign_bytes(manifest.as_bytes(), &signature, key.public_key)?;
259
260 Ok(find_shasum(&manifest, filename).map(|hex| super::Checksum {
262 algo: HashAlgo::Sha256,
263 hex: hex.to_string(),
264 }))
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use std::io::Write;
271
272 #[test]
273 fn sha256_known_vector() {
274 let td = tempfile::tempdir().unwrap();
275 let p = td.path().join("f");
276 let mut f = std::fs::File::create(&p).unwrap();
277 f.write_all(b"abc").unwrap();
278 let expected = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
280 assert!(verify_file(&p, expected, HashAlgo::Sha256, "f").is_ok());
281 assert!(verify_file(&p, "deadbeef", HashAlgo::Sha256, "f").is_err());
282 }
283
284 #[test]
285 fn parse_shasums() {
286 let body = "aaaa node-v20-linux-x64.tar.gz\nbbbb node-v20-linux-x64.tar.xz\n";
287 assert_eq!(find_shasum(body, "node-v20-linux-x64.tar.xz"), Some("bbbb"));
288 assert_eq!(find_shasum(body, "missing"), None);
289 }
290
291 #[test]
292 fn shasums_matches_basename_in_path() {
293 let body = "cccc ./dist/bun-linux-x64.zip\n";
295 assert_eq!(find_shasum(body, "bun-linux-x64.zip"), Some("cccc"));
296 }
297
298 #[test]
299 fn sidecar_token_parsing() {
300 let hex = "b".repeat(64);
301 assert_eq!(parse_sha256_token(&hex).as_deref(), Some(hex.as_str()));
302 let body = format!("{hex} deno-x86_64-unknown-linux-gnu.zip\n");
304 assert_eq!(parse_sha256_token(&body).as_deref(), Some(hex.as_str()));
305 assert_eq!(parse_sha256_token("nothex"), None);
307 assert_eq!(parse_sha256_token(""), None);
308 }
309
310 #[test]
311 fn sri_parsing_prefers_sha512() {
312 use base64::Engine;
313 let sha256_hex = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
315 let sha256_b64 =
316 base64::engine::general_purpose::STANDARD.encode(hex::decode(sha256_hex).unwrap());
317 let cs = parse_sri(&format!("sha256-{sha256_b64}")).unwrap();
318 assert_eq!(cs.algo, HashAlgo::Sha256);
319 assert_eq!(cs.hex, sha256_hex);
320
321 let sha512_hex = "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a\
323 2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f";
324 let sha512_b64 =
325 base64::engine::general_purpose::STANDARD.encode(hex::decode(sha512_hex).unwrap());
326 let cs = parse_sri(&format!("sha256-{sha256_b64} sha512-{sha512_b64}")).unwrap();
327 assert_eq!(cs.algo, HashAlgo::Sha512);
328 assert_eq!(cs.hex, sha512_hex);
329
330 assert!(parse_sri("md5-xxxx").is_none());
331 }
332
333 #[test]
334 fn minisign_rejects_bad_inputs() {
335 let td = tempfile::tempdir().unwrap();
336 let f = td.path().join("artifact");
337 std::fs::write(&f, b"payload").unwrap();
338 assert!(verify_minisign(&f, "untrusted comment\nRWQf6L...", "not-a-key").is_err());
340 let fake_key = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
343 assert!(verify_minisign(&f, "garbage", fake_key).is_err());
344 }
345
346 const MISE_KEY: &str = "RWTC3g8W3z4RZK3V3qv7fa1QY4JEWyBtqIHW+85QlJpZc5yG+uNYNBSZ";
351 const MISE_SIG: &str = "untrusted comment: signature from minisign secret key\n\
352 RUTC3g8W3z4RZPN3yrytMMxcrYyruSFMJw/fd1BsY9CWTb06OvLLpbNdRmdTfO9yqMBy4TcBu4ZiUr6e+WLWViNnRyT4J0pUAA4=\n\
353 trusted comment: timestamp:1735607189\tfile:SHASUMS256.txt\thashed\n\
354 rqyHRI2HBPZJQLqYOpdcB8g7aKcsOVA9+NY5Gn12aguvRokwIZhwHAg5z+xIvuu2iplosMcbP0lBhrhh+K2LCQ==\n";
355
356 #[test]
357 fn trusted_key_registered_for_mise() {
358 let k = trusted_key("github:jdx/mise").expect("mise key present");
359 assert_eq!(k.public_key, MISE_KEY);
360 assert_eq!(k.manifest, "SHASUMS256.txt");
361 assert!(trusted_key("github:unknown/repo").is_none());
362 }
363
364 #[test]
365 fn real_signature_rejects_tampered_content() {
366 let tampered = b"this is not the signed manifest";
369 assert!(verify_minisign_bytes(tampered, MISE_SIG, MISE_KEY).is_err());
370 assert!(minisign_verify::PublicKey::from_base64(MISE_KEY).is_ok());
373 assert!(minisign_verify::Signature::decode(MISE_SIG).is_ok());
374 }
375}