a73x

690c31f4

Bootstrap governance without changing what the server exposes

a73x   2026-08-13 14:55

Commit message
Bootstrap governance without changing what the server exposes

Governance shipped in b6dcbb7b and the exposure model in 1beeb03e, and
nothing could create a settings.git. The live deployment proved it: it
runs the code, hosts five repositories, and /data/repos/ has no settings
repo. Governance was present and entirely inert.

`git-collab-server setup --config <path> --admin-key <file|->` is the
missing piece, following gitolite, whose design this borrows.

It is a command and never a startup step. Creating settings.git is
exactly what puts the inverted default in force, so a server that seeded
one on boot would make every repository invisible the moment it upgraded
— rule 3's protection undone by the feature meant to make governance
usable.

Which is also why the seed reproduces the policy already in force. It
reads each repository's server.toml and writes the rule that means the
same thing today, including `R = @anonymous` and `option listed = yes`
where the repository is public, so enabling governance is behaviourally
a no-op. That is the difference between a bootstrap and a switch nobody
dares flip, and it is asserted end to end against a live server: capture
what several repositories of differing visibility expose, run setup,
assert the exposure is the same decision afterwards. The claim is also
checked in-process before anything is written, against the real
evaluator rather than a second copy of the intent.

Both files land in one commit, put through the same validation a push
would face — the push-time guard from 1beeb03e r2 refuses a roster with
no rules, and setup must not hand-create the state that guard exists to
prevent.

One thing governance cannot say: server.toml has two anonymous switches
([ui] anonymous and [http] anonymous_clone) and the rule language has
one grant covering both. A repository setting them differently has no
faithful translation, so setup writes the closed answer and names the
repository, in the report and in a comment in the file. A bootstrap may
narrow exposure where it must; it must never widen it by guessing.

authorized_keys is left alone, so removing settings.git puts the server
back exactly as it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

