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}