Skip to main content

repolith_actions/
source_hash.rs

1//! Shared input-hash ingredients: build platform and source-tree digest.
2//!
3//! Both live here rather than in each action because getting either one
4//! wrong produces the same failure — a cache that reports `up to date`
5//! while the artifact is stale — and that failure is silent.
6//!
7//! # Why the platform belongs in the hash
8//!
9//! `cargo --version` carries no target triple (`cargo -vV` does), so
10//! without `platform_tag` an aarch64 macOS build and an x86_64 Linux
11//! build of the same source hash identically. Harmless with a per-machine
12//! `SQLite` cache; wrong the moment a Neo4j backend is shared, where the
13//! second machine reads the first's entry and installs nothing at all.
14//!
15//! `RUSTFLAGS` is deliberately absent: it is not in the CLI's
16//! `ENV_ALLOWLIST`, so it never reaches cargo and cannot change the
17//! build. `RUSTUP_TOOLCHAIN` *is* allowlisted, but is covered
18//! transitively — `cargo --version` runs through the rustup shim and
19//! reports the resolved toolchain.
20//!
21//! # Why the digest is content-only
22//!
23//! No mtimes: a fresh CI clone rewrites them all and would make every
24//! action look stale forever. Hashing content instead keeps the digest
25//! stable across machines, which a shared cache backend requires.
26//!
27//! The walk honors `.gitignore` and always skips `.git/` and `target/` —
28//! hashing build output would be both enormous and self-defeating, since
29//! it changes on every build and would trigger a rebuild on every sync.
30
31use ignore::WalkBuilder;
32use repolith_core::types::{BuildError, Sha256};
33use sha2::{Digest, Sha256 as ShaHasher};
34use std::path::Path;
35
36/// Feed the build platform into a hasher.
37///
38/// Compile-time constants of the running `repolith` binary — no
39/// subprocess. repolith has no cross-compilation support, so the host
40/// platform *is* the target platform.
41pub 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
48/// Content digest of the source tree rooted at `path`.
49///
50/// Hashes each file's path-relative-to-root and its bytes, in a stable
51/// (sorted) order so two machines agree. Honors `.gitignore`; always
52/// skips `.git/` and `target/`.
53///
54/// # Errors
55/// [`BuildError::Io`] when the root cannot be walked. Individual
56/// unreadable entries are folded into the digest as a marker rather than
57/// aborting: a permission-denied file is a real input state, and failing
58/// the whole plan over one unreadable path would be worse than rebuilding.
59pub 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    // Collect first, then sort: `ignore`'s walk order is filesystem
68    // dependent, and an unstable order would produce a different digest
69    // for identical content.
70    let mut entries: Vec<(String, std::path::PathBuf)> = Vec::new();
71    let walker = WalkBuilder::new(path)
72        .hidden(false) // .config/, .cargo/ etc. are real inputs
73        // The digest must depend on the tree's own content and nothing
74        // else, or two machines will disagree. So: honor ignore files
75        // found *inside* the tree, and no others.
76        .git_ignore(true)
77        // Apply the tree's own `.gitignore` whether or not it is a
78        // worktree — otherwise a git clone and a vendored copy of the
79        // same sources would digest differently.
80        .require_git(false)
81        // Never walk up. A stray `.gitignore` in a parent (a real one
82        // lives in macOS `$TMPDIR`) would otherwise silently swallow the
83        // whole tree, and its presence differs from machine to machine.
84        .parents(false)
85        .git_global(false) // machine-local global ignore
86        .git_exclude(false) // `.git/info/exclude` is per-clone, not committed
87        .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            // Normalize separators so a Windows peer and a Unix peer
106            // agree on the digest of the same tree.
107            .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            // Unreadable is itself a state worth distinguishing from
123            // absent or empty; don't fail the plan over it.
124            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        // The regression that mtime-based hashing would introduce: a
172        // fresh clone rewrites every mtime and would look fully stale.
173        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(); // rewrite → new mtime, same bytes
179        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        // Path is hashed alongside content, so a pure rename is a change.
222        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}