README.md
Old New
@@ -397,9 +397,29 @@ policy before serving anything you care about.
397 397
398 ### Governance 398 ### Governance
399 399
400 Create a repository called `settings` under `repos_dir` and the server takes its 400 A repository called `settings` under `repos_dir` takes over as the server's
401 rules from there instead. Pushing to it reconfigures the server; there is no 401 rules. Pushing to it reconfigures the server; there is no reload and no restart.
402 reload and no restart. 402
403 Create it with `setup`, which is deliberately a command rather than something
404 the server does on its own:
405
406 ```console
407 $ git-collab-server setup --config /etc/git-collab/server.toml \
408 --admin-key ~/.ssh/alex.pub
409 ```
410
411 The key file's basename becomes the identity — `alex.pub` is the principal
412 `alex` — and `--admin-name` overrides it. That name is enrolled in `keydir/` and
413 granted `RW+` on `settings`, so it can push the rules from then on.
414
415 The seed reproduces the policy already in force: it reads each repository's
416 `server.toml` and writes the rule that means the same thing, including
417 `R = @anonymous` and `option listed = yes` for a repository that is public
418 today. So enabling governance changes nothing about what the server exposes,
419 which is what makes it safe to run on a live server — `--dry-run` prints the
420 rules first. Setup refuses if `settings.git` already exists, and leaves
421 `authorized_keys` alone (though once governance is on, only keys in `keydir/`
422 authenticate; enrol the rest before anyone needs them).
403 423
404 ```text 424 ```text
405 settings.git 425 settings.git
src/server/governance/conf.rs
Old New
@@ -237,6 +237,16 @@ fn compile_refex(refex: Option<&str>, line: usize) -> Result<Regex, ConfError> {
237 } 237 }
238 238
239 impl AccessConf { 239 impl AccessConf {
240 /// Whether `name` would parse as a `repo` line naming exactly itself.
241 ///
242 /// A generator writing a block per existing repository needs this: a name
243 /// carrying a regex metacharacter compiles to a *wild* pattern, so the
244 /// block would silently govern repositories other than the one it was
245 /// written for. Better to leave such a repository out and say so.
246 pub fn is_literal_repo_name(name: &str) -> bool {
247 !name.starts_with('@') && !is_wild_pattern(name) && is_plain_name(name)
248 }
249
240 /// Parse an access.conf. Every failure carries a line number, because this 250 /// Parse an access.conf. Every failure carries a line number, because this
241 /// message is what a rejected push shows the person who wrote the file. 251 /// message is what a rejected push shows the person who wrote the file.
242 pub fn parse(source: &str) -> Result<AccessConf, ConfError> { 252 pub fn parse(source: &str) -> Result<AccessConf, ConfError> {
src/server/governance/keydir.rs
Old New
@@ -58,7 +58,11 @@ pub fn name_for_path(path: &str) -> Option<&str> {
58 /// Names must be usable as literal tokens in `conf/access.conf`, so they carry 58 /// Names must be usable as literal tokens in `conf/access.conf`, so they carry
59 /// the same character set as a principal there and must not look like a group 59 /// the same character set as a principal there and must not look like a group
60 /// reference or the `CREATOR` keyword. 60 /// reference or the `CREATOR` keyword.
61 fn validate_name(name: &str) -> Result<(), String> { 61 ///
62 /// Public to the crate because `setup` derives a name from a filename and has
63 /// to reject a bad one at the point the operator can still fix it, rather than
64 /// enrolling a key nobody can write a rule for.
65 pub(crate) fn validate_name(name: &str) -> Result<(), String> {
62 if name == "CREATOR" { 66 if name == "CREATOR" {
63 return Err("CREATOR is a reserved keyword and cannot name a key".to_string()); 67 return Err("CREATOR is a reserved keyword and cannot name a key".to_string());
64 } 68 }
src/server/main.rs
Old New
@@ -10,6 +10,7 @@ mod migrate;
10 mod refs; 10 mod refs;
11 mod releases; 11 mod releases;
12 mod repos; 12 mod repos;
13 mod setup;
13 mod ssh; 14 mod ssh;
14 15
15 #[derive(Parser)] 16 #[derive(Parser)]
@@ -76,6 +77,40 @@ enum Command {
76 #[arg(long)] 77 #[arg(long)]
77 json: bool, 78 json: bool,
78 }, 79 },
80
81 /// Create `settings.git` and put governance in force.
82 ///
83 /// Deliberately a command and never a startup step. Creating the settings
84 /// repository is what makes unlisted-and-unreadable the default, so a
85 /// server that seeded one on boot would hide every repository it hosts the
86 /// moment it upgraded.
87 ///
88 /// The seed reproduces the policy already in force — every repository's
89 /// current `server.toml` becomes the rule that means the same thing — so
90 /// running this changes nothing about what the server exposes. That is
91 /// what makes it safe on a live server; `--dry-run` shows the rules first.
92 Setup {
93 /// The same config the server runs with; `repos_dir` is read, and
94 /// `authorized_keys` is left alone.
95 #[arg(short, long)]
96 config: PathBuf,
97
98 /// OpenSSH public key for the first administrator, or `-` for stdin.
99 ///
100 /// The file's basename without `.pub` becomes the identity, as in
101 /// `gitolite setup`: `keydir/alex.pub` is the principal `alex`.
102 #[arg(long, value_name = "FILE|-")]
103 admin_key: String,
104
105 /// Name that identity instead of taking it from the filename.
106 /// Required when the key is read from stdin.
107 #[arg(long, value_name = "NAME")]
108 admin_name: Option<String>,
109
110 /// Print the `conf/access.conf` this would write, and write nothing.
111 #[arg(long)]
112 dry_run: bool,
113 },
79 } 114 }
80 115
81 #[tokio::main] 116 #[tokio::main]
@@ -103,6 +138,9 @@ async fn main() {
103 let (path, dry_run, json) = match &command { 138 let (path, dry_run, json) = match &command {
104 Command::Migrate { config, dry_run } => (config, *dry_run, false), 139 Command::Migrate { config, dry_run } => (config, *dry_run, false),
105 Command::Refs { config, json } => (config, false, *json), 140 Command::Refs { config, json } => (config, false, *json),
141 Command::Setup {
142 config, dry_run, ..
143 } => (config, *dry_run, false),
106 }; 144 };
107 let config = match config::ServerConfig::from_file(path) { 145 let config = match config::ServerConfig::from_file(path) {
108 Ok(c) => c, 146 Ok(c) => c,
@@ -114,6 +152,16 @@ async fn main() {
114 std::process::exit(match command { 152 std::process::exit(match command {
115 Command::Migrate { .. } => migrate::run(&config.repos_dir, dry_run), 153 Command::Migrate { .. } => migrate::run(&config.repos_dir, dry_run),
116 Command::Refs { .. } => refs::run(&config.repos_dir, json), 154 Command::Refs { .. } => refs::run(&config.repos_dir, json),
155 Command::Setup {
156 admin_key,
157 admin_name,
158 ..
159 } => setup::run(
160 &config.repos_dir,
161 &admin_key,
162 admin_name.as_deref(),
163 dry_run,
164 ),
117 }); 165 });
118 } 166 }
119 167
src/server/setup.rs
Old New
@@ -0,0 +1,606 @@
1 //! `git-collab-server setup`: create `settings.git` and put governance in
2 //! force, the way `gitolite setup` creates `gitolite-admin`.
3 //!
4 //! Governance and the exposure model both shipped with nothing able to create
5 //! the repository that turns them on, so a deployment could run the code, host
6 //! repositories, and have no `settings.git` anywhere — governance present and
7 //! entirely inert. This is the missing piece.
8 //!
9 //! # Why this is a command and never a startup step
10 //!
11 //! Creating `settings.git` is precisely what puts the inverted default in
12 //! force: under governance a repository is unlisted and unreadable unless a
13 //! rule says otherwise. A server that seeded one on boot would, at the moment
14 //! it upgraded, make every repository invisible until somebody wrote rules —
15 //! the protection undone by the feature meant to make governance usable. So
16 //! the operator runs this, once, deliberately.
17 //!
18 //! # Why the seed reproduces the policy already in force
19 //!
20 //! The same reason, from the other end. If enabling governance changed what
21 //! the server exposes, no operator would dare enable it on anything live, and
22 //! a bootstrap nobody runs is not a bootstrap. So the seed reads each
23 //! repository's `server.toml` and writes the rule that means what it means
24 //! today, including `R = @anonymous` and `option listed = yes` where the
25 //! repository is public. Enabling governance is then behaviourally a no-op,
26 //! and `tests/server_setup_test.rs` asserts exactly that against a live
27 //! server.
28 //!
29 //! The claim is also checked here, at run time, rather than only in the test
30 //! suite: the generated rules are parsed and every repository's decision is
31 //! compared against the decision `server.toml` gives today. A mismatch is a
32 //! bug in this file, and it aborts rather than governing a server with rules
33 //! that mean something other than what it was told they mean.
34 //!
35 //! # The one thing governance cannot say
36 //!
37 //! `server.toml` has two anonymous switches — `[ui] anonymous` and
38 //! `[http] anonymous_clone` — and governance has one grant, `R = @anonymous`,
39 //! covering both. A repository that has them set differently therefore has no
40 //! faithful translation. Setup writes the closed answer and says so, loudly
41 //! and by name, in the report and in a comment in the file: a bootstrap may
42 //! narrow exposure where it must, and must never widen it by guessing.
43
44 use std::path::{Path, PathBuf};
45
46 use russh::keys::PublicKey;
47
48 use crate::governance::{self, conf::AccessConf, keydir, SETTINGS_REPO};
49 use crate::repos::{self, RepoPolicy};
50
51 /// The branch the seed lands on. `governance::load` follows HEAD, so this is
52 /// a convention rather than a requirement — but a convention the operator
53 /// clones, so it should be the modern one.
54 const SEED_BRANCH: &str = "main";
55
56 /// Where the identity in `keydir/` came from. Reported, because the basename
57 /// *is* the principal: a wrong one locks the operator out of the repository
58 /// setup just created, and the failure would not show up until the first push.
59 enum NameSource {
60 Explicit,
61 Filename,
62 }
63
64 /// Run the command. Returns the process exit code.
65 pub fn run(repos_dir: &Path, admin_key: &str, admin_name: Option<&str>, dry_run: bool) -> i32 {
66 match try_run(repos_dir, admin_key, admin_name, dry_run) {
67 Ok(()) => 0,
68 Err(reason) => {
69 eprintln!("error: {reason}");
70 1
71 }
72 }
73 }
74
75 fn try_run(
76 repos_dir: &Path,
77 admin_key: &str,
78 admin_name: Option<&str>,
79 dry_run: bool,
80 ) -> Result<(), String> {
81 if !repos_dir.is_dir() {
82 return Err(format!(
83 "repos_dir {} does not exist; nothing to govern",
84 repos_dir.display()
85 ));
86 }
87
88 let settings_path = repos_dir.join(format!("{SETTINGS_REPO}.git"));
89 if settings_path.exists() {
90 return Err(format!(
91 "{} already exists; this server is already set up.\n \
92 Setup will not overwrite rules an operator wrote. Clone it and edit it, \
93 or move it aside if you meant to start again.",
94 settings_path.display()
95 ));
96 }
97
98 let (key_text, key, name, name_source) = read_admin_key(admin_key, admin_name)?;
99 let admin_principal = crate::ssh::session::ssh_key_principal(&key);
100
101 let entries = repos::discover(repos_dir)
102 .map_err(|e| format!("cannot read {}: {e}", repos_dir.display()))?;
103
104 // A directory literally named `settings` is the same name as the
105 // governance repository, and would collide with the block written for it.
106 if let Some(entry) = entries.iter().find(|entry| entry.name == SETTINGS_REPO) {
107 return Err(format!(
108 "{} is already a repository named {SETTINGS_REPO}, which is the name governance \
109 reserves for itself. Rename it before setting up.",
110 entry.path.display()
111 ));
112 }
113
114 let plan = Plan::build(repos_dir, &entries, &name, &admin_principal);
115 let conf_text = plan.render();
116
117 // Machine-check the safety claim before anything is written: the rules
118 // just generated must give every repository the decision it already has.
119 verify(&conf_text, &plan)?;
120
121 if dry_run {
122 print!("{conf_text}");
123 println!();
124 report(&plan, &name, &name_source, &settings_path, true);
125 return Ok(());
126 }
127
128 seed(&settings_path, &conf_text, &name, &key_text)?;
129 report(&plan, &name, &name_source, &settings_path, false);
130 Ok(())
131 }
132
133 // ---------------------------------------------------------------------------
134 // The admin key
135 // ---------------------------------------------------------------------------
136
137 /// Read the admin key from a file or stdin, and work out the identity it
138 /// belongs to.
139 ///
140 /// The name is the `keydir/` basename, which follows `gitolite setup -pk`:
141 /// the file you hand it names the principal. `-` has no filename to read, so
142 /// it needs `--admin-name` and says so rather than inventing something from
143 /// the key comment — a comment is free text an operator never chose as an
144 /// identity, and it is the kind of guess that is wrong exactly once.
145 fn read_admin_key(
146 admin_key: &str,
147 admin_name: Option<&str>,
148 ) -> Result<(String, PublicKey, String, NameSource), String> {
149 let (text, from_filename) = if admin_key == "-" {
150 let mut buffer = String::new();
151 std::io::Read::read_to_string(&mut std::io::stdin(), &mut buffer)
152 .map_err(|e| format!("cannot read the admin key from stdin: {e}"))?;
153 (buffer, None)
154 } else {
155 let path = PathBuf::from(admin_key);
156 let text = std::fs::read_to_string(&path)
157 .map_err(|e| format!("cannot read the admin key from {}: {e}", path.display()))?;
158 let stem = path
159 .file_name()
160 .and_then(|n| n.to_str())
161 .map(|n| n.strip_suffix(".pub").unwrap_or(n).to_string());
162 (text, stem)
163 };
164
165 let text = text.trim().to_string();
166 if text.is_empty() {
167 return Err(format!(
168 "the admin key is empty; expected one OpenSSH public key \
169 (`ssh-ed25519 AAAA… you@host`) from {}",
170 if admin_key == "-" { "stdin" } else { admin_key }
171 ));
172 }
173
174 let key = PublicKey::from_openssh(&text).map_err(|e| {
175 format!(
176 "the admin key is not a well-formed OpenSSH public key: {e}\n \
177 This is the contents of a `.pub` file, not a private key."
178 )
179 })?;
180
181 let (name, source) = match (admin_name, from_filename) {
182 (Some(name), _) => (name.to_string(), NameSource::Explicit),
183 (None, Some(stem)) => (stem, NameSource::Filename),
184 (None, None) => {
185 return Err(
186 "a key read from stdin has no filename to take an identity from; \
187 pass --admin-name <name> to say who it belongs to"
188 .to_string(),
189 )
190 }
191 };
192
193 // The basename is written straight into `conf/access.conf` as a
194 // principal, so it has to be a name that file can hold.
195 keydir::validate_name(&name).map_err(|reason| {
196 format!(
197 "{name:?} cannot name a principal: {reason}\n \
198 The identity is the key file's basename without `.pub`. \
199 Pass --admin-name <name> to choose it directly."
200 )
201 })?;
202
203 Ok((text, key, name, source))
204 }
205
206 // ---------------------------------------------------------------------------
207 // Translating the policy already in force
208 // ---------------------------------------------------------------------------
209
210 /// What one repository's `server.toml` says, and the rules that mean the same.
211 struct RepoPlan {
212 /// The key rules are matched against: the path under `repos_dir` with one
213 /// `.git` removed. The same string `repo_key` gives the request path.
214 key: String,
215 /// Rules, already rendered as `(lhs, rhs)` pairs.
216 rules: Vec<(String, String)>,
217 /// `option listed = yes`.
218 listed: bool,
219 /// What an anonymous request may do today, and so must still do.
220 anonymous_read: bool,
221 /// Notes to write into the file above the block, and to report.
222 notes: Vec<String>,
223 }
224
225 struct Plan {
226 admin: String,
227 repos: Vec<RepoPlan>,
228 /// Repositories no rule could be written for at all, and why.
229 skipped: Vec<String>,
230 /// Every place the seed could not say what `server.toml` says, and so
231 /// said less. Empty is the good answer, and the usual one; anything here
232 /// is a decision the operator now has to make deliberately.
233 narrowed: Vec<String>,
234 }
235
236 impl Plan {
237 fn build(
238 repos_dir: &Path,
239 entries: &[repos::RepoEntry],
240 admin: &str,
241 admin_principal: &str,
242 ) -> Plan {
243 let mut plan = Plan {
244 admin: admin.to_string(),
245 repos: Vec::new(),
246 skipped: Vec::new(),
247 narrowed: Vec::new(),
248 };
249
250 for entry in entries {
251 let Some(key) = governance::repo_key(repos_dir, &entry.path) else {
252 plan.skipped.push(format!(
253 "{}: not a name that can appear in the rules",
254 entry.path.display()
255 ));
256 continue;
257 };
258 // A name carrying regex metacharacters would compile to a wild
259 // pattern and quietly govern repositories other than this one.
260 if !AccessConf::is_literal_repo_name(&key) {
261 // Left out, so governance closes it. Say plainly whether that
262 // is a change anyone will notice: this is the one place the
263 // seed cannot keep its no-op promise, and burying it in a
264 // sentence about rule syntax would be the wrong emphasis.
265 let visible = if entry.policy.allows_anonymous_ui() {
266 "It is public today, so it will disappear from the repository list \
267 until you do."
268 } else {
269 "It is not on the anonymous surface today, so nothing visible changes."
270 };
271 plan.skipped.push(format!(
272 "{key}: this name cannot be written as a literal rule (it would compile \
273 to a pattern matching other repositories), so no block was generated \
274 for it and governance closes it to everyone.\n{visible}"
275 ));
276 continue;
277 }
278 plan.repos
279 .push(RepoPlan::build(&key, &entry.policy, admin, admin_principal));
280 }
281
282 for repo in &plan.repos {
283 for note in &repo.notes {
284 plan.narrowed.push(format!("{}: {note}", repo.key));
285 }
286 }
287 plan
288 }
289
290 /// The whole `conf/access.conf`.
291 fn render(&self) -> String {
292 let mut out = String::new();
293 out.push_str(
294 "# conf/access.conf — the rules that govern this server.\n\
295 #\n\
296 # Generated by `git-collab-server setup` from the policy that was already in\n\
297 # force: every block below reproduces what the repository's server.toml grants\n\
298 # today, so turning governance on changed nothing. From here on this file is\n\
299 # the authority on access, and server.toml's [access], visibility, [ui] and\n\
300 # [http] keys are no longer consulted.\n\
301 #\n\
302 # Push to this repository to change it. The push is validated first, and one\n\
303 # that would lock everyone out is refused.\n\n",
304 );
305
306 out.push_str(&format!("repo {SETTINGS_REPO}\n"));
307 out.push_str(
308 " # Whoever holds this may rewrite the rules, so it is deliberately one\n\
309 \x20 # name rather than a group. No anonymous grant: the key roster lives\n\
310 \x20 # here, and governance is unlisted and unreadable until a rule says\n\
311 \x20 # otherwise.\n",
312 );
313 out.push_str(&rule("RW+", &self.admin));
314
315 for repo in &self.repos {
316 out.push('\n');
317 out.push_str(&format!("repo {}\n", repo.key));
318 for note in &repo.notes {
319 for line in note.lines() {
320 out.push_str(&format!(" # {line}\n"));
321 }
322 }
323 for (lhs, rhs) in &repo.rules {
324 out.push_str(&rule(lhs, rhs));
325 }
326 if repo.listed {
327 out.push_str(&rule("option listed", "yes"));
328 }
329 }
330 out
331 }
332 }
333
334 /// One rule line, in the column layout the design's examples use.
335 fn rule(lhs: &str, rhs: &str) -> String {
336 format!(" {lhs:<21} = {rhs}\n")
337 }
338
339 impl RepoPlan {
340 fn build(key: &str, policy: &RepoPolicy, admin: &str, admin_principal: &str) -> RepoPlan {
341 let mut rules = Vec::new();
342 let mut notes = Vec::new();
343
344 // --- The authenticated axis -------------------------------------
345 //
346 // `*` in server.toml means "any principal that authenticated at all",
347 // and `@all` means "every key enrolled in keydir/", which is the same
348 // sentence in the new language. A force-push was never separately
349 // gated before governance, so a writer maps to RW+.
350 let read_all = policy.access.read.iter().any(|entry| entry == "*");
351 let write_all = policy.access.write.iter().any(|entry| entry == "*");
352
353 // An explicit roster names fingerprints, and rules name keydir
354 // identities. Only the one key this command enrols can be translated;
355 // the rest are recorded so the operator can enrol and grant them.
356 let mut untranslatable: Vec<&str> = policy
357 .access
358 .read
359 .iter()
360 .chain(policy.access.write.iter())
361 .map(String::as_str)
362 .filter(|entry| *entry != "*" && *entry != admin_principal)
363 .collect();
364 untranslatable.sort();
365 untranslatable.dedup();
366
367 // A fingerprint cannot appear in a rule, so the one roster entry that
368 // can be translated is the admin's own key, under the admin's name.
369 if !write_all && policy.allows_write(admin_principal) {
370 rules.push(("RW+".to_string(), admin.to_string()));
371 } else if !read_all && policy.allows_read(admin_principal) {
372 rules.push(("R".to_string(), admin.to_string()));
373 }
374 if write_all {
375 rules.push(("RW+".to_string(), "@all".to_string()));
376 } else if read_all {
377 rules.push(("R".to_string(), "@all".to_string()));
378 }
379
380 if !untranslatable.is_empty() {
381 notes.push(format!(
382 "server.toml granted access to {} by key fingerprint, and rules name\n\
383 keydir identities instead. Enrol each key as keydir/<name>.pub and add\n\
384 the grant here; until then those keys cannot reach this repository:\n {}",
385 if untranslatable.len() == 1 {
386 "a key".to_string()
387 } else {
388 format!("{} keys", untranslatable.len())
389 },
390 untranslatable.join("\n ")
391 ));
392 }
393
394 // --- The anonymous axis -----------------------------------------
395 //
396 // Two switches upstream, one grant here. Where they agree the
397 // translation is exact; where they disagree there is no rule that
398 // means what server.toml means, so the closed answer is written and
399 // named. Narrowing is a thing a bootstrap may do; widening is not.
400 let ui = policy.allows_anonymous_ui();
401 let http = policy.allows_anonymous_http();
402 let anonymous_read = ui && http;
403 if ui != http {
404 notes.push(format!(
405 "server.toml sets [ui] anonymous = {ui} but [http] anonymous_clone = {http},\n\
406 and governance has one anonymous read grant covering both. No rule can\n\
407 mean that, so none was written and this repository is now closed to\n\
408 anonymous requests. Add `R = @anonymous` (and `option listed = yes`) to\n\
409 publish it deliberately."
410 ));
411 }
412 if anonymous_read {
413 rules.push(("R".to_string(), "@anonymous".to_string()));
414 }
415
416 RepoPlan {
417 key: key.to_string(),
418 rules,
419 listed: anonymous_read,
420 anonymous_read,
421 notes,
422 }
423 }
424 }
425
426 // ---------------------------------------------------------------------------
427 // The self-check
428 // ---------------------------------------------------------------------------
429
430 /// Parse what was generated and confirm it decides every repository the way
431 /// the plan says it should.
432 ///
433 /// This is the safety claim, checked against the real evaluator rather than
434 /// against a second copy of the intent. If it ever fails, this file has a bug
435 /// and the right thing to do is refuse to govern the server with it.
436 fn verify(conf_text: &str, plan: &Plan) -> Result<(), String> {
437 let conf = AccessConf::parse(conf_text).map_err(|e| {
438 format!(
439 "the generated rules do not parse ({e}); refusing to write them. \
440 This is a bug in `setup`."
441 )
442 })?;
443
444 for repo in &plan.repos {
445 let anonymous_read = conf.anonymous_may_read(&repo.key);
446 let listed = conf.is_listed(&repo.key);
447 if anonymous_read != repo.anonymous_read || listed != repo.listed {
448 return Err(format!(
449 "the generated rules would expose {} differently from its server.toml \
450 (anonymous read {} vs {}, listed {} vs {}); refusing to write them. \
451 This is a bug in `setup`.",
452 repo.key, anonymous_read, repo.anonymous_read, listed, repo.listed
453 ));
454 }
455 }
456
457 // The settings repository must stay off the anonymous surface: the whole
458 // key roster is in it.
459 if conf.anonymous_may_read(SETTINGS_REPO) || conf.is_listed(SETTINGS_REPO) {
460 return Err(format!(
461 "the generated rules would publish {SETTINGS_REPO}, which holds the key \
462 roster; refusing to write them. This is a bug in `setup`."
463 ));
464 }
465 Ok(())
466 }
467
468 // ---------------------------------------------------------------------------
469 // Writing it
470 // ---------------------------------------------------------------------------
471
472 /// Create `settings.git` and land both files in a single commit.
473 ///
474 /// One commit, not two: the push-time guard refuses a roster with no rules,
475 /// because that state governs nothing while publishing who has access to the
476 /// server. Setup must not create by hand the very thing that guard exists to
477 /// prevent — so the tree is assembled whole, put through the same validation a
478 /// push would face, and only then committed.
479 fn seed(
480 settings_path: &Path,
481 conf_text: &str,
482 admin_name: &str,
483 key_text: &str,
484 ) -> Result<(), String> {
485 let mut options = git2::RepositoryInitOptions::new();
486 options.bare(true).initial_head(SEED_BRANCH);
487 let repo = git2::Repository::init_opts(settings_path, &options)
488 .map_err(|e| format!("cannot create {}: {e}", settings_path.display()))?;
489
490 // From here on a failure leaves a half-made repository, which would make
491 // the server think it is governed by nothing. Clean it up on every exit.
492 match build_and_commit(&repo, conf_text, admin_name, key_text) {
493 Ok(()) => Ok(()),
494 Err(reason) => {
495 drop(repo);
496 let _ = std::fs::remove_dir_all(settings_path);
497 Err(reason)
498 }
499 }
500 }
501
502 fn build_and_commit(
503 repo: &git2::Repository,
504 conf_text: &str,
505 admin_name: &str,
506 key_text: &str,
507 ) -> Result<(), String> {
508 let git = |e: git2::Error| format!("building the seed commit: {e}");
509
510 let conf_blob = repo.blob(conf_text.as_bytes()).map_err(git)?;
511 let key_blob = repo
512 .blob(format!("{}\n", key_text.trim()).as_bytes())
513 .map_err(git)?;
514
515 let mut conf_dir = repo.treebuilder(None).map_err(git)?;
516 conf_dir
517 .insert("access.conf", conf_blob, 0o100644)
518 .map_err(git)?;
519 let conf_tree = conf_dir.write().map_err(git)?;
520
521 let mut keydir_tree = repo.treebuilder(None).map_err(git)?;
522 keydir_tree
523 .insert(format!("{admin_name}.pub"), key_blob, 0o100644)
524 .map_err(git)?;
525 let keydir_tree = keydir_tree.write().map_err(git)?;
526
527 let mut root = repo.treebuilder(None).map_err(git)?;
528 root.insert("conf", conf_tree, 0o040000).map_err(git)?;
529 root.insert("keydir", keydir_tree, 0o040000).map_err(git)?;
530 let tree = repo.find_tree(root.write().map_err(git)?).map_err(git)?;
531
532 // Exactly the checks a push to this repository would face: the rules
533 // parse, somebody is enrolled, and somebody still holds RW+ on settings.
534 // Passing them is what makes the seed a configuration the server would
535 // have accepted from an operator.
536 let landing_ref = format!("refs/heads/{SEED_BRANCH}");
537 governance::check_roster_has_rules(&tree)?;
538 governance::validate_settings_tree(repo, &tree, &landing_ref)?;
539
540 let who = git2::Signature::now("git-collab-server setup", "setup@git-collab").map_err(git)?;
541 repo.commit(
542 Some(&landing_ref),
543 &who,
544 &who,
545 "Seed governance from the policy already in force\n\n\
546 Generated by `git-collab-server setup`. Every block reproduces what the\n\
547 repository's server.toml granted at this moment, so enabling governance\n\
548 changed nothing about what this server exposes.\n",
549 &tree,
550 &[],
551 )
552 .map_err(git)?;
553 Ok(())
554 }
555
556 // ---------------------------------------------------------------------------
557 // The report
558 // ---------------------------------------------------------------------------
559
560 fn report(plan: &Plan, name: &str, source: &NameSource, settings_path: &Path, dry_run: bool) {
561 let derived = match source {
562 NameSource::Explicit => "from --admin-name",
563 NameSource::Filename => "from the key file's name",
564 };
565 if dry_run {
566 println!("Dry run: nothing was written.");
567 println!("Would create {}", settings_path.display());
568 } else {
569 println!("Created {}", settings_path.display());
570 }
571 println!("Admin identity: {name} ({derived}, enrolled as keydir/{name}.pub)");
572 println!(
573 " Rules are written against this name, and it holds RW+ on `{SETTINGS_REPO}`. \
574 If it is wrong, nobody can push the rules."
575 );
576 println!(
577 "Reproduced the policy of {} {}.",
578 plan.repos.len(),
579 if plan.repos.len() == 1 {
580 "repository"
581 } else {
582 "repositories"
583 }
584 );
585
586 for note in plan.narrowed.iter().chain(plan.skipped.iter()) {
587 let mut lines = note.lines();
588 if let Some(first) = lines.next() {
589 eprintln!("warning: {first}");
590 }
591 for line in lines {
592 eprintln!(" {line}");
593 }
594 }
595
596 if !dry_run {
597 println!();
598 println!("Governance is now in force. `authorized_keys` was not touched, but it is");
599 println!("no longer consulted: only keys in keydir/ authenticate. Enrol the rest");
600 println!(
601 "by cloning {}, adding keydir/<name>.pub",
602 settings_path.display()
603 );
604 println!("and a grant in conf/access.conf, and pushing both together.");
605 }
606 }
tests/common/mod.rs
Old New
@@ -1118,6 +1118,20 @@ impl ServerHarness {
1118 self.root.path().join("repos") 1118 self.root.path().join("repos")
1119 } 1119 }
1120 1120
1121 /// The config file the running server was started with, for subcommands
1122 /// an operator runs against the same server (`migrate`, `setup`).
1123 pub fn config_path(&self) -> PathBuf {
1124 self.root.path().join("server.toml")
1125 }
1126
1127 /// A scratch directory under the harness root, for a test that needs
1128 /// somewhere to clone to or a file to feed a command.
1129 pub fn scratch(&self, name: &str) -> PathBuf {
1130 let path = self.root.path().join(name);
1131 std::fs::create_dir_all(&path).unwrap();
1132 path
1133 }
1134
1121 /// Generate a client SSH keypair (once) and authorize it. Returns the key path. 1135 /// Generate a client SSH keypair (once) and authorize it. Returns the key path.
1122 /// 1136 ///
1123 /// Not thread-safe: writes shared state under the harness root 1137 /// Not thread-safe: writes shared state under the harness root
tests/server_setup_test.rs
Old New
@@ -0,0 +1,619 @@
1 //! `git-collab-server setup`: the bootstrap that creates `settings.git`.
2 //!
3 //! Governance and the exposure model both shipped with no way to create the
4 //! repository that turns them on, so governance was present and inert. This
5 //! is the missing half, and the whole difficulty is in one property:
6 //!
7 //! **Creating `settings.git` is what puts the inverted default in force**, so
8 //! a seed that did not reproduce the server's current effective policy would
9 //! make every repository invisible the moment it ran. The central test here
10 //! therefore captures what a live server exposes, runs `setup` against it,
11 //! and asserts the exposure is byte-for-byte the same decision afterwards.
12 //! Everything else in this file exists to protect that claim.
13
14 mod common;
15
16 use common::ServerHarness;
17 use std::path::Path;
18 use std::process::{Command, Output};
19
20 // ---------------------------------------------------------------------------
21 // Fixtures
22 // ---------------------------------------------------------------------------
23
24 fn git_in(dir: &Path, args: &[&str]) -> String {
25 let output = Command::new("git")
26 .args(args)
27 .current_dir(dir)
28 .output()
29 .expect("failed to run git");
30 assert!(
31 output.status.success(),
32 "git {:?} in {:?} failed: {}",
33 args,
34 dir,
35 String::from_utf8_lossy(&output.stderr)
36 );
37 String::from_utf8(output.stdout).unwrap()
38 }
39
40 fn stdout(output: &Output) -> String {
41 String::from_utf8_lossy(&output.stdout).to_string()
42 }
43
44 fn stderr(output: &Output) -> String {
45 String::from_utf8_lossy(&output.stderr).to_string()
46 }
47
48 /// Run `git-collab-server setup` with the given extra arguments.
49 fn setup(config: &Path, args: &[&str]) -> Output {
50 let mut command = Command::new(env!("CARGO_BIN_EXE_git-collab-server"));
51 command.args(["setup", "--config", config.to_str().unwrap()]);
52 command.args(args);
53 command.output().expect("failed to run git-collab-server")
54 }
55
56 fn assert_ok(output: &Output, context: &str) {
57 assert!(
58 output.status.success(),
59 "{context}: expected success, got {:?}\nstdout: {}\nstderr: {}",
60 output.status.code(),
61 stdout(output),
62 stderr(output)
63 );
64 }
65
66 fn assert_failed(output: &Output, context: &str) {
67 assert!(
68 !output.status.success(),
69 "{context}: expected a refusal, but it succeeded\nstdout: {}",
70 stdout(output)
71 );
72 }
73
74 /// Create a bare repository under the server's repos dir, with the given
75 /// `server.toml` body (empty for none at all).
76 fn make_repo(repos_dir: &Path, name: &str, server_toml: &str) {
77 let path = repos_dir.join(format!("{name}.git"));
78 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
79 git_in(repos_dir, &["init", "-q", "--bare", path.to_str().unwrap()]);
80 if !server_toml.is_empty() {
81 let collab = path.join(".collab");
82 std::fs::create_dir_all(&collab).unwrap();
83 std::fs::write(collab.join("server.toml"), server_toml).unwrap();
84 }
85 }
86
87 /// What an unauthenticated request may see of a repository, asked of the real
88 /// server rather than of the code that decides it.
89 #[derive(Debug, PartialEq, Eq)]
90 struct Exposed {
91 listed: bool,
92 readable: bool,
93 clonable: bool,
94 }
95
96 fn exposure_of(harness: &ServerHarness, repo: &str) -> Exposed {
97 Exposed {
98 listed: harness.get_ok("/").body.contains(repo),
99 readable: harness.get(&format!("/{repo}")).status_line.contains("200"),
100 clonable: harness
101 .get(&format!("/{repo}.git/info/refs?service=git-upload-pack"))
102 .status_line
103 .contains("200"),
104 }
105 }
106
107 /// The repositories the exposure test stands up, and the policy each carries.
108 ///
109 /// Deliberately mixed, and deliberately reaching the same closed state by two
110 /// different routes (`visibility` and the two `anonymous` switches), because
111 /// the seed reads the *effective* policy rather than any one key.
112 const REPOS: &[(&str, &str)] = &[
113 // No server.toml at all: the default, and public.
114 ("alpha", ""),
115 ("bravo-private", "visibility = \"private\"\n"),
116 (
117 "charlie-quiet",
118 "visibility = \"public\"\n[ui]\nanonymous = false\n[http]\nanonymous_clone = false\n",
119 ),
120 // Public to anonymous readers, but its authenticated access is an
121 // explicit roster of fingerprints rather than the default `*`.
122 (
123 "delta-restricted",
124 "visibility = \"public\"\n[access]\nread = [\"key:SHA256:somebody\"]\nwrite = [\"key:SHA256:somebody\"]\n",
125 ),
126 // Nested, because a repo key keeps its `/` and the rule has to match it.
127 ("nested/echo", ""),
128 ];
129
130 fn names() -> Vec<&'static str> {
131 let mut names: Vec<&'static str> = REPOS.iter().map(|(name, _)| *name).collect();
132 names.push("hosted");
133 names
134 }
135
136 /// Stand up a server with the mixed repository set above.
137 fn mixed_server() -> ServerHarness {
138 let harness = ServerHarness::new("hosted");
139 let repos_dir = harness.repos_dir();
140 for (name, server_toml) in REPOS {
141 make_repo(&repos_dir, name, server_toml);
142 }
143 harness
144 }
145
146 /// An ed25519 public key file for the admin, under the harness root.
147 fn admin_key(harness: &ServerHarness, name: &str) -> std::path::PathBuf {
148 harness.named_key(name).with_extension("pub")
149 }
150
151 // ---------------------------------------------------------------------------
152 // The test that matters
153 // ---------------------------------------------------------------------------
154
155 /// Enabling governance is behaviourally a no-op.
156 ///
157 /// This is the entire safety claim, and the reason `setup` may be run on a
158 /// live server at all: the seed reproduces the effective policy, so the same
159 /// repositories are listed, the same ones are readable and clonable by an
160 /// anonymous request, and the same ones are refused — before and after.
161 #[test]
162 fn setup_leaves_the_anonymous_exposure_of_every_repository_unchanged() {
163 let harness = mixed_server();
164 harness.push_head();
165
166 let before: Vec<(&str, Exposed)> = names()
167 .into_iter()
168 .map(|name| (name, exposure_of(&harness, name)))
169 .collect();
170
171 // Sanity: the fixture is only worth anything if it is actually mixed.
172 assert!(
173 before.iter().any(|(_, e)| e.readable) && before.iter().any(|(_, e)| !e.readable),
174 "the fixture must contain both exposed and hidden repositories, got {before:?}"
175 );
176
177 let output = setup(
178 &harness.config_path(),
179 &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()],
180 );
181 assert_ok(&output, "setup on a mixed server");
182
183 // Governance really is in force now, or the assertion below is vacuous:
184 // on an ungoverned server `settings` would be listed like anything else.
185 assert!(
186 harness.repos_dir().join("settings.git").exists(),
187 "setup must create settings.git"
188 );
189 assert_eq!(
190 exposure_of(&harness, "settings"),
191 Exposed {
192 listed: false,
193 readable: false,
194 clonable: false
195 },
196 "the governance repository must not be on the anonymous surface: \
197 it holds the key roster"
198 );
199
200 let after: Vec<(&str, Exposed)> = names()
201 .into_iter()
202 .map(|name| (name, exposure_of(&harness, name)))
203 .collect();
204
205 assert_eq!(
206 before, after,
207 "enabling governance changed what the server exposes"
208 );
209 }
210
211 /// Both files land in one commit.
212 ///
213 /// A roster pushed without rules is refused by the push-time guard, and for
214 /// good reason — it governs nothing while publishing who has access. Setup
215 /// must not hand-create by another route the state that guard exists to
216 /// prevent.
217 #[test]
218 fn the_seed_is_a_single_commit_holding_both_the_rules_and_the_roster() {
219 let harness = mixed_server();
220 let output = setup(
221 &harness.config_path(),
222 &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()],
223 );
224 assert_ok(&output, "setup");
225
226 let settings = harness.repos_dir().join("settings.git");
227 let count = git_in(&settings, &["rev-list", "--count", "HEAD"]);
228 assert_eq!(count.trim(), "1", "the seed must be exactly one commit");
229
230 let files = git_in(&settings, &["show", "--name-only", "--format=", "HEAD"]);
231 let mut paths: Vec<&str> = files.lines().filter(|l| !l.is_empty()).collect();
232 paths.sort();
233 assert_eq!(
234 paths,
235 vec!["conf/access.conf", "keydir/alex.pub"],
236 "one commit must hold both halves; got {files}"
237 );
238 }
239
240 /// The identity is the keydir basename, and getting it wrong locks the
241 /// operator out of the repository setup just created. So: it is reported, and
242 /// it actually works — the admin can push to `settings` over SSH afterwards,
243 /// authenticating through `keydir/` rather than `authorized_keys`.
244 #[test]
245 fn the_admin_it_names_can_push_the_settings_repository_afterwards() {
246 let harness = mixed_server();
247 let key = harness.named_key("alex");
248 let output = setup(
249 &harness.config_path(),
250 &["--admin-key", key.with_extension("pub").to_str().unwrap()],
251 );
252 assert_ok(&output, "setup");
253 assert!(
254 stdout(&output).contains("alex"),
255 "setup must report the identity it derived; got {}",
256 stdout(&output)
257 );
258
259 // Clone over the filesystem, change the rules, push over SSH as the admin.
260 let work = harness.scratch("admin-work");
261 git_in(
262 &work,
263 &[
264 "clone",
265 "-q",
266 harness.repos_dir().join("settings.git").to_str().unwrap(),
267 ".",
268 ],
269 );
270 git_in(&work, &["config", "user.email", "alex@example.com"]);
271 git_in(&work, &["config", "user.name", "Alex"]);
272 let conf = work.join("conf").join("access.conf");
273 let text = std::fs::read_to_string(&conf).unwrap();
274 std::fs::write(&conf, format!("{text}\n# an edit by the admin\n")).unwrap();
275 git_in(&work, &["add", "-A"]);
276 git_in(&work, &["commit", "-q", "-m", "edit"]);
277
278 let push = harness.ssh_push_from(&work, &key, "settings", "HEAD:refs/heads/main");
279 assert!(
280 push.status.success(),
281 "the admin setup enrolled must be able to push settings; got {}",
282 stderr(&push)
283 );
284 }
285
286 // ---------------------------------------------------------------------------
287 // Refusals and the dry run
288 // ---------------------------------------------------------------------------
289
290 /// An operator's rules are not something to overwrite.
291 #[test]
292 fn setup_refuses_when_a_settings_repository_already_exists() {
293 let harness = mixed_server();
294 let key = admin_key(&harness, "alex");
295 assert_ok(
296 &setup(
297 &harness.config_path(),
298 &["--admin-key", key.to_str().unwrap()],
299 ),
300 "the first setup",
301 );
302
303 let settings = harness.repos_dir().join("settings.git");
304 let before = git_in(&settings, &["rev-parse", "HEAD"]);
305
306 let second = setup(
307 &harness.config_path(),
308 &["--admin-key", key.to_str().unwrap()],
309 );
310 assert_failed(&second, "a second setup");
311 assert!(
312 stderr(&second).contains("settings.git"),
313 "the refusal must name what is in the way; got {}",
314 stderr(&second)
315 );
316 assert_eq!(
317 before,
318 git_in(&settings, &["rev-parse", "HEAD"]),
319 "a refused setup must not touch the existing rules"
320 );
321 }
322
323 /// An operator should be able to read the policy before it governs their
324 /// server.
325 #[test]
326 fn dry_run_prints_the_access_conf_and_changes_nothing() {
327 let harness = mixed_server();
328 let output = setup(
329 &harness.config_path(),
330 &[
331 "--admin-key",
332 admin_key(&harness, "alex").to_str().unwrap(),
333 "--dry-run",
334 ],
335 );
336 assert_ok(&output, "a dry run");
337
338 let printed = stdout(&output);
339 assert!(
340 printed.contains("repo settings") && printed.contains("RW+"),
341 "the dry run must print the rules that would govern; got {printed}"
342 );
343 for (name, _) in REPOS {
344 assert!(
345 printed.contains(&format!("repo {name}")),
346 "the dry run must show a block for {name}; got {printed}"
347 );
348 }
349 assert!(
350 !harness.repos_dir().join("settings.git").exists(),
351 "a dry run must not create anything"
352 );
353
354 // And the server is still ungoverned: everything it listed, it still
355 // lists.
356 assert!(exposure_of(&harness, "alpha").listed);
357 }
358
359 /// The generated rules reproduce the anonymous axis per repository, in the
360 /// spelling the design uses. Checked as text as well as through the server:
361 /// the operator reads this file, so its contents are part of the contract.
362 #[test]
363 fn the_generated_rules_publish_exactly_the_repositories_that_are_public_today() {
364 let harness = mixed_server();
365 let output = setup(
366 &harness.config_path(),
367 &[
368 "--admin-key",
369 admin_key(&harness, "alex").to_str().unwrap(),
370 "--dry-run",
371 ],
372 );
373 assert_ok(&output, "a dry run");
374 let printed = stdout(&output);
375
376 /// The lines of the `repo <name>` block, up to the next `repo` line.
377 fn block_of<'a>(conf: &'a str, name: &str) -> Vec<&'a str> {
378 conf.lines()
379 .skip_while(|line| line.trim() != format!("repo {name}"))
380 .skip(1)
381 .take_while(|line| !line.trim_start().starts_with("repo "))
382 .map(|line| line.trim())
383 .filter(|line| !line.is_empty() && !line.starts_with('#'))
384 .collect()
385 }
386
387 // Public today: reachable by name, and advertised.
388 let alpha = block_of(&printed, "alpha").join("\n");
389 assert!(
390 alpha.contains("R = @anonymous")
391 || alpha.contains("= @anonymous")
392 || alpha.contains("= @anonymous"),
393 "a public repository must keep its anonymous read grant; got {alpha}"
394 );
395 assert!(
396 alpha.contains("listed") && alpha.contains("yes"),
397 "and stay listed; got {alpha}"
398 );
399
400 // Private today: no anonymous grant at all, in either spelling.
401 let private = block_of(&printed, "bravo-private").join("\n");
402 assert!(
403 !private.contains("@anonymous"),
404 "a private repository must not gain an anonymous grant; got {private}"
405 );
406 assert!(!private.contains("listed"), "nor be listed; got {private}");
407
408 // Public but with both anonymous switches off: same closed result, by a
409 // different route through server.toml.
410 let quiet = block_of(&printed, "charlie-quiet").join("\n");
411 assert!(
412 !quiet.contains("@anonymous") && !quiet.contains("listed"),
413 "an unlisted repository must stay unlisted; got {quiet}"
414 );
415
416 // The settings repository grants the admin RW+ and nothing anonymous.
417 let settings = block_of(&printed, "settings").join("\n");
418 assert!(
419 settings.contains("RW+") && settings.contains("alex"),
420 "the admin must hold RW+ on settings; got {settings}"
421 );
422 assert!(
423 !settings.contains("@anonymous"),
424 "the key roster must not be published; got {settings}"
425 );
426 }
427
428 // ---------------------------------------------------------------------------
429 // The admin key
430 // ---------------------------------------------------------------------------
431
432 #[test]
433 fn a_malformed_admin_key_is_refused_and_nothing_is_created() {
434 let harness = mixed_server();
435 let bad = harness.scratch("keys").join("broken.pub");
436 std::fs::write(&bad, "ssh-ed25519 this-is-not-base64 alex@laptop\n").unwrap();
437
438 let output = setup(
439 &harness.config_path(),
440 &["--admin-key", bad.to_str().unwrap()],
441 );
442 assert_failed(&output, "a malformed key");
443 assert!(
444 stderr(&output).to_lowercase().contains("key"),
445 "the error must say the key is the problem; got {}",
446 stderr(&output)
447 );
448 assert!(
449 !harness.repos_dir().join("settings.git").exists(),
450 "a failed setup must leave nothing behind"
451 );
452 }
453
454 #[test]
455 fn a_missing_admin_key_is_refused_and_nothing_is_created() {
456 let harness = mixed_server();
457 let missing = harness.repos_dir().join("nowhere").join("absent.pub");
458
459 let output = setup(
460 &harness.config_path(),
461 &["--admin-key", missing.to_str().unwrap()],
462 );
463 assert_failed(&output, "a missing key file");
464 assert!(
465 stderr(&output).contains("absent.pub"),
466 "the error must name the file it could not read; got {}",
467 stderr(&output)
468 );
469 assert!(!harness.repos_dir().join("settings.git").exists());
470 }
471
472 /// The keydir basename *is* the principal, so a name that cannot be written
473 /// in `conf/access.conf` has to be refused rather than written and discovered
474 /// later, when it is the reason nobody can push.
475 #[test]
476 fn a_key_whose_filename_cannot_name_a_principal_is_refused() {
477 let harness = mixed_server();
478 let source = std::fs::read_to_string(admin_key(&harness, "alex")).unwrap();
479 let reserved = harness.scratch("keys").join("CREATOR.pub");
480 std::fs::write(&reserved, &source).unwrap();
481
482 let output = setup(
483 &harness.config_path(),
484 &["--admin-key", reserved.to_str().unwrap()],
485 );
486 assert_failed(&output, "a key named for a reserved keyword");
487 assert!(
488 stderr(&output).contains("--admin-name"),
489 "the refusal must point at the way out; got {}",
490 stderr(&output)
491 );
492 assert!(!harness.repos_dir().join("settings.git").exists());
493 }
494
495 /// `--admin-name` overrides the filename, which is also the only way to name
496 /// an identity when the key arrives on stdin.
497 #[test]
498 fn the_admin_name_can_be_given_explicitly_and_read_from_stdin() {
499 let harness = mixed_server();
500 let output = setup(
501 &harness.config_path(),
502 &[
503 "--admin-key",
504 admin_key(&harness, "alex").to_str().unwrap(),
505 "--admin-name",
506 "ops",
507 ],
508 );
509 assert_ok(&output, "setup with an explicit name");
510 assert!(
511 harness.repos_dir().join("settings.git").exists(),
512 "setup must have run"
513 );
514 let files = git_in(
515 &harness.repos_dir().join("settings.git"),
516 &["show", "--name-only", "--format=", "HEAD"],
517 );
518 assert!(
519 files.contains("keydir/ops.pub"),
520 "the explicit name must decide the keydir basename; got {files}"
521 );
522 }
523
524 #[test]
525 fn the_admin_key_may_be_read_from_stdin() {
526 use std::io::Write;
527 use std::process::Stdio;
528
529 let harness = mixed_server();
530 let key = std::fs::read_to_string(admin_key(&harness, "alex")).unwrap();
531
532 let mut child = Command::new(env!("CARGO_BIN_EXE_git-collab-server"))
533 .args([
534 "setup",
535 "--config",
536 harness.config_path().to_str().unwrap(),
537 "--admin-key",
538 "-",
539 "--admin-name",
540 "ops",
541 ])
542 .stdin(Stdio::piped())
543 .stdout(Stdio::piped())
544 .stderr(Stdio::piped())
545 .spawn()
546 .expect("failed to spawn git-collab-server");
547 child
548 .stdin
549 .take()
550 .unwrap()
551 .write_all(key.as_bytes())
552 .unwrap();
553 let output = child.wait_with_output().unwrap();
554 assert_ok(&output, "setup reading the key from stdin");
555 assert!(harness.repos_dir().join("settings.git").exists());
556 }
557
558 /// Reading a key from stdin with no name to give it cannot guess, and must
559 /// say so rather than inventing one.
560 #[test]
561 fn a_key_on_stdin_with_no_name_is_refused() {
562 use std::io::Write;
563 use std::process::Stdio;
564
565 let harness = mixed_server();
566 let key = std::fs::read_to_string(admin_key(&harness, "alex")).unwrap();
567
568 let mut child = Command::new(env!("CARGO_BIN_EXE_git-collab-server"))
569 .args([
570 "setup",
571 "--config",
572 harness.config_path().to_str().unwrap(),
573 "--admin-key",
574 "-",
575 ])
576 .stdin(Stdio::piped())
577 .stdout(Stdio::piped())
578 .stderr(Stdio::piped())
579 .spawn()
580 .expect("failed to spawn git-collab-server");
581 child
582 .stdin
583 .take()
584 .unwrap()
585 .write_all(key.as_bytes())
586 .unwrap();
587 let output = child.wait_with_output().unwrap();
588 assert_failed(&output, "a key on stdin with no name");
589 assert!(
590 stderr(&output).contains("--admin-name"),
591 "the refusal must name the flag that fixes it; got {}",
592 stderr(&output)
593 );
594 }
595
596 /// `setup` must never touch `authorized_keys`: leaving it alone is what lets
597 /// an operator turn governance back off by removing `settings.git` and find
598 /// the server exactly as they left it.
599 #[test]
600 fn setup_does_not_touch_the_authorized_keys_file() {
601 let harness = mixed_server();
602 // The harness writes the file when a client key is generated.
603 let key = harness.ssh_client_key();
604 let authorized = std::fs::read_to_string(key.with_file_name("authorized_keys")).unwrap();
605
606 assert_ok(
607 &setup(
608 &harness.config_path(),
609 &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()],
610 ),
611 "setup",
612 );
613
614 assert_eq!(
615 authorized,
616 std::fs::read_to_string(key.with_file_name("authorized_keys")).unwrap(),
617 "setup must leave authorized_keys alone"
618 );
619 }