Skip to main content

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}