majordomus_cli/mesh/repository.rs
1//! Which repository and which runtime a mesh participant is — independently of any path.
2//!
3//! A repository's mesh identity must be the same on every machine that holds a clone of
4//! it and different for every other repository. The path of the git directory (what
5//! `server.status` digests to tell checkouts apart) is neither: a macOS checkout path and
6//! the Linux checkout path of the same clone are one repository with two spellings. So the
7//! mesh identity is derived from
8//! the repository's **root commits** — content every full clone shares and no unrelated
9//! repository has — or, when a person declares one, from the declared identity
10//! (`cooperation.repository`), which is what a shallow clone needs and what separates a
11//! fork that should not cooperate with its origin.
12//!
13//! A runtime is one server of one node: one per checkout. Its id is a digest of the
14//! checkout id, so a restarted server of the same checkout is the same runtime (a new
15//! instance of it), and two worktrees of one machine are two runtimes.
16//!
17//! ```
18//! use majordomus_cli::mesh::repository::{declared, of_root_commits, runtime_id};
19//!
20//! let a = of_root_commits(&["b".repeat(40), "a".repeat(40)]);
21//! let b = of_root_commits(&["a".repeat(40), "b".repeat(40)]);
22//! assert_eq!(a.id, b.id, "the order git lists roots in does not matter");
23//! assert_ne!(a.id, declared("majordomus").unwrap().id);
24//! assert_eq!(runtime_id("checkout-1").len(), 16);
25//! ```
26
27use std::path::Path;
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31use sha2::{Digest, Sha256};
32
33use super::MeshError;
34
35const REPOSITORY_DOMAIN: &str = "majordomus-mesh-repository/v1\n";
36const RUNTIME_DOMAIN: &str = "majordomus-mesh-runtime/v1\n";
37
38/// What the repository identity was derived from, which a reader needs in order to judge
39/// it: an identity taken from the root commits is a fact about the history two clones
40/// share, while a declared one is a decision somebody made, and only the second can be
41/// changed by editing a file.
42///
43/// ```
44/// use majordomus_cli::mesh::repository::{declared, of_root_commits, MeshRepositoryBasis};
45///
46/// assert_eq!(declared("majordomus").unwrap().basis, MeshRepositoryBasis::Declared);
47/// assert_eq!(
48/// of_root_commits(&["a".repeat(40)]).basis,
49/// MeshRepositoryBasis::RootCommits,
50/// );
51/// ```
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
53#[serde(rename_all = "snake_case")]
54pub enum MeshRepositoryBasis {
55 /// `cooperation.repository` in the mesh declaration.
56 Declared,
57 /// The repository's root commits.
58 RootCommits,
59}
60
61/// A repository's mesh identity, carried by every advertisement, link and event, together
62/// with the account of where it came from. The `detail` exists so that a person told two
63/// checkouts are not the same repository can see what each one was derived from instead of
64/// comparing two opaque digests.
65///
66/// ```
67/// use majordomus_cli::mesh::repository::MeshRepositoryIdentity;
68/// use majordomus_cli::mesh::repository::of_root_commits;
69///
70/// let identity: MeshRepositoryIdentity = of_root_commits(&["a".repeat(40)]);
71/// assert_eq!(identity.id.len(), 32, "32 hex characters, on every machine");
72/// assert_eq!(identity.detail, "aaaaaaaaaaaa", "the evidence, short enough to read");
73/// ```
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75pub struct MeshRepositoryIdentity {
76 /// 32 hex: what advertisements, links and events carry.
77 pub id: String,
78 /// What it was derived from.
79 pub basis: MeshRepositoryBasis,
80 /// The evidence, human-readable: the declared value, or the root commits.
81 pub detail: String,
82}
83
84fn digest(text: &str, hex_chars: usize) -> String {
85 let hash = Sha256::digest(text.as_bytes());
86 hash.iter()
87 .take(hex_chars / 2)
88 .map(|b| format!("{b:02x}"))
89 .collect()
90}
91
92/// The identity a declared value yields: what a shallow clone, which cannot see its own
93/// root commit, must use, and what separates a fork that should not cooperate with the
94/// repository it came from. The value is a name chosen by the people who run the mesh, not
95/// a digest, so it is bounded and must not be empty — an empty declaration would put every
96/// repository that forgot to fill it in on one mesh.
97///
98/// ```
99/// use majordomus_cli::mesh::repository::declared;
100///
101/// let a = declared("majordomus").unwrap();
102/// assert_eq!(a.id, declared(" majordomus ").unwrap().id, "surrounding space is noise");
103/// assert_ne!(a.id, declared("majordomus-fork").unwrap().id);
104/// assert!(declared("").is_err(), "an empty declaration would merge unrelated repositories");
105/// ```
106pub fn declared(value: &str) -> Result<MeshRepositoryIdentity, MeshError> {
107 let value = value.trim();
108 if value.is_empty() || value.len() > 128 {
109 return Err(MeshError::Config(
110 "a declared repository identity is 1 to 128 characters".into(),
111 ));
112 }
113 Ok(MeshRepositoryIdentity {
114 id: digest(&format!("{REPOSITORY_DOMAIN}declared\n{value}"), 32),
115 basis: MeshRepositoryBasis::Declared,
116 detail: value.into(),
117 })
118}
119
120/// The identity a set of root commits yields. The roots are treated as a set rather than a
121/// list, because `git rev-list` makes no promise about the order it prints them in and two
122/// machines that disagreed about that order would decide they held different repositories;
123/// for the same reason the same root named twice is one root.
124///
125/// ```
126/// use majordomus_cli::mesh::repository::of_root_commits;
127///
128/// let a = of_root_commits(&["b".repeat(40), "a".repeat(40)]);
129/// let b = of_root_commits(&["a".repeat(40), "b".repeat(40), "a".repeat(40)]);
130/// assert_eq!(a.id, b.id, "a set: order and repetition are not information");
131/// assert_ne!(a.id, of_root_commits(&["a".repeat(40)]).id);
132/// ```
133pub fn of_root_commits(roots: &[String]) -> MeshRepositoryIdentity {
134 // A set, so the digest reads the same on every machine whatever order the commits were
135 // listed in, and the same root named twice is one root.
136 let sorted: Vec<&str> = roots
137 .iter()
138 .map(|r| r.trim())
139 .collect::<std::collections::BTreeSet<_>>()
140 .into_iter()
141 .collect();
142 let joined = sorted.join("\n");
143 MeshRepositoryIdentity {
144 id: digest(&format!("{REPOSITORY_DOMAIN}roots\n{joined}"), 32),
145 basis: MeshRepositoryBasis::RootCommits,
146 detail: sorted
147 .iter()
148 .map(|r| r.chars().take(12).collect::<String>())
149 .collect::<Vec<_>>()
150 .join(", "),
151 }
152}
153
154/// Resolve the mesh identity of the repository at `root`: the declared value when there
155/// is one; otherwise its root commits, refusing a shallow clone (whose "root" is only
156/// where its history was cut) and a repository with no commit yet.
157///
158/// The declaration is consulted first and answers on its own, so a caller that has one
159/// never runs git — which is what lets a shallow clone, a worktree of one, or a directory
160/// that is not a repository at all still take part in a mesh.
161///
162/// ```
163/// use majordomus_cli::mesh::repository::{declared, resolve};
164///
165/// let nowhere = tempfile::tempdir().unwrap();
166/// let stated = resolve(nowhere.path(), Some("majordomus")).unwrap();
167/// assert_eq!(stated.id, declared("majordomus").unwrap().id, "git was never asked");
168///
169/// // without one, the history has to answer, and a directory holding no history cannot
170/// assert!(resolve(nowhere.path(), None).is_err());
171/// ```
172pub fn resolve(
173 root: &Path,
174 declared_value: Option<&str>,
175) -> Result<MeshRepositoryIdentity, MeshError> {
176 if let Some(value) = declared_value {
177 return declared(value);
178 }
179 let git = |args: &[&str]| -> Result<String, MeshError> {
180 let out = std::process::Command::new("git")
181 .arg("-C")
182 .arg(root)
183 .args(args)
184 .output()
185 .map_err(|e| MeshError::Config(format!("git: {e}")))?;
186 if !out.status.success() {
187 return Err(MeshError::Config(format!(
188 "git {}: {}",
189 args.join(" "),
190 String::from_utf8_lossy(&out.stderr).trim()
191 )));
192 }
193 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
194 };
195 if git(&["rev-parse", "--is-shallow-repository"])? == "true" {
196 return Err(MeshError::Config(
197 "a shallow clone's root commit is where its history was cut, not the repository's: declare cooperation.repository in the mesh declaration".into(),
198 ));
199 }
200 let roots: Vec<String> = git(&["rev-list", "--max-parents=0", "HEAD"])
201 .map_err(|_| {
202 MeshError::Config("the repository has no commit yet: nothing identifies it".into())
203 })?
204 .lines()
205 .map(str::to_string)
206 .collect();
207 if roots.is_empty() {
208 return Err(MeshError::Config(
209 "the repository has no root commit: nothing identifies it".into(),
210 ));
211 }
212 Ok(of_root_commits(&roots))
213}
214
215/// The runtime slot of a checkout: 16 hex of a digest of its checkout id. A runtime is one
216/// server of one node, so this is what makes a restarted server the same runtime as the
217/// one it replaced — a new instance of it rather than a new participant — while two
218/// worktrees of one machine stay two runtimes that can claim against each other.
219///
220/// ```
221/// use majordomus_cli::mesh::repository::runtime_id;
222///
223/// assert_eq!(runtime_id("checkout-1"), runtime_id("checkout-1"), "a restart is not a new runtime");
224/// assert_ne!(runtime_id("checkout-1"), runtime_id("checkout-2"));
225/// assert_eq!(runtime_id("checkout-1").len(), 16);
226/// ```
227pub fn runtime_id(checkout_id: &str) -> String {
228 digest(&format!("{RUNTIME_DOMAIN}{checkout_id}"), 16)
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn git(dir: &Path, args: &[&str]) {
236 let status = std::process::Command::new("git")
237 .arg("-C")
238 .arg(dir)
239 .args(args)
240 .env("GIT_AUTHOR_NAME", "t")
241 .env("GIT_AUTHOR_EMAIL", "t@t")
242 .env("GIT_COMMITTER_NAME", "t")
243 .env("GIT_COMMITTER_EMAIL", "t@t")
244 .output()
245 .expect("git runs");
246 assert!(
247 status.status.success(),
248 "git {args:?}: {}",
249 String::from_utf8_lossy(&status.stderr)
250 );
251 }
252
253 fn repo(dir: &Path, content: &str) {
254 std::fs::create_dir_all(dir).unwrap();
255 git(dir, &["init", "-q"]);
256 std::fs::write(dir.join("f"), content).unwrap();
257 git(dir, &["add", "f"]);
258 git(dir, &["commit", "-q", "-m", "root"]);
259 }
260
261 #[test]
262 fn two_clones_at_different_paths_are_one_repository_and_another_is_not() {
263 let tmp = tempfile::tempdir().unwrap();
264 let origin = tmp.path().join("machine-a/repo");
265 repo(&origin, "one");
266 let clone = tmp.path().join("machine-b/elsewhere/repo");
267 std::fs::create_dir_all(clone.parent().unwrap()).unwrap();
268 git(
269 tmp.path(),
270 &[
271 "clone",
272 "-q",
273 origin.to_str().unwrap(),
274 clone.to_str().unwrap(),
275 ],
276 );
277 let other = tmp.path().join("machine-a/other");
278 repo(&other, "two");
279
280 let a = resolve(&origin, None).unwrap();
281 let b = resolve(&clone, None).unwrap();
282 let c = resolve(&other, None).unwrap();
283 assert_eq!(a.id, b.id, "same history, different paths: one repository");
284 assert_ne!(a.id, c.id, "different history: different repositories");
285 assert_eq!(a.basis, MeshRepositoryBasis::RootCommits);
286 // A declaration overrides the history, both ways.
287 assert_eq!(
288 resolve(&origin, Some("x")).unwrap().id,
289 resolve(&other, Some("x")).unwrap().id
290 );
291 assert_ne!(resolve(&origin, Some("x")).unwrap().id, a.id);
292 }
293
294 #[test]
295 fn a_shallow_clone_and_an_empty_repository_are_refused_with_the_remedy() {
296 let tmp = tempfile::tempdir().unwrap();
297 let origin = tmp.path().join("origin");
298 repo(&origin, "one");
299 std::fs::write(origin.join("f"), "two").unwrap();
300 git(&origin, &["commit", "-q", "-am", "second"]);
301 let shallow = tmp.path().join("shallow");
302 git(
303 tmp.path(),
304 &[
305 "clone",
306 "-q",
307 "--depth",
308 "1",
309 &format!("file://{}", origin.display()),
310 shallow.to_str().unwrap(),
311 ],
312 );
313 let error = resolve(&shallow, None).unwrap_err().to_string();
314 assert!(error.contains("cooperation.repository"), "{error}");
315 assert!(resolve(&shallow, Some("declared")).is_ok());
316
317 let empty = tmp.path().join("empty");
318 std::fs::create_dir_all(&empty).unwrap();
319 git(&empty, &["init", "-q"]);
320 assert!(resolve(&empty, None).is_err());
321 }
322
323 #[test]
324 fn a_runtime_is_stable_per_checkout_and_distinct_across_checkouts() {
325 assert_eq!(runtime_id("c1"), runtime_id("c1"));
326 assert_ne!(runtime_id("c1"), runtime_id("c2"));
327 assert!(runtime_id("c1").bytes().all(|b| b.is_ascii_hexdigit()));
328 assert!(declared("").is_err());
329 }
330}