lean_ctx/core/addons/
binhash.rs1use std::io::Read;
13use std::path::{Path, PathBuf};
14
15use sha2::{Digest, Sha256};
16
17pub fn sha256_file(path: &Path) -> Result<String, String> {
20 let mut file =
21 std::fs::File::open(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
22 let mut hasher = Sha256::new();
23 let mut buf = [0u8; 8192];
24 loop {
25 let n = file
26 .read(&mut buf)
27 .map_err(|e| format!("read {} failed: {e}", path.display()))?;
28 if n == 0 {
29 break;
30 }
31 hasher.update(&buf[..n]);
32 }
33 Ok(crate::core::agent_identity::hex_encode(&hasher.finalize()))
34}
35
36#[must_use]
40pub fn resolve_on_path(command: &str) -> Option<PathBuf> {
41 let cmd = command.trim();
42 if cmd.is_empty() {
43 return None;
44 }
45 if cmd.contains('/') || cmd.contains('\\') {
46 let p = PathBuf::from(cmd);
47 return p.is_file().then_some(p);
48 }
49 let path_var = std::env::var_os("PATH")?;
50 std::env::split_paths(&path_var)
51 .map(|dir| dir.join(cmd))
52 .find(|candidate| candidate.is_file())
53}
54
55pub fn verify_binary(command: &str, expected_sha256: &str) -> Result<(), String> {
61 let expected = expected_sha256.trim();
62 if expected.is_empty() {
63 return Ok(());
64 }
65 let path = resolve_on_path(command).ok_or_else(|| {
66 format!("binary `{command}` is pinned (sha256) but could not be found on PATH")
67 })?;
68 let actual = sha256_file(&path)?;
69 if actual.eq_ignore_ascii_case(expected) {
70 Ok(())
71 } else {
72 Err(format!(
73 "binary `{command}` failed its sha256 pin — expected {}…, got {}… ({} may have been \
74 replaced)",
75 short(expected),
76 short(&actual),
77 path.display()
78 ))
79 }
80}
81
82fn short(hex: &str) -> String {
83 hex.chars().take(12).collect()
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 fn tmp(label: &str) -> PathBuf {
93 std::env::temp_dir().join(format!("leanctx-binhash-{}-{label}", std::process::id()))
94 }
95
96 #[test]
97 fn hashes_file_matching_known_sha256() {
98 let dir = tmp("known");
99 std::fs::create_dir_all(&dir).unwrap();
100 let f = dir.join("payload.bin");
101 std::fs::write(&f, b"abc").unwrap();
102 let expected = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
104 assert_eq!(sha256_file(&f).unwrap(), expected);
105 std::fs::remove_dir_all(&dir).ok();
106 }
107
108 #[test]
109 fn empty_pin_is_ok_without_resolving() {
110 assert!(verify_binary("definitely-not-a-real-binary-xyz", "").is_ok());
112 }
113
114 #[test]
115 fn verify_matches_and_detects_mismatch() {
116 let dir = tmp("verify");
117 std::fs::create_dir_all(&dir).unwrap();
118 let f = dir.join("my-mcp");
119 std::fs::write(&f, b"hello world").unwrap();
120 let good = sha256_file(&f).unwrap();
121 let path_str = f.to_string_lossy().to_string();
122
123 assert!(verify_binary(&path_str, &good).is_ok());
124 assert!(verify_binary(&path_str, &good.to_uppercase()).is_ok());
125 assert!(
126 verify_binary(&path_str, &"0".repeat(64)).is_err(),
127 "wrong hash must fail"
128 );
129 std::fs::remove_dir_all(&dir).ok();
130 }
131
132 #[test]
133 fn pinned_but_missing_binary_fails_closed() {
134 let err = verify_binary(
135 "/nonexistent/path/to/mcp",
136 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
137 );
138 assert!(err.is_err(), "a pin that cannot be checked must fail");
139 }
140
141 #[test]
142 fn resolve_absolute_path_roundtrips() {
143 let dir = tmp("resolve");
144 std::fs::create_dir_all(&dir).unwrap();
145 let f = dir.join("tool");
146 std::fs::write(&f, b"x").unwrap();
147 let resolved = resolve_on_path(&f.to_string_lossy()).expect("absolute path resolves");
148 assert_eq!(resolved, f);
149 std::fs::remove_dir_all(&dir).ok();
150 }
151}