Skip to main content

mars_agents/hash/
mod.rs

1use std::fs;
2use std::path::Path;
3
4use sha2::{Digest, Sha256};
5
6use crate::error::MarsError;
7use crate::types::ItemKind;
8
9/// Compute SHA-256 of a file or directory (for skills).
10///
11/// For agents (single `.md` file): SHA-256 of file content.
12/// For skills (directory): SHA-256 of sorted `(relative_path, file_hash)` pairs —
13/// deterministic regardless of filesystem ordering.
14///
15/// Output format: `"sha256:<64-char-hex>"`.
16pub fn compute_hash(path: &Path, kind: ItemKind) -> Result<String, MarsError> {
17    match kind {
18        ItemKind::Agent => {
19            let content = fs::read(path)?;
20            Ok(hash_bytes(&content))
21        }
22        ItemKind::Skill => compute_dir_hash(path),
23    }
24}
25
26/// Compute hash for a skill directory while excluding selected top-level entries.
27pub fn compute_skill_hash_filtered(
28    dir: &Path,
29    excluded_top_level: &[&str],
30) -> Result<String, MarsError> {
31    compute_dir_hash_filtered(dir, excluded_top_level)
32}
33
34/// Compute SHA-256 of raw bytes.
35///
36/// Returns `"sha256:<64-char-lowercase-hex>"`.
37pub fn hash_bytes(content: &[u8]) -> String {
38    let mut hasher = Sha256::new();
39    hasher.update(content);
40    let digest = hasher.finalize();
41    format!("sha256:{:064x}", digest)
42}
43
44/// Compute a deterministic hash for a directory by:
45/// 1. Walking all files recursively
46/// 2. Collecting (relative_path, file_sha256) pairs
47/// 3. Sorting lexicographically by path
48/// 4. Concatenating "path:hash\n" strings
49/// 5. SHA-256 of the concatenated result
50fn compute_dir_hash(dir: &Path) -> Result<String, MarsError> {
51    compute_dir_hash_filtered(dir, &[])
52}
53
54fn compute_dir_hash_filtered(dir: &Path, excluded_top_level: &[&str]) -> Result<String, MarsError> {
55    let mut entries: Vec<(String, String)> = Vec::new();
56    collect_file_hashes(dir, dir, &mut entries, excluded_top_level)?;
57    entries.sort_by(|a, b| a.0.cmp(&b.0));
58
59    let mut manifest = String::new();
60    for (rel_path, hash) in &entries {
61        manifest.push_str(rel_path);
62        manifest.push(':');
63        manifest.push_str(hash);
64        manifest.push('\n');
65    }
66
67    Ok(hash_bytes(manifest.as_bytes()))
68}
69
70/// Recursively collect (relative_path, hash) pairs for all files in a directory.
71fn collect_file_hashes(
72    root: &Path,
73    current: &Path,
74    entries: &mut Vec<(String, String)>,
75    excluded_top_level: &[&str],
76) -> Result<(), MarsError> {
77    for entry in fs::read_dir(current)? {
78        let entry = entry?;
79        let path = entry.path();
80        let file_type = entry.file_type()?;
81
82        let rel_path = path.strip_prefix(root).expect("path is always under root");
83        if is_excluded_top_level(rel_path, excluded_top_level) {
84            continue;
85        }
86
87        if file_type.is_dir() {
88            collect_file_hashes(root, &path, entries, excluded_top_level)?;
89        } else {
90            // Build forward-slash relative path from components for cross-platform determinism
91            let rel_path: String = rel_path
92                .components()
93                .map(|c| c.as_os_str().to_string_lossy())
94                .collect::<Vec<_>>()
95                .join("/");
96            let content = fs::read(&path)?;
97            let hash = hash_bytes(&content);
98            entries.push((rel_path, hash));
99        }
100    }
101    Ok(())
102}
103
104fn is_excluded_top_level(path: &Path, excluded_top_level: &[&str]) -> bool {
105    let Some(first) = path.components().next().map(|c| c.as_os_str()) else {
106        return false;
107    };
108    excluded_top_level.iter().any(|excluded| first == *excluded)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use std::fs;
115    use tempfile::TempDir;
116
117    #[test]
118    fn hash_bytes_returns_lowercase_hex() {
119        let hash = hash_bytes(b"test");
120        assert!(hash.starts_with("sha256:"));
121        let hex = &hash["sha256:".len()..];
122        assert_eq!(hex.len(), 64);
123        assert!(
124            hex.chars()
125                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
126        );
127    }
128
129    #[test]
130    fn compute_hash_skill_directory() {
131        let dir = TempDir::new().unwrap();
132        let skill_dir = dir.path().join("my-skill");
133        fs::create_dir_all(skill_dir.join("sub")).unwrap();
134        fs::write(skill_dir.join("main.md"), "main content").unwrap();
135        fs::write(skill_dir.join("sub").join("helper.md"), "helper content").unwrap();
136
137        let hash = compute_hash(&skill_dir, ItemKind::Skill).unwrap();
138        assert!(hash.starts_with("sha256:"));
139
140        // Verify determinism: same content → same hash
141        let hash2 = compute_hash(&skill_dir, ItemKind::Skill).unwrap();
142        assert_eq!(hash, hash2);
143    }
144
145    #[test]
146    fn dir_hash_deterministic_regardless_of_creation_order() {
147        let dir1 = TempDir::new().unwrap();
148        let skill1 = dir1.path().join("skill");
149        fs::create_dir_all(&skill1).unwrap();
150        // Create files in order: a, b
151        fs::write(skill1.join("a.md"), "content a").unwrap();
152        fs::write(skill1.join("b.md"), "content b").unwrap();
153
154        let dir2 = TempDir::new().unwrap();
155        let skill2 = dir2.path().join("skill");
156        fs::create_dir_all(&skill2).unwrap();
157        // Create files in reverse order: b, a
158        fs::write(skill2.join("b.md"), "content b").unwrap();
159        fs::write(skill2.join("a.md"), "content a").unwrap();
160
161        let hash1 = compute_hash(&skill1, ItemKind::Skill).unwrap();
162        let hash2 = compute_hash(&skill2, ItemKind::Skill).unwrap();
163        assert_eq!(hash1, hash2);
164    }
165
166    #[test]
167    fn dir_hash_changes_with_different_content() {
168        let dir = TempDir::new().unwrap();
169        let skill_dir = dir.path().join("skill");
170        fs::create_dir_all(&skill_dir).unwrap();
171        fs::write(skill_dir.join("file.md"), "version 1").unwrap();
172
173        let hash1 = compute_hash(&skill_dir, ItemKind::Skill).unwrap();
174
175        fs::write(skill_dir.join("file.md"), "version 2").unwrap();
176
177        let hash2 = compute_hash(&skill_dir, ItemKind::Skill).unwrap();
178        assert_ne!(hash1, hash2);
179    }
180
181    #[test]
182    fn dir_hash_changes_with_different_filename() {
183        let dir1 = TempDir::new().unwrap();
184        let skill1 = dir1.path().join("skill");
185        fs::create_dir_all(&skill1).unwrap();
186        fs::write(skill1.join("a.md"), "content").unwrap();
187
188        let dir2 = TempDir::new().unwrap();
189        let skill2 = dir2.path().join("skill");
190        fs::create_dir_all(&skill2).unwrap();
191        fs::write(skill2.join("b.md"), "content").unwrap();
192
193        let hash1 = compute_hash(&skill1, ItemKind::Skill).unwrap();
194        let hash2 = compute_hash(&skill2, ItemKind::Skill).unwrap();
195        assert_ne!(hash1, hash2);
196    }
197
198    #[test]
199    fn filtered_skill_hash_ignores_excluded_top_level_entries() {
200        let dir = TempDir::new().unwrap();
201        let skill_dir = dir.path().join("skill");
202        fs::create_dir_all(skill_dir.join(".git")).unwrap();
203        fs::write(skill_dir.join("SKILL.md"), "base").unwrap();
204        fs::write(skill_dir.join("mars.toml"), "v1").unwrap();
205        fs::write(skill_dir.join(".git").join("config"), "ignored").unwrap();
206
207        let hash1 =
208            compute_skill_hash_filtered(&skill_dir, crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL)
209                .unwrap();
210
211        fs::write(skill_dir.join("mars.toml"), "v2").unwrap();
212        fs::write(skill_dir.join(".git").join("config"), "changed").unwrap();
213
214        let hash2 =
215            compute_skill_hash_filtered(&skill_dir, crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL)
216                .unwrap();
217
218        assert_eq!(hash1, hash2);
219    }
220}