repolith_actions/
source_hash.rs1use ignore::WalkBuilder;
32use repolith_core::types::{BuildError, Sha256};
33use sha2::{Digest, Sha256 as ShaHasher};
34use std::path::Path;
35
36pub fn platform_tag(h: &mut ShaHasher) {
42 h.update(b":arch:");
43 h.update(std::env::consts::ARCH.as_bytes());
44 h.update(b":os:");
45 h.update(std::env::consts::OS.as_bytes());
46}
47
48pub fn tree_digest(path: &Path) -> Result<Sha256, BuildError> {
60 if !path.exists() {
61 return Err(BuildError::Io(format!(
62 "source tree {} does not exist",
63 path.display()
64 )));
65 }
66
67 let mut entries: Vec<(String, std::path::PathBuf)> = Vec::new();
71 let walker = WalkBuilder::new(path)
72 .hidden(false) .git_ignore(true)
77 .require_git(false)
81 .parents(false)
85 .git_global(false) .git_exclude(false) .filter_entry(|e| {
88 !matches!(
89 e.file_name().to_str(),
90 Some(".git" | "target" | ".repolith")
91 )
92 })
93 .build();
94
95 for entry in walker {
96 let entry = entry.map_err(|e| BuildError::Io(format!("walk {}: {e}", path.display())))?;
97 if !entry.file_type().is_some_and(|t| t.is_file()) {
98 continue;
99 }
100 let rel = entry
101 .path()
102 .strip_prefix(path)
103 .unwrap_or(entry.path())
104 .to_string_lossy()
105 .replace('\\', "/");
108 entries.push((rel, entry.path().to_path_buf()));
109 }
110 entries.sort();
111
112 let mut h = ShaHasher::new();
113 h.update(b"tree:v1:");
114 for (rel, abs) in &entries {
115 h.update(rel.as_bytes());
116 h.update(b"\0");
117 match std::fs::read(abs) {
118 Ok(bytes) => {
119 h.update(b"ok:");
120 h.update(&bytes);
121 }
122 Err(e) => {
125 h.update(b"unreadable:");
126 h.update(e.kind().to_string().as_bytes());
127 }
128 }
129 h.update(b"\n");
130 }
131 Ok(Sha256(h.finalize().into()))
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use std::fs;
138
139 fn digest(dir: &Path) -> Sha256 {
140 tree_digest(dir).expect("digest")
141 }
142
143 #[test]
144 fn missing_tree_is_an_error() {
145 assert!(tree_digest(Path::new("/nonexistent/repolith/tree")).is_err());
146 }
147
148 #[test]
149 fn same_content_same_digest() {
150 let a = tempfile::tempdir().unwrap();
151 let b = tempfile::tempdir().unwrap();
152 for d in [a.path(), b.path()] {
153 fs::create_dir(d.join("src")).unwrap();
154 fs::write(d.join("src/main.rs"), b"fn main() {}").unwrap();
155 fs::write(d.join("Cargo.toml"), b"[package]\nname=\"x\"").unwrap();
156 }
157 assert_eq!(digest(a.path()), digest(b.path()), "content-only digest");
158 }
159
160 #[test]
161 fn one_byte_edit_changes_the_digest() {
162 let dir = tempfile::tempdir().unwrap();
163 fs::write(dir.path().join("f.rs"), b"fn main() {}").unwrap();
164 let before = digest(dir.path());
165 fs::write(dir.path().join("f.rs"), b"fn main() { }").unwrap();
166 assert_ne!(before, digest(dir.path()), "edit must be visible");
167 }
168
169 #[test]
170 fn touch_alone_does_not_change_the_digest() {
171 let dir = tempfile::tempdir().unwrap();
174 let f = dir.path().join("f.rs");
175 fs::write(&f, b"same").unwrap();
176 let before = digest(dir.path());
177 let content = fs::read(&f).unwrap();
178 fs::write(&f, &content).unwrap(); assert_eq!(before, digest(dir.path()), "mtime must not leak in");
180 }
181
182 #[test]
183 fn target_and_git_are_excluded() {
184 let dir = tempfile::tempdir().unwrap();
185 fs::write(dir.path().join("f.rs"), b"src").unwrap();
186 let before = digest(dir.path());
187
188 fs::create_dir(dir.path().join("target")).unwrap();
189 fs::write(dir.path().join("target/huge.bin"), vec![0u8; 4096]).unwrap();
190 fs::create_dir(dir.path().join(".git")).unwrap();
191 fs::write(dir.path().join(".git/HEAD"), b"ref: refs/heads/main").unwrap();
192
193 assert_eq!(
194 before,
195 digest(dir.path()),
196 "build output and git internals must not be hashed"
197 );
198 }
199
200 #[test]
201 fn gitignored_files_are_excluded() {
202 let dir = tempfile::tempdir().unwrap();
203 fs::write(dir.path().join(".gitignore"), b"secret.txt\n").unwrap();
204 fs::write(dir.path().join("f.rs"), b"src").unwrap();
205 let before = digest(dir.path());
206 fs::write(dir.path().join("secret.txt"), b"ignored").unwrap();
207 assert_eq!(before, digest(dir.path()), ".gitignore must be honored");
208 }
209
210 #[test]
211 fn adding_a_file_changes_the_digest() {
212 let dir = tempfile::tempdir().unwrap();
213 fs::write(dir.path().join("a.rs"), b"a").unwrap();
214 let before = digest(dir.path());
215 fs::write(dir.path().join("b.rs"), b"b").unwrap();
216 assert_ne!(before, digest(dir.path()), "new file must be visible");
217 }
218
219 #[test]
220 fn renaming_changes_the_digest() {
221 let dir = tempfile::tempdir().unwrap();
223 fs::write(dir.path().join("a.rs"), b"same").unwrap();
224 let before = digest(dir.path());
225 fs::rename(dir.path().join("a.rs"), dir.path().join("b.rs")).unwrap();
226 assert_ne!(before, digest(dir.path()));
227 }
228
229 #[test]
230 fn platform_tag_is_mixed_in() {
231 let mut with = ShaHasher::new();
232 with.update(b"x");
233 platform_tag(&mut with);
234 let mut without = ShaHasher::new();
235 without.update(b"x");
236 assert_ne!(
237 with.finalize().to_vec(),
238 without.finalize().to_vec(),
239 "platform must contribute to the hash"
240 );
241 }
242}