did_git_sign/init.rs
1use anyhow::{Context, Result};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use crate::config::{self, SigningConfig, VtaCredentials};
6
7/// Inputs to install did-git-sign for an already-provisioned persona.
8///
9/// All fields are values the caller already has — there is no VTA bootstrap
10/// here. The function writes the config file, stores VTA credentials in the
11/// OS keyring, runs the relevant `git config` invocations, and updates the
12/// allowed_signers file.
13///
14/// The `verifying_key` is the Ed25519 public key (32 raw bytes) that signs
15/// the persona's commits. It's used in the allowed_signers entry; the
16/// caller is expected to have already derived it from the persona key
17/// material.
18pub struct InstallArgs<'a> {
19 /// `true` writes config to the user's `~/.config/did-git-sign/`,
20 /// `false` writes a repo-local `.did-git-sign.json`.
21 pub global: bool,
22 /// The verification method id, e.g. `did:webvh:.../persona#key-1`.
23 pub did_key_id: String,
24 /// VTA key UUID for the persona's signing key (stored in keyring so
25 /// `did-git-sign -Y sign` can fetch the secret on demand).
26 pub vta_key_id: String,
27 /// DID this binary authenticates to the VTA as. The persona admin DID
28 /// minted during VTA provisioning is the right value here.
29 pub credential_did: String,
30 /// Multibase-encoded private key paired with `credential_did`.
31 pub credential_private_key_mb: String,
32 /// VTA's own DID (e.g. `did:webvh:.../vta`).
33 pub vta_did: String,
34 /// VTA service URL, as resolved from the VTA's DID document. May be
35 /// empty for DIDComm-only VTAs — `mediator_did` must be set in that
36 /// case so the signer can reach the VTA over DIDComm.
37 pub vta_url: String,
38 /// DIDComm mediator DID advertised by the VTA. When `Some`, the signer
39 /// uses DIDComm transport instead of REST. Required when `vta_url` is
40 /// empty.
41 pub mediator_did: Option<String>,
42 /// Optional `git config user.name` to set during install.
43 pub user_name: Option<String>,
44 /// Persona signing key public bytes (Ed25519, 32 bytes).
45 pub verifying_key: &'a [u8; 32],
46}
47
48/// Output of [`install`]. Mostly informational — used for the post-install
49/// summary the caller prints.
50pub struct InstallResult {
51 /// Path the JSON config was written to.
52 pub config_path: PathBuf,
53 /// SSH public key string (`ssh-ed25519 …`) for the user to paste into
54 /// their git host's signing-key settings.
55 pub ssh_public_key: String,
56 /// Set to the previous `--global user.signingKey` value if a non-global
57 /// install just shadowed it. The caller can flag this to the operator
58 /// so they aren't surprised when inspecting `git config --list`.
59 pub overridden_global_signing_key: Option<String>,
60 /// Set when a `--global` install just made a DID the committer email for
61 /// **every** repository on the machine.
62 ///
63 /// Correct for a contributor in one community, and wrong the moment there
64 /// are two: the identity a commit claims has to match the key that signs
65 /// it, so a single global value silently claims the wrong community
66 /// everywhere else. The caller surfaces this with the per-community
67 /// alternative rather than refusing — the single-community case is real
68 /// and `--global` is the right tool for it.
69 pub global_committer_email: Option<String>,
70}
71
72/// Configure did-git-sign for an already-provisioned persona.
73///
74/// Idempotent against the file/keyring/git config state — re-running on a
75/// host that already has did-git-sign installed updates the values without
76/// erroring.
77pub fn install(args: InstallArgs<'_>) -> Result<InstallResult> {
78 let cfg = SigningConfig {
79 did_key_id: args.did_key_id.clone(),
80 user_name: args.user_name,
81 };
82
83 let vta_creds = VtaCredentials {
84 vta_url: args.vta_url,
85 vta_did: args.vta_did,
86 credential_did: args.credential_did,
87 private_key_multibase: args.credential_private_key_mb,
88 key_id: args.vta_key_id,
89 mediator_did: args.mediator_did,
90 };
91
92 let config_path = if args.global {
93 SigningConfig::default_global_path()?
94 } else {
95 SigningConfig::repo_local_path()
96 };
97
98 cfg.save(&config_path)?;
99 config::store_vta_credentials(&args.did_key_id, &vta_creds)?;
100
101 setup_git(&config_path, &cfg, args.global)?;
102
103 let entry = allowed_signers_entry(&cfg, args.verifying_key);
104 let config_dir = config_path.parent().unwrap_or(Path::new("."));
105 setup_allowed_signers(config_dir, &entry, args.global)?;
106
107 // If we just shadowed a global user.signingKey with a local one, tell
108 // the caller so they can surface it. Best-effort — failures here are
109 // non-fatal.
110 let overridden_global_signing_key = (!args.global)
111 .then(|| {
112 std::process::Command::new("git")
113 .args(["config", "--global", "user.signingKey"])
114 .output()
115 .ok()
116 .filter(|o| o.status.success())
117 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
118 .filter(|s| !s.is_empty())
119 })
120 .flatten();
121
122 Ok(InstallResult {
123 config_path,
124 ssh_public_key: ssh_public_key_string(args.verifying_key),
125 overridden_global_signing_key,
126 global_committer_email: args.global.then(|| cfg.did_key_id.clone()),
127 })
128}
129
130/// Tear down a did-git-sign install for `did_key_id`. Idempotent — every
131/// step succeeds (best-effort) when its target is already gone, so the
132/// function is safe to run repeatedly or against a partial install.
133///
134/// The caller decides scope: pass `global = true` to remove the user's
135/// `~/.config/did-git-sign/config.json` install, `false` to remove a
136/// repo-local `.did-git-sign.json` next to the current directory.
137///
138/// Returned [`UninstallResult`] is informational — it lists what was
139/// touched so callers can render a summary, but never carries a hard
140/// failure.
141pub fn uninstall(global: bool, did_key_id: &str) -> Result<UninstallResult> {
142 let mut summary = UninstallResult::default();
143
144 let config_path = if global {
145 SigningConfig::default_global_path()?
146 } else {
147 SigningConfig::repo_local_path()
148 };
149
150 // 1. Remove SigningConfig JSON file (silently if absent).
151 if config_path.exists() {
152 match std::fs::remove_file(&config_path) {
153 Ok(()) => {
154 summary.removed_config_file = Some(config_path.clone());
155 }
156 Err(e) => {
157 summary
158 .warnings
159 .push(format!("could not remove {}: {e}", config_path.display()));
160 }
161 }
162 }
163
164 // 2. Drop the keyring entries that are keyed by did_key_id. The
165 // `delete_credential` API errors when the entry doesn't exist —
166 // swallow that case.
167 for suffix in [":vta", ":token"] {
168 let key = format!("{did_key_id}{suffix}");
169 if let Ok(entry) = keyring_core::Entry::new(config::KEYRING_SERVICE, &key) {
170 match entry.delete_credential() {
171 Ok(()) => summary.removed_keyring_entries.push(key),
172 Err(keyring_core::Error::NoEntry) => {}
173 Err(e) => {
174 summary
175 .warnings
176 .push(format!("could not remove keyring entry '{key}': {e}"));
177 }
178 }
179 }
180 }
181
182 // 3. Strip the matching line out of allowed_signers (if the file
183 // exists and contains an entry for this principal). Other principals
184 // in the same file are preserved.
185 let signers_path = config_path
186 .parent()
187 .unwrap_or(Path::new("."))
188 .join("allowed_signers");
189 if signers_path.exists() {
190 match std::fs::read_to_string(&signers_path) {
191 Ok(content) => {
192 let prefix = format!("{did_key_id} ");
193 let mut kept = Vec::new();
194 let mut removed = false;
195 for line in content.lines() {
196 if line.trim_start().starts_with(&prefix) {
197 removed = true;
198 } else {
199 kept.push(line);
200 }
201 }
202 if removed {
203 let mut new_content = kept.join("\n");
204 if !new_content.is_empty() {
205 new_content.push('\n');
206 }
207 // Atomic for the same reason as the install path: a reader
208 // must never catch this file mid-truncate and conclude the
209 // remaining principals are not allowed to sign.
210 if let Err(e) = write_file_atomic(&signers_path, &new_content) {
211 summary
212 .warnings
213 .push(format!("could not rewrite {}: {e}", signers_path.display()));
214 } else {
215 summary.allowed_signers_entry_removed = true;
216 }
217 }
218 }
219 Err(e) => {
220 summary
221 .warnings
222 .push(format!("could not read {}: {e}", signers_path.display()));
223 }
224 }
225 }
226
227 // 4. Unset the git config keys we own at the install scope. Best
228 // effort — `git config --unset` errors when the key isn't set,
229 // which we ignore.
230 let scope = if global { "--global" } else { "--local" };
231 for key in [
232 "user.signingKey",
233 "gpg.format",
234 "gpg.ssh.program",
235 "gpg.ssh.defaultKeyFile",
236 "gpg.ssh.allowedSignersFile",
237 "commit.gpgsign",
238 ] {
239 if git_config_unset(scope, key) {
240 summary.git_config_keys_unset.push(key.to_string());
241 }
242 }
243
244 Ok(summary)
245}
246
247/// Outcome of an [`uninstall`] call. None of the variants represent fatal
248/// errors — the caller is expected to render `warnings` if it wants to
249/// surface partial-state issues to the operator.
250#[derive(Debug, Default)]
251pub struct UninstallResult {
252 /// Path of the SigningConfig file that was removed (if any).
253 pub removed_config_file: Option<PathBuf>,
254 /// Keyring keys that were deleted (under the `did-git-sign` service).
255 pub removed_keyring_entries: Vec<String>,
256 /// True when an allowed_signers line for this principal was removed.
257 pub allowed_signers_entry_removed: bool,
258 /// Git config keys that were unset at the install scope.
259 pub git_config_keys_unset: Vec<String>,
260 /// Best-effort warnings — used for display, not error propagation.
261 pub warnings: Vec<String>,
262}
263
264/// Returns true if `git config <scope> --unset <key>` removed something.
265/// Errors and "key not present" both map to false (best-effort cleanup).
266fn git_config_unset(scope: &str, key: &str) -> bool {
267 Command::new("git")
268 .arg("config")
269 .arg(scope)
270 .arg("--unset")
271 .arg(key)
272 .output()
273 .ok()
274 .map(|o| o.status.success())
275 .unwrap_or(false)
276}
277
278/// Initialize git configuration for DID-based SSH signing.
279pub fn setup_git(config_path: &Path, cfg: &SigningConfig, global: bool) -> Result<()> {
280 let scope = if global { "--global" } else { "--local" };
281 let config_path_str = config_path
282 .to_str()
283 .context("config path is not valid UTF-8")?;
284
285 // Set gpg format to ssh
286 git_config(scope, "gpg.format", "ssh")?;
287
288 // Set our tool as the signing program
289 // Git calls: <program> -Y sign -f <user.signingKey or defaultKeyFile> -n git
290 git_config(scope, "gpg.ssh.program", "did-git-sign")?;
291
292 // Point git to our config file as both the signing key and the fallback key file.
293 // user.signingKey takes precedence over gpg.ssh.defaultKeyFile when set, so we
294 // must set it here to override any global user.signingKey (e.g. an SSH public key)
295 // that would otherwise be passed as -f and cause a config parse error.
296 //
297 // NOTE: user.signingKey is conventionally a .pub path; using a .json path here
298 // is unconventional. Third-party tools inspecting this repo's git config will
299 // see a non-.pub value. This is an accepted trade-off — the local override is
300 // the only non-destructive way to win over a global user.signingKey without
301 // modifying the user's global git configuration.
302 git_config(scope, "user.signingKey", config_path_str)?;
303 git_config(scope, "gpg.ssh.defaultKeyFile", config_path_str)?;
304
305 // Enable commit signing by default
306 git_config(scope, "commit.gpgsign", "true")?;
307
308 // The committer identity IS the DID claim. An sshsig blob carries a raw
309 // Ed25519 key and no identity, so `user.email` is the only place a commit
310 // states which DID signed it — `verify-trust` reads it from the committer
311 // header (inside the payload the signature covers), resolves that DID, and
312 // requires it to publish the signing key. Left unset, every commit fails
313 // the CI check as `noSignerDid` however valid its signature.
314 //
315 // This was removed once, on the grounds that git's own SSH verification
316 // uses the allowed_signers principal rather than user.email. That reasoning
317 // does not hold either way round: `allowed_signers_entry` writes the
318 // principal as `did_key_id`, and git matches principals against the
319 // committer email — so leaving it unset breaks the local check too.
320 git_config(scope, "user.email", &cfg.did_key_id)?;
321
322 // Optionally set user.name
323 if let Some(name) = &cfg.user_name {
324 git_config(scope, "user.name", name)?;
325 }
326
327 Ok(())
328}
329
330/// Generate an allowed_signers file entry for verification.
331pub fn allowed_signers_entry(cfg: &SigningConfig, public_key_bytes: &[u8; 32]) -> String {
332 let pub_b64 = base64_encode_pubkey(public_key_bytes);
333 format!("{} ssh-ed25519 {}", cfg.did_key_id, pub_b64)
334}
335
336/// Replace a file's contents in one step: write a sibling temp file, then
337/// rename it over the target.
338///
339/// `std::fs::write` truncates and then writes, so a reader arriving in between
340/// sees an empty or partial file, and two writers racing can interleave. For
341/// `allowed_signers` that means a concurrent `git verify-commit` reading no
342/// principals — a signature that verifies reported as one that does not — or
343/// one `init` losing another's entry. `rename(2)` within a directory is atomic
344/// on POSIX: a reader sees either the old file or the new one.
345///
346/// The temp file is created in the destination directory because rename cannot
347/// cross filesystems, and carries the pid so two processes cannot collide on
348/// it.
349fn write_file_atomic(path: &Path, contents: &str) -> Result<()> {
350 let dir = path.parent().unwrap_or(Path::new("."));
351 let name = path
352 .file_name()
353 .and_then(|n| n.to_str())
354 .unwrap_or("allowed_signers");
355 let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id()));
356
357 std::fs::write(&tmp, contents).with_context(|| format!("failed to write {}", tmp.display()))?;
358 match std::fs::rename(&tmp, path) {
359 Ok(()) => Ok(()),
360 Err(e) => {
361 // Do not leave the temp file behind on a failed rename.
362 let _ = std::fs::remove_file(&tmp);
363 Err(e).with_context(|| format!("failed to replace {}", path.display()))
364 }
365 }
366}
367
368/// Set up the allowed_signers file for signature verification.
369pub fn setup_allowed_signers(config_dir: &Path, entry: &str, global: bool) -> Result<()> {
370 let signers_path = config_dir.join("allowed_signers");
371 let signers_path_str = signers_path
372 .to_str()
373 .context("signers path is not valid UTF-8")?;
374
375 // Append or create the allowed_signers file. Read-modify-write is still a
376 // race against a *concurrent* init — two personas provisioned at once can
377 // still lose one entry — but the write itself no longer exposes a
378 // truncated file to a reader mid-update.
379 let existing = std::fs::read_to_string(&signers_path).unwrap_or_default();
380 if !existing.contains(entry) {
381 let mut content = existing;
382 if !content.is_empty() && !content.ends_with('\n') {
383 content.push('\n');
384 }
385 content.push_str(entry);
386 content.push('\n');
387 write_file_atomic(&signers_path, &content)?;
388 }
389
390 let scope = if global { "--global" } else { "--local" };
391 git_config(scope, "gpg.ssh.allowedSignersFile", signers_path_str)?;
392
393 Ok(())
394}
395
396/// Run `git config <scope> <key> <value>`.
397fn git_config(scope: &str, key: &str, value: &str) -> Result<()> {
398 let output = Command::new("git")
399 .arg("config")
400 .arg(scope)
401 .arg(key)
402 .arg(value)
403 .output()
404 .context("failed to run git config")?;
405
406 if !output.status.success() {
407 let stderr = String::from_utf8_lossy(&output.stderr);
408 anyhow::bail!("git config {scope} {key} failed: {stderr}");
409 }
410
411 Ok(())
412}
413
414/// Format an Ed25519 public key as an SSH public key string (e.g., `ssh-ed25519 AAAA...`).
415pub fn ssh_public_key_string(public_key_bytes: &[u8; 32]) -> String {
416 format!("ssh-ed25519 {}", base64_encode_pubkey(public_key_bytes))
417}
418
419/// Base64-encode a raw Ed25519 public key for SSH authorized_keys format.
420fn base64_encode_pubkey(public_key_bytes: &[u8; 32]) -> String {
421 use base64::Engine;
422 // SSH public key blob: "ssh-ed25519" type string + key bytes
423 let mut blob = Vec::new();
424 let key_type = b"ssh-ed25519";
425 blob.extend_from_slice(&(key_type.len() as u32).to_be_bytes());
426 blob.extend_from_slice(key_type);
427 blob.extend_from_slice(&(public_key_bytes.len() as u32).to_be_bytes());
428 blob.extend_from_slice(public_key_bytes);
429 base64::engine::general_purpose::STANDARD.encode(&blob)
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn test_base64_pubkey_format() {
438 let key = [0u8; 32];
439 let encoded = base64_encode_pubkey(&key);
440 // Should be a valid base64 string
441 assert!(!encoded.is_empty());
442
443 // Decode and verify structure
444 use base64::Engine;
445 let decoded = base64::engine::general_purpose::STANDARD
446 .decode(&encoded)
447 .unwrap();
448 // 4 + 11 + 4 + 32 = 51 bytes
449 assert_eq!(decoded.len(), 51);
450 }
451
452 #[test]
453 fn test_allowed_signers_entry_format() {
454 let cfg = SigningConfig {
455 did_key_id: "did:webvh:abc:example.com#key-0".to_string(),
456 user_name: None,
457 };
458 let key = [0u8; 32];
459 let entry = allowed_signers_entry(&cfg, &key);
460 assert!(entry.starts_with("did:webvh:abc:example.com#key-0 ssh-ed25519 "));
461 }
462
463 #[test]
464 fn test_ssh_public_key_string_format() {
465 let key = [0u8; 32];
466 let result = ssh_public_key_string(&key);
467 assert!(result.starts_with("ssh-ed25519 "));
468 // The base64 part should be decodable
469 let b64_part = result.strip_prefix("ssh-ed25519 ").unwrap();
470 let decoded =
471 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64_part).unwrap();
472 // 4 + 11 + 4 + 32 = 51 bytes
473 assert_eq!(decoded.len(), 51);
474 }
475
476 #[test]
477 fn test_allowed_signers_entry_contains_valid_ssh_key() {
478 let cfg = SigningConfig {
479 did_key_id: "did:webvh:test:host#key-0".to_string(),
480 user_name: Some("Test User".to_string()),
481 };
482 let key = [0xFF; 32];
483 let entry = allowed_signers_entry(&cfg, &key);
484
485 // Entry should have format: <email> ssh-ed25519 <base64>
486 let parts: Vec<&str> = entry.splitn(3, ' ').collect();
487 assert_eq!(parts.len(), 3);
488 assert_eq!(parts[0], "did:webvh:test:host#key-0");
489 assert_eq!(parts[1], "ssh-ed25519");
490 // Third part is valid base64
491 assert!(
492 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, parts[2],).is_ok()
493 );
494 }
495
496 #[test]
497 fn test_base64_pubkey_encodes_key_type_and_bytes() {
498 let key = [0x42; 32];
499 let encoded = base64_encode_pubkey(&key);
500 let decoded =
501 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &encoded).unwrap();
502
503 // Verify SSH wire format: uint32 len + "ssh-ed25519" + uint32 len + key bytes
504 assert_eq!(&decoded[0..4], &(11u32).to_be_bytes());
505 assert_eq!(&decoded[4..15], b"ssh-ed25519");
506 assert_eq!(&decoded[15..19], &(32u32).to_be_bytes());
507 assert_eq!(&decoded[19..51], &[0x42; 32]);
508 }
509
510 #[test]
511 fn test_setup_allowed_signers_creates_file() {
512 let dir = tempfile::tempdir().unwrap();
513 let entry = "did:webvh:test:host#key-0 ssh-ed25519 AAAA";
514
515 // We cannot test the git config part without a git repo, but we can test
516 // the file-writing portion by calling the function in a git repo context.
517 // Instead, verify the file-writing logic directly:
518 let signers_path = dir.path().join("allowed_signers");
519 let content = format!("{entry}\n");
520 std::fs::write(&signers_path, &content).unwrap();
521
522 let read_back = std::fs::read_to_string(&signers_path).unwrap();
523 assert!(read_back.contains(entry));
524 }
525
526 #[test]
527 fn atomic_write_replaces_contents_and_leaves_no_temp_file() {
528 let dir = tempfile::tempdir().unwrap();
529 let path = dir.path().join("allowed_signers");
530
531 write_file_atomic(&path, "first\n").unwrap();
532 assert_eq!(std::fs::read_to_string(&path).unwrap(), "first\n");
533
534 write_file_atomic(&path, "second\n").unwrap();
535 assert_eq!(
536 std::fs::read_to_string(&path).unwrap(),
537 "second\n",
538 "the rename must replace, not append"
539 );
540
541 let strays: Vec<_> = std::fs::read_dir(dir.path())
542 .unwrap()
543 .filter_map(Result::ok)
544 .map(|e| e.file_name().to_string_lossy().to_string())
545 .filter(|n| n != "allowed_signers")
546 .collect();
547 assert!(strays.is_empty(), "temp files left behind: {strays:?}");
548 }
549
550 /// The property `std::fs::write` could not offer: a reader either sees the
551 /// old file or the new one, never a truncated one. Asserted by observing
552 /// that the target is never absent or empty across a replace — with
553 /// truncate-then-write there is a window where it is both.
554 #[test]
555 fn atomic_write_never_exposes_a_truncated_file() {
556 let dir = tempfile::tempdir().unwrap();
557 let path = dir.path().join("allowed_signers");
558 let long = "did:webvh:test:host#key-0 ssh-ed25519 AAAA\n".repeat(500);
559 write_file_atomic(&path, &long).unwrap();
560
561 for _ in 0..20 {
562 write_file_atomic(&path, &long).unwrap();
563 let seen = std::fs::read_to_string(&path).expect("target always exists");
564 assert_eq!(seen.len(), long.len(), "a reader saw a partial file");
565 }
566 }
567
568 #[test]
569 fn test_different_keys_produce_different_ssh_strings() {
570 let key_a = [0x00; 32];
571 let key_b = [0xFF; 32];
572 assert_ne!(ssh_public_key_string(&key_a), ssh_public_key_string(&key_b));
573 }
574
575 /// Changes the process CWD on construction, restores it on drop (panic-safe).
576 /// Requires `#[serial_test::serial]` — CWD is process-global.
577 /// **Any future non-serial test that uses a relative path or calls
578 /// `current_dir()` will silently resolve against the wrong directory.**
579 struct CwdGuard {
580 original: std::path::PathBuf,
581 }
582
583 impl CwdGuard {
584 fn change_to(path: &std::path::Path) -> Self {
585 let original = std::env::current_dir().unwrap();
586 std::env::set_current_dir(path).unwrap();
587 CwdGuard { original }
588 }
589 }
590
591 impl Drop for CwdGuard {
592 fn drop(&mut self) {
593 // Best-effort restore; ignore errors (e.g. if the temp dir was already removed).
594 let _ = std::env::set_current_dir(&self.original);
595 }
596 }
597
598 /// `setup_git` must write `user.email` as the signing DID's key id.
599 ///
600 /// This inverts an earlier regression guard that asserted the opposite. That
601 /// guard's reasoning — git's SSH verification matches the allowed_signers
602 /// principal, not user.email — does not survive either direction:
603 /// [`allowed_signers_entry`] writes the principal as `did_key_id` and git
604 /// matches principals *against the committer email*, so an unset value
605 /// breaks local verification too. And `verify-trust` has no other channel
606 /// for the identity at all: an sshsig carries a key, never a DID, so a
607 /// commit whose committer is not a DID fails CI as `noSignerDid` no matter
608 /// how valid its signature.
609 #[test]
610 #[serial_test::serial]
611 fn setup_git_writes_the_signing_did_as_user_email() {
612 let dir = tempfile::tempdir().unwrap();
613 std::process::Command::new("git")
614 .args(["init"])
615 .current_dir(dir.path())
616 .output()
617 .unwrap();
618
619 // Move into the temp repo so that `git config --local` targets it.
620 // The inner block ensures CwdGuard is dropped (and CWD restored) before
621 // the assertions run, keeping the verify step independent of CWD.
622 let original_cwd = std::env::current_dir().unwrap();
623 {
624 let _cwd = CwdGuard::change_to(dir.path());
625 let config_path = dir.path().join(".did-git-sign.json");
626 let cfg = SigningConfig {
627 did_key_id: "did:webvh:test#key-0".to_string(),
628 user_name: None,
629 };
630 setup_git(&config_path, &cfg, false).unwrap();
631 // _cwd drops here: original directory is restored
632 }
633 // Pin the invariant explicitly so a future edit that moves the
634 // verify command inside the guard's scope (or drops the guard) is
635 // caught loudly rather than silently regressing the CWD-independence
636 // promise the inner block makes.
637 assert_eq!(
638 std::env::current_dir().unwrap(),
639 original_cwd,
640 "CwdGuard must restore the original directory on drop"
641 );
642
643 // Verify with an explicit -C so the check is not sensitive to the current CWD.
644 let out = std::process::Command::new("git")
645 .args([
646 "-C",
647 dir.path().to_str().unwrap(),
648 "config",
649 "--local",
650 "user.email",
651 ])
652 .output()
653 .unwrap();
654
655 assert!(
656 out.status.success(),
657 "user.email must be set by setup_git: without it every commit fails \
658 verify-trust as noSignerDid"
659 );
660 assert_eq!(
661 String::from_utf8_lossy(&out.stdout).trim(),
662 "did:webvh:test#key-0",
663 "user.email must be the signing DID's verification-method id"
664 );
665 }
666}