git-ents.gitmain
⌘K
foforge
hosted_root.rs355 lines · 14.4 KB · rusthistorycomment on this file
1//! End-to-end coverage of the single-node hosted root
2//! (`docs/development-plan.adoc`'s phase-6 row): a real bare repository
3//! served by *stock git's own* `receive-pack`, with `pre-receive` /
4//! `post-receive` hooks shelling to the built `git-ents` binary's `hook`
5//! plumbing subcommands (`crate::hook`).
6//!
7//! This is the literal shape `git.ents.cloud` runs: nothing here is
8//! simulated at the library-call level — every push goes through a real
9//! `git push` subprocess against a real bare repository, exactly as an
10//! external contributor's client would see it.
11#![allow(clippy::expect_used, reason = "integration test")]
12
13mod common;
14
15use std::path::Path;
16use std::process::Command;
17
18use git_ents::root::LocalRoot;
19
20/// Install `pre-receive` and `post-receive` hooks on `bare` by running the
21/// real, built `git ents setup --hosted` command — not a test-harness
22/// stand-in — over a subprocess, exactly how an operator deploying the
23/// single-node hosted root would (`roots.single-node-hosted`).
24///
25/// Neither test defines an effect, so `post-receive` never has a pending
26/// obligation to sign results for — meaning it is safe for the scratch
27/// `HOME` this generates a key under to be cleaned up once this function
28/// returns; nothing later needs to load that key again.
29fn setup_hosted(bare: &Path) {
30 let scratch_home = tempfile::tempdir().expect("tempdir");
31 let output = Command::new(common::bin_path())
32 .arg("setup")
33 .arg("--hosted")
34 .arg(bare)
35 // Isolate from the ambient environment the same way `git()` below
36 // does, but keep a real (scratch) HOME: `setup --hosted` needs
37 // somewhere to generate a signing key when neither `--key` nor
38 // `user.signingkey` resolves to one.
39 .env("GIT_CONFIG_GLOBAL", "/dev/null")
40 .env("GIT_CONFIG_SYSTEM", "/dev/null")
41 .env("HOME", scratch_home.path())
42 .output()
43 .expect("git-ents runs");
44 assert!(output.status.success(), "{output:?}");
45
46 // The public half published for `git ents bootstrap`'s discovery —
47 // written next to the key `setup --hosted` resolved (here, the one it
48 // generated under the scratch HOME).
49 let pub_path = scratch_home.path().join(".ssh").join("id_ed25519.pub");
50 assert!(
51 pub_path.exists(),
52 "setup --hosted must write the key's public half"
53 );
54 let pubkey = std::fs::read_to_string(&pub_path).expect("readable");
55 assert!(pubkey.starts_with("ssh-"), "{pubkey:?}");
56
57 for hook in ["pre-receive", "post-receive"] {
58 let path = bare.join("hooks").join(hook);
59 assert!(path.exists(), "setup --hosted must install {hook}");
60 #[cfg(unix)]
61 {
62 use std::os::unix::fs::PermissionsExt as _;
63 let mode = std::fs::metadata(&path).expect("meta").permissions().mode();
64 assert!(mode & 0o111 != 0, "{hook} must be executable: {mode:o}");
65 }
66 }
67}
68
69fn git(dir: &Path, args: &[&str]) -> std::process::Output {
70 Command::new("git")
71 .arg("-C")
72 .arg(dir)
73 .args(args)
74 .env("GIT_AUTHOR_NAME", "test")
75 .env("GIT_AUTHOR_EMAIL", "test@ents.test")
76 .env("GIT_COMMITTER_NAME", "test")
77 .env("GIT_COMMITTER_EMAIL", "test@ents.test")
78 // Isolate from whatever `~/.gitconfig`/`~/.ssh` the machine
79 // running this test happens to have — a hosted worker's signing
80 // key resolution must never depend on the ambient environment.
81 .env("GIT_CONFIG_GLOBAL", "/dev/null")
82 .env("GIT_CONFIG_SYSTEM", "/dev/null")
83 .env_remove("HOME")
84 .output()
85 .expect("git runs")
86}
87
88/// A signed enrollment commit on `refs/meta/member/<username>`, built
89/// in-process against a scratch clone via `git-ents`'s own local root
90/// (`LocalRoot`, exactly what `git ents members add` does) — this is how a
91/// real client would produce the bytes a push transmits.
92fn build_member_commit(clone: &Path, key: &Path, username: &str) {
93 let root = LocalRoot::open(clone).expect("opens clone as a local root");
94 git_ents::commands::members::add(&root, username, None, Some(key.to_owned()))
95 .expect("builds and lands the signed enrollment commit locally");
96}
97
98/// A bootstrap enrollment pushed to the single-node hosted root round
99/// trips: `setup_hosted` (`git ents setup --hosted`, `roots.single-node-hosted`)
100/// installs the real hooks, then the mandatory gate (`gate.mandatory-hosted`)
101/// admits the push under the bootstrap window (`gate.bootstrap`) exactly as
102/// the advisory local root would, and the ref lands on the bare repository
103/// for real, over a real `git push`.
104// @relation(roots.local, roots.composition, roots.single-node-hosted, gate.mandatory-hosted, gate.bootstrap, scope=function, role=Verifies)
105#[test]
106fn bootstrap_push_round_trips_through_the_hosted_root() {
107 let bare = common::Fixture::new_bare(20);
108 setup_hosted(bare.path());
109
110 let clone_dir = tempfile::tempdir().expect("tempdir");
111 let clone_output = git(
112 clone_dir.path(),
113 &["clone", "--quiet", bare.path().to_str().expect("utf8"), "."],
114 );
115 assert!(clone_output.status.success(), "{clone_output:?}");
116
117 let key = common::write_key_in(clone_dir.path(), 21);
118 build_member_commit(clone_dir.path(), &key, "jdc");
119
120 // The very first push, before any member is enrolled, is admitted
121 // unsigned — the bootstrap window (`gate.bootstrap`)'s transport
122 // counterpart: nobody is enrolled yet to have signed it as.
123 let push = git(
124 clone_dir.path(),
125 &["push", "origin", "refs/meta/member/jdc"],
126 );
127 assert!(
128 push.status.success(),
129 "bootstrap push must be accepted by the mandatory gate: {push:?}"
130 );
131
132 // The ref really landed on the bare (hosted) repository, not just the
133 // client's own clone.
134 let show = git(bare.path(), &["show-ref", "refs/meta/member/jdc"]);
135 assert!(
136 show.status.success(),
137 "ref must exist on the hosted root: {show:?}"
138 );
139}
140
141/// The operator bootstrap porcelain (`git ents bootstrap`) against a
142/// fresh hosted root: one command from a clone enrolls the operator
143/// under the self-admitting window (`gate.bootstrap`), then the server
144/// key under the operator's own signature (`roots.web-signing`), landing
145/// both refs on the bare repository over real pushes — the whole
146/// first-boot runbook `docker/entrypoint.sh` waits on.
147// @relation(gate.bootstrap, roots.web-signing, scope=function, role=Verifies)
148#[test]
149fn bootstrap_command_enrolls_operator_then_server_key() {
150 let bare = common::Fixture::new_bare(30);
151 setup_hosted(bare.path());
152
153 let clone_dir = tempfile::tempdir().expect("tempdir");
154 let clone_output = git(
155 clone_dir.path(),
156 &["clone", "--quiet", bare.path().to_str().expect("utf8"), "."],
157 );
158 assert!(clone_output.status.success(), "{clone_output:?}");
159
160 let operator_key = common::write_key_in(clone_dir.path(), 31);
161 let server_key = clone_dir.path().join(".server_key");
162 common::write_key(&server_key, 32);
163 let server_pubkey = git_ents::sign::Signer::load(&server_key)
164 .expect("loads server key")
165 .public_openssh();
166
167 let output = Command::new(common::bin_path())
168 .args(["bootstrap", "jdc", "--server-pubkey"])
169 .arg(&server_pubkey)
170 .arg("--key")
171 .arg(&operator_key)
172 .current_dir(clone_dir.path())
173 .env("GIT_AUTHOR_NAME", "test")
174 .env("GIT_AUTHOR_EMAIL", "test@ents.test")
175 .env("GIT_COMMITTER_NAME", "test")
176 .env("GIT_COMMITTER_EMAIL", "test@ents.test")
177 .env("GIT_CONFIG_GLOBAL", "/dev/null")
178 .env("GIT_CONFIG_SYSTEM", "/dev/null")
179 .env_remove("HOME")
180 .output()
181 .expect("git-ents runs");
182 assert!(output.status.success(), "{output:?}");
183
184 for member in ["jdc", "forge"] {
185 let show = git(bare.path(), &["show-ref", &format!("refs/meta/member/{member}")]);
186 assert!(
187 show.status.success(),
188 "refs/meta/member/{member} must land on the hosted root: {show:?}"
189 );
190 }
191}
192
193/// A second push, from a *different, unenrolled* signer, straight onto a
194/// canonical meta-ref with no admin standing, must be refused by the
195/// mandatory gate — and because `pre-receive` rejects the whole batch
196/// before git writes anything, the object graph never lands either.
197// @relation(gate.mandatory-hosted, gate.verdict-reason, scope=function, role=Verifies)
198#[test]
199fn unauthorized_push_is_refused_by_the_hosted_root() {
200 let bare = common::Fixture::new_bare(22);
201 setup_hosted(bare.path());
202
203 // First, a legitimate admin bootstraps the repository.
204 let admin_clone = tempfile::tempdir().expect("tempdir");
205 let clone_output = git(
206 admin_clone.path(),
207 &["clone", "--quiet", bare.path().to_str().expect("utf8"), "."],
208 );
209 assert!(clone_output.status.success());
210 let admin_key = common::write_key_in(admin_clone.path(), 23);
211 build_member_commit(admin_clone.path(), &admin_key, "admin");
212 let push = git(
213 admin_clone.path(),
214 &["push", "origin", "refs/meta/member/admin"],
215 );
216 assert!(push.status.success(), "{push:?}");
217
218 // Turn on the tip invariant (`gate.epoch`): before an epoch is
219 // recorded, every `refs/meta/*` update passes as `PreEpoch` — history
220 // before verification is archival, not yet gated. No porcelain command
221 // sets this yet (a genuine, explicitly deferred gap; see this crate's
222 // final report), so this test writes the config entity the same way
223 // `ents-receive`'s own doctest does: directly through
224 // `ents_receive::propose_entity`, admin-signed.
225 let admin_root = LocalRoot::open(admin_clone.path()).expect("opens");
226 let admin_signer = git_ents::sign::Signer::load(&admin_key).expect("loads");
227 let identity = ents_receive::Identity {
228 actor: gix::actor::Signature {
229 name: "admin".into(),
230 email: "admin@ents.test".into(),
231 time: gix::date::Time {
232 seconds: 1_000,
233 offset: 0,
234 },
235 },
236 author: None,
237 sign: &|payload| admin_signer.sign(payload),
238 };
239 let config_ref: gix::refs::FullName =
240 ents_model::namespace::CONFIG_REF.try_into().expect("valid");
241 let outcome = ents_receive::propose_entity(
242 &admin_root.refs,
243 &admin_root.objects,
244 &admin_root.events,
245 config_ref,
246 &ents_gate::Config {
247 epoch: Some(1_000),
248 },
249 &identity,
250 "Enable the tip invariant",
251 admin_root.mode(),
252 )
253 .expect("evaluates");
254 git_ents::mutate::outcome_to_result(outcome, None).expect("admin may set the epoch");
255 // Admin is now an enrolled, active member, so this push must itself
256 // carry a valid signed-push certificate under the admin's key.
257 common::configure_signing(admin_clone.path(), &admin_key);
258 let push = git(
259 admin_clone.path(),
260 &["push", "--signed=if-asked", "origin", "refs/meta/config"],
261 );
262 assert!(push.status.success(), "{push:?}");
263
264 // Now a second, unenrolled signer tries to enroll a member directly —
265 // an ordinary member ref is unauthorized-namespace by default without
266 // an admin doing it, so this must be refused.
267 let outsider_clone = tempfile::tempdir().expect("tempdir");
268 let clone_output = git(
269 outsider_clone.path(),
270 &["clone", "--quiet", bare.path().to_str().expect("utf8"), "."],
271 );
272 assert!(clone_output.status.success());
273 let outsider_key = common::write_key_in(outsider_clone.path(), 24);
274 // Fetch the admin's enrollment first so the local root's own gate
275 // check has the current member list to read (mirrors a real client's
276 // fetch-before-push).
277 let fetch = git(
278 outsider_clone.path(),
279 &["fetch", "origin", "+refs/meta/*:refs/meta/*"],
280 );
281 assert!(fetch.status.success(), "{fetch:?}");
282 build_member_commit(outsider_clone.path(), &outsider_key, "mallory");
283
284 // Admin is already enrolled by this point, so the hosted root now
285 // requires every push to be signed; sign this one too, so the
286 // rejection below demonstrates the mandatory gate refusing an
287 // unauthorized (if honestly identified) signer, not merely an
288 // unsigned push.
289 common::configure_signing(outsider_clone.path(), &outsider_key);
290 let push = git(
291 outsider_clone.path(),
292 &[
293 "push",
294 "--signed=if-asked",
295 "origin",
296 "refs/meta/member/mallory",
297 ],
298 );
299 assert!(
300 !push.status.success(),
301 "an unauthorized signer's push must be refused by the mandatory gate"
302 );
303
304 let show = git(bare.path(), &["show-ref", "refs/meta/member/mallory"]);
305 assert!(
306 !show.status.success(),
307 "a refused pre-receive push must leave no trace on the hosted root"
308 );
309}
310
311/// `serve --hosted` fails closed on an unenrolled server key
312/// (`roots.web-signing`: the signing key must itself be an enrolled
313/// member) and boots once the key is enrolled, with the enrolled
314/// username as the serving identity's label.
315// @relation(roots.web-signing, roots.single-node-hosted, scope=function, role=Verifies)
316#[test]
317fn hosted_serve_boots_only_with_an_enrolled_server_key() {
318 use ssh_key::private::{Ed25519Keypair, KeypairData};
319
320 let dir = tempfile::tempdir().expect("tempdir");
321 let bare = dir.path().join("repo.git");
322 let output = Command::new("git")
323 .args(["init", "--bare"])
324 .arg(&bare)
325 .output()
326 .expect("git runs");
327 assert!(output.status.success(), "{output:?}");
328
329 let key_path = dir.path().join("hosted_signing_key");
330 let pair = Ed25519Keypair::from_seed(&[42; 32]);
331 let key = ssh_key::PrivateKey::new(KeypairData::from(pair), "server").expect("well-formed");
332 key.write_openssh_file(&key_path, ssh_key::LineEnding::LF)
333 .expect("writes");
334
335 let root = git_ents::root::HostedRoot::open(&bare).expect("opens");
336 let refused = git_ents::commands::serve::build_hosted_state(
337 root,
338 key_path.clone(),
339 "ents.test".to_owned(),
340 );
341 assert!(
342 refused.is_err(),
343 "an unenrolled server key must refuse to boot"
344 );
345
346 let local = LocalRoot::open(&bare).expect("opens");
347 git_ents::commands::members::add(&local, "server", None, Some(key_path.clone()))
348 .expect("enrolls the server key");
349
350 let root = git_ents::root::HostedRoot::open(&bare).expect("opens");
351 let state =
352 git_ents::commands::serve::build_hosted_state(root, key_path, "ents.test".to_owned())
353 .expect("boots once enrolled");
354 assert_eq!(state.identity.label(), "server");
355}