Skip to main content

majordomus_cli/
generation.rs

1//! The *generation* of an executable: which revision of the crate it was built from.
2//!
3//! A version string is not a generation. Two builds that both call themselves
4//! `majordomus-cli 0.4.0` can carry different models, and a derivation run with the wrong
5//! one is silent: `generate` writes what its own model says and exits 0. It happened on
6//! 2026-09-10 — a binary built from an older revision of this crate was handed to
7//! `scripts/derive` through `MAJORDOMUS_BIN` and rewrote a pristine `origin/master` from
8//! that older model. 16,625 lines removed across ten artifacts, `docs/generated/changelog.json`
9//! alone losing 9,724 of them, exit 0, no diagnostic. It was caught because somebody read
10//! `git diff --stat` before committing.
11//!
12//! So the executable carries a digest of the sources it was built from, computed here at
13//! build time (`build.rs` compiles this module too, through `#[path]`) and again at run time over
14//! the crate of the repository being derived. Equal means this executable *is* that tree's
15//! executable, whatever either of them calls itself; the comparison is a property of the
16//! code rather than of a string a human types into a manifest.
17//!
18//! Nothing outside `std` and `sha2` may be used in this file, and nothing lives here that
19//! only the crate needs. A build script cannot depend on the crate it is building, so the
20//! one definition of the digest has to compile on both sides — which is also why it is one
21//! definition and not two that could drift — and an item here that the build script does
22//! not reach is dead code in that compilation and fails `-D warnings`. The diagnostics that
23//! *render* a generation belong with the command that prints them.
24//!
25//! ```
26//! use majordomus_cli::generation::crate_generation;
27//!
28//! // one crate directory, taken twice: what `build.rs` compiles into the executable, and
29//! // what `majordomus generate` recomputes over the tree it is asked to derive
30//! let tree = tempfile::tempdir().unwrap();
31//! std::fs::create_dir_all(tree.path().join("src")).unwrap();
32//! std::fs::write(tree.path().join("src/lib.rs"), "pub fn f() {}\n").unwrap();
33//! let built_from = crate_generation(tree.path()).unwrap();
34//! assert_eq!(crate_generation(tree.path()), Some(built_from.clone()));
35//!
36//! // the sources move, and the executable built from the old ones is no longer this
37//! // tree's executable — which is the whole question, and the one a version cannot answer
38//! std::fs::write(tree.path().join("src/lib.rs"), "pub fn g() {}\n").unwrap();
39//! assert_ne!(crate_generation(tree.path()), Some(built_from));
40//! ```
41
42use sha2::{Digest, Sha256};
43use std::path::{Path, PathBuf};
44
45/// The inputs a generation is taken over: the crate's manifest, its resolved dependency
46/// graph, its build script and its sources.
47///
48/// `benches/` and `tests/` are deliberately outside it. They change nothing about what a
49/// build of the executable produces, and a generation that moved when a test was edited
50/// would refuse a binary that is in fact correct — the false refusal that would push the
51/// next session back to `MAJORDOMUS_BIN` and to this defect.
52pub const GENERATION_INPUTS: &[&str] = &["Cargo.toml", "Cargo.lock", "build.rs", "src"];
53
54/// The generation of the crate rooted at `crate_dir`: a hex SHA-256 over every file of
55/// [`GENERATION_INPUTS`], each framed by the length and bytes of its crate-relative path
56/// and by its own length, so that no rename, concatenation or reordering can produce the
57/// digest of a different tree.
58///
59/// Files whose name begins with `.` are skipped. An editor's swap file or a `.DS_Store`
60/// dropped into `src/` after the build would otherwise move the tree's generation without
61/// moving anything the compiler read, and the refusal that followed would be a lie.
62///
63/// `None` when `crate_dir` carries no crate at all: there is nothing there to be a
64/// generation of, and the caller — a released binary in a foreign repository — has no
65/// question to ask.
66///
67/// ```
68/// use majordomus_cli::generation::crate_generation;
69///
70/// let tree = tempfile::tempdir().unwrap();
71/// // a directory with no crate in it is not a generation of anything
72/// assert_eq!(crate_generation(tree.path()), None);
73///
74/// std::fs::create_dir_all(tree.path().join("src")).unwrap();
75/// std::fs::write(tree.path().join("src/lib.rs"), "pub fn f() {}\n").unwrap();
76/// let before = crate_generation(tree.path()).unwrap();
77/// assert_eq!(before.len(), 64);
78///
79/// // one comment, no behaviour, and it is already another generation: the executable this
80/// // tree builds is not the executable it built a moment ago, and only this says so
81/// std::fs::write(tree.path().join("src/lib.rs"), "// note\npub fn f() {}\n").unwrap();
82/// assert_ne!(before, crate_generation(tree.path()).unwrap());
83/// ```
84pub fn crate_generation(crate_dir: &Path) -> Option<String> {
85    let mut files: Vec<(String, PathBuf)> = Vec::new();
86    for input in GENERATION_INPUTS {
87        collect(crate_dir, &crate_dir.join(input), &mut files);
88    }
89    if files.is_empty() {
90        return None;
91    }
92    files.sort();
93    let mut hasher = Sha256::new();
94    for (rel, path) in &files {
95        let bytes = std::fs::read(path).ok()?;
96        hasher.update((rel.len() as u64).to_le_bytes());
97        hasher.update(rel.as_bytes());
98        hasher.update((bytes.len() as u64).to_le_bytes());
99        hasher.update(&bytes);
100    }
101    Some(format!("{:x}", hasher.finalize()))
102}
103
104/// Every regular file under `path`, crate-relative, appended to `out`. Recursive, and
105/// order-independent: the caller sorts, because a directory listing is not ordered.
106fn collect(root: &Path, path: &Path, out: &mut Vec<(String, PathBuf)>) {
107    if path
108        .file_name()
109        .and_then(|n| n.to_str())
110        .is_some_and(|n| n.starts_with('.'))
111    {
112        return;
113    }
114    if path.is_dir() {
115        let Ok(entries) = std::fs::read_dir(path) else {
116            return;
117        };
118        for entry in entries.flatten() {
119            collect(root, &entry.path(), out);
120        }
121    } else if path.is_file() {
122        if let Some(rel) = path.strip_prefix(root).ok().and_then(Path::to_str) {
123            out.push((rel.to_string(), path.to_path_buf()));
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::crate_generation;
131    use std::fs;
132
133    /// A crate directory with one source file, so the property under test is the digest and
134    /// not the tree.
135    fn crate_at(dir: &std::path::Path, manifest: &str, source: &str) {
136        fs::create_dir_all(dir.join("src")).unwrap();
137        fs::write(dir.join("Cargo.toml"), manifest).unwrap();
138        fs::write(dir.join("src/lib.rs"), source).unwrap();
139    }
140
141    #[test]
142    fn a_directory_without_a_crate_has_no_generation() {
143        let t = tempfile::tempdir().unwrap();
144        assert_eq!(crate_generation(t.path()), None);
145    }
146
147    #[test]
148    fn the_same_sources_are_the_same_generation_and_a_changed_source_is_not() {
149        let a = tempfile::tempdir().unwrap();
150        let b = tempfile::tempdir().unwrap();
151        crate_at(a.path(), "[package]\nname = \"x\"\n", "pub fn f() {}\n");
152        crate_at(b.path(), "[package]\nname = \"x\"\n", "pub fn f() {}\n");
153        assert_eq!(crate_generation(a.path()), crate_generation(b.path()));
154        // one comment, no behaviour: still another generation, because the executable this
155        // tree builds is not the executable the other one builds and nothing else can say so
156        fs::write(b.path().join("src/lib.rs"), "// note\npub fn f() {}\n").unwrap();
157        assert_ne!(crate_generation(a.path()), crate_generation(b.path()));
158    }
159
160    #[test]
161    fn a_renamed_source_is_another_generation() {
162        // the path is hashed with its content: two trees holding the same bytes under
163        // different names are different crates, and a digest of content alone would agree
164        let a = tempfile::tempdir().unwrap();
165        let b = tempfile::tempdir().unwrap();
166        crate_at(a.path(), "[package]\nname = \"x\"\n", "pub fn f() {}\n");
167        crate_at(b.path(), "[package]\nname = \"x\"\n", "pub fn f() {}\n");
168        fs::rename(b.path().join("src/lib.rs"), b.path().join("src/main.rs")).unwrap();
169        assert_ne!(crate_generation(a.path()), crate_generation(b.path()));
170    }
171
172    #[test]
173    fn a_dotfile_dropped_beside_the_sources_is_not_part_of_the_generation() {
174        let t = tempfile::tempdir().unwrap();
175        crate_at(t.path(), "[package]\nname = \"x\"\n", "pub fn f() {}\n");
176        let before = crate_generation(t.path());
177        fs::write(t.path().join("src/.DS_Store"), b"\x00\x01").unwrap();
178        assert_eq!(before, crate_generation(t.path()));
179    }
180
181    #[test]
182    fn tests_and_benches_are_outside_the_generation() {
183        let t = tempfile::tempdir().unwrap();
184        crate_at(t.path(), "[package]\nname = \"x\"\n", "pub fn f() {}\n");
185        let before = crate_generation(t.path());
186        fs::create_dir_all(t.path().join("tests")).unwrap();
187        fs::write(t.path().join("tests/it.rs"), "#[test]\nfn t() {}\n").unwrap();
188        assert_eq!(before, crate_generation(t.path()));
189    }
190}