Skip to main content

auths_cli/commands/artifact/
verify.rs

1use anyhow::{Context, Result, anyhow};
2use serde::Serialize;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use auths_keri::witness::SignedReceipt;
7use auths_verifier::core::Attestation;
8use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig};
9use auths_verifier::{
10    CanonicalDid, IdentityBundle, VerificationReport, verify_chain, verify_chain_with_witnesses,
11};
12
13use super::core::{ArtifactMetadata, ArtifactSource};
14use super::file::FileArtifact;
15use crate::commands::verify_helpers::parse_witness_keys;
16use crate::ux::format::is_json_mode;
17
18/// JSON output for `artifact verify --json`.
19#[derive(Serialize)]
20struct VerifyArtifactResult {
21    file: String,
22    valid: bool,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    digest_match: Option<bool>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    chain_valid: Option<bool>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    chain_report: Option<VerificationReport>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    witness_quorum: Option<WitnessQuorum>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    issuer: Option<String>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    commit_sha: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    commit_verified: Option<bool>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    error: Option<String>,
39}
40
41/// Execute the `artifact verify` command.
42///
43/// Exit codes: 0=valid, 1=invalid, 2=error.
44pub async fn handle_verify(
45    file: &Path,
46    signature: Option<PathBuf>,
47    identity_bundle: Option<PathBuf>,
48    witness_receipts: Option<PathBuf>,
49    witness_keys: &[String],
50    witness_threshold: usize,
51    verify_commit: bool,
52) -> Result<()> {
53    let file_str = file.to_string_lossy().to_string();
54
55    // 1. Locate and load signature file
56    let sig_path = signature.unwrap_or_else(|| {
57        let mut p = file.to_path_buf();
58        let new_name = format!(
59            "{}.auths.json",
60            p.file_name().unwrap_or_default().to_string_lossy()
61        );
62        p.set_file_name(new_name);
63        p
64    });
65
66    let sig_content = match fs::read_to_string(&sig_path) {
67        Ok(c) => c,
68        Err(e) => {
69            return output_error(
70                &file_str,
71                2,
72                &format!("Failed to read signature file {:?}: {}", sig_path, e),
73            );
74        }
75    };
76
77    // 2. Parse attestation
78    let attestation: Attestation = match serde_json::from_str(&sig_content) {
79        Ok(a) => a,
80        Err(e) => {
81            return output_error(&file_str, 2, &format!("Failed to parse attestation: {}", e));
82        }
83    };
84
85    // 3. Extract artifact metadata from payload
86    let artifact_meta: ArtifactMetadata = match &attestation.payload {
87        Some(payload) => match serde_json::from_value(payload.clone()) {
88            Ok(m) => m,
89            Err(e) => {
90                return output_error(
91                    &file_str,
92                    2,
93                    &format!("Failed to parse artifact metadata from payload: {}", e),
94                );
95            }
96        },
97        None => {
98            return output_error(
99                &file_str,
100                2,
101                "Attestation has no payload (expected artifact metadata)",
102            );
103        }
104    };
105
106    // 4. Compute file digest and compare
107    let file_artifact = FileArtifact::new(file);
108    let file_digest = match file_artifact.digest() {
109        Ok(d) => d,
110        Err(e) => {
111            return output_error(
112                &file_str,
113                2,
114                &format!("Failed to compute file digest: {}", e),
115            );
116        }
117    };
118
119    if file_digest != artifact_meta.digest {
120        return output_result(
121            1,
122            VerifyArtifactResult {
123                file: file_str.clone(),
124                valid: false,
125                digest_match: Some(false),
126                chain_valid: None,
127                chain_report: None,
128                witness_quorum: None,
129                issuer: Some(attestation.issuer.to_string()),
130                commit_sha: attestation.commit_sha.clone(),
131                commit_verified: None,
132                error: Some(format!(
133                    "Digest mismatch: file={}, attestation={}",
134                    file_digest.hex, artifact_meta.digest.hex
135                )),
136            },
137        );
138    }
139
140    // 5. Resolve identity public key
141    let (root_pk, identity_did) = match resolve_identity_key(&identity_bundle, &attestation) {
142        Ok(v) => v,
143        Err(e) => {
144            return output_error(&file_str, 2, &e.to_string());
145        }
146    };
147
148    // 6. Verify attestation chain authenticity (signatures, linkage, expiry).
149    //    Capability authority is no longer gated here: an artifact-signer capability
150    //    grant must come from a holder-verified ACDC credential, not the attestation.
151    let chain = vec![attestation.clone()];
152    let chain_result = verify_chain(&chain, &root_pk).await;
153
154    let (chain_valid, chain_report) = match chain_result {
155        Ok(mut report) => {
156            if let Ok(home) = auths_sdk::paths::auths_home() {
157                let storage = auths_sdk::storage::RegistryAttestationStorage::new(&home);
158                if let Ok(enriched) = storage.load_all_enriched() {
159                    let anchor_set: std::collections::HashSet<auths_keri::Said> = enriched
160                        .iter()
161                        .filter(|e| e.anchor == auths_keri::AnchorStatus::Anchored)
162                        .map(|e| e.said.clone())
163                        .collect();
164                    let all_anchored = chain.iter().all(|att| {
165                        auths_sdk::attestation::canonical_said(att)
166                            .is_some_and(|s| anchor_set.contains(&s))
167                    });
168                    report.anchored = Some(if all_anchored {
169                        auths_keri::AnchorStatus::Anchored
170                    } else {
171                        auths_keri::AnchorStatus::NotAnchored
172                    });
173                }
174            }
175            let is_valid = report.is_valid();
176            (Some(is_valid), Some(report))
177        }
178        Err(e) => {
179            return output_error(&file_str, 1, &format!("Chain verification failed: {}", e));
180        }
181    };
182
183    // 7. Optional witness verification
184    let witness_quorum = match verify_witnesses(
185        &chain,
186        &root_pk,
187        &witness_receipts,
188        witness_keys,
189        witness_threshold,
190    )
191    .await
192    {
193        Ok(q) => q,
194        Err(e) => {
195            return output_error(&file_str, 2, &format!("Witness verification error: {}", e));
196        }
197    };
198
199    // 8. Compute overall verdict
200    let mut valid = chain_valid.unwrap_or(false);
201
202    if let Some(ref q) = witness_quorum
203        && q.verified < q.required
204    {
205        valid = false;
206    }
207
208    // 8a. Ephemeral attestation: verify commit signature transitively
209    let is_ephemeral = attestation.issuer.as_str().starts_with("did:key:");
210    if is_ephemeral && valid {
211        match &attestation.commit_sha {
212            None => {
213                if !is_json_mode() {
214                    eprintln!(
215                        "Error: ephemeral attestation (did:key issuer) requires commit_sha. \
216                         This attestation is unsigned provenance without a commit anchor."
217                    );
218                }
219                valid = false;
220            }
221            Some(sha) => {
222                // Verify the commit is signed by a trusted key.
223                // Uses in-process verification via auths-verifier (no git shell-out).
224                let commit_sig_ok = verify_commit_in_process(sha).await;
225
226                if !commit_sig_ok {
227                    valid = false;
228                }
229
230                if !is_json_mode() {
231                    if commit_sig_ok {
232                        eprintln!(
233                            "  Trust chain: artifact <- ephemeral key <- commit {} <- maintainer",
234                            &sha[..8.min(sha.len())]
235                        );
236                    } else {
237                        eprintln!(
238                            "  Commit {} is not signed by a trusted maintainer.",
239                            &sha[..8.min(sha.len())]
240                        );
241                    }
242                }
243            }
244        }
245    }
246
247    // 8b. Display commit linkage info (always, when present)
248    let commit_sha_val = attestation.commit_sha.clone();
249    if let Some(ref sha) = commit_sha_val
250        && !is_json_mode()
251        && !is_ephemeral
252    {
253        eprintln!("  Commit: {}", sha);
254    }
255
256    // 8c. Optional commit attestation verification
257    let commit_verified = if verify_commit {
258        match &commit_sha_val {
259            None => {
260                if !is_json_mode() {
261                    eprintln!(
262                        "warning: artifact attestation has no commit_sha field; \
263                         re-sign with: auths artifact sign --commit <SHA>"
264                    );
265                }
266                None
267            }
268            Some(sha) => {
269                // Look up commit attestation via git ref
270                let commit_ref = format!("refs/auths/commits/{}", sha);
271                let lookup = crate::subprocess::git_command(&[
272                    "show",
273                    &format!("{}:attestation.json", commit_ref),
274                ])
275                .output();
276                match lookup {
277                    Ok(output) if output.status.success() => {
278                        if !is_json_mode() {
279                            eprintln!("  Commit {}: signing attestation found", &sha[..12]);
280                        }
281                        Some(true)
282                    }
283                    _ => {
284                        if !is_json_mode() {
285                            eprintln!(
286                                "warning: no signing attestation found for commit {}",
287                                &sha[..std::cmp::min(sha.len(), 12)]
288                            );
289                        }
290                        Some(false)
291                    }
292                }
293            }
294        }
295    } else {
296        None
297    };
298
299    let exit_code = if valid { 0 } else { 1 };
300
301    output_result(
302        exit_code,
303        VerifyArtifactResult {
304            file: file_str,
305            valid,
306            digest_match: Some(true),
307            chain_valid,
308            chain_report,
309            witness_quorum,
310            issuer: Some(identity_did.to_string()),
311            commit_sha: commit_sha_val,
312            commit_verified,
313            error: None,
314        },
315    )
316}
317
318/// Resolve identity public key from bundle or from the attestation's issuer DID.
319fn resolve_identity_key(
320    identity_bundle: &Option<PathBuf>,
321    attestation: &Attestation,
322) -> Result<(auths_verifier::DevicePublicKey, CanonicalDid)> {
323    if let Some(bundle_path) = identity_bundle {
324        let bundle_content = fs::read_to_string(bundle_path)
325            .with_context(|| format!("Failed to read identity bundle: {:?}", bundle_path))?;
326        let bundle: IdentityBundle = serde_json::from_str(&bundle_content)
327            .with_context(|| format!("Failed to parse identity bundle: {:?}", bundle_path))?;
328        let pk_bytes = hex::decode(bundle.public_key_hex.as_str())
329            .context("Invalid public key hex in bundle")?;
330        let pk = auths_verifier::DevicePublicKey::try_new(bundle.curve, &pk_bytes)
331            .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
332        Ok((pk, bundle.identity_did.into()))
333    } else {
334        // Resolve public key from the issuer DID
335        let issuer = &attestation.issuer;
336        let (pk_bytes, curve) = resolve_pk_from_did(issuer)
337            .with_context(|| format!("Failed to resolve public key from issuer DID '{}'. Use --identity-bundle for stateless verification.", issuer))?;
338        let pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
339            .map_err(|e| anyhow!("Invalid issuer public key resolved from DID: {e}"))?;
340        Ok((pk, issuer.clone()))
341    }
342}
343
344/// Extract raw Ed25519 public key bytes from a DID string.
345///
346/// Supports `did:keri:<base58>` and `did:key:z<base58multicodec>`.
347fn resolve_pk_from_did(did: &str) -> Result<(Vec<u8>, auths_crypto::CurveType)> {
348    if let Some(encoded) = did.strip_prefix("did:keri:") {
349        let pk = bs58::decode(encoded)
350            .into_vec()
351            .context("Invalid base58 in did:keri")?;
352        // KERI DIDs are currently Ed25519-only
353        Ok((pk, auths_crypto::CurveType::Ed25519))
354    } else if did.starts_with("did:key:z") {
355        match auths_crypto::did_key_decode(did) {
356            Ok(auths_crypto::DecodedDidKey::Ed25519(pk)) => {
357                Ok((pk.to_vec(), auths_crypto::CurveType::Ed25519))
358            }
359            Ok(auths_crypto::DecodedDidKey::P256(pk)) => Ok((pk, auths_crypto::CurveType::P256)),
360            Err(e) => Err(anyhow!("Failed to resolve did:key: {}", e)),
361        }
362    } else {
363        Err(anyhow!(
364            "Unsupported DID method: {}. Use --identity-bundle instead.",
365            did
366        ))
367    }
368}
369
370/// Verify witness receipts if provided.
371async fn verify_witnesses(
372    chain: &[Attestation],
373    root_pk: &auths_verifier::DevicePublicKey,
374    receipts_path: &Option<PathBuf>,
375    witness_keys_raw: &[String],
376    threshold: usize,
377) -> Result<Option<WitnessQuorum>> {
378    let receipts_path = match receipts_path {
379        Some(p) => p,
380        None => return Ok(None),
381    };
382
383    let receipts_bytes = fs::read(receipts_path)
384        .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
385    let receipts: Vec<SignedReceipt> =
386        serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
387
388    let witness_keys = parse_witness_keys(witness_keys_raw)?;
389
390    let config = WitnessVerifyConfig {
391        receipts: &receipts,
392        witness_keys: &witness_keys,
393        threshold,
394    };
395
396    let report = verify_chain_with_witnesses(chain, root_pk, &config)
397        .await
398        .context("Witness chain verification failed")?;
399
400    Ok(report.witness_quorum)
401}
402
403fn output_error(file: &str, exit_code: i32, message: &str) -> Result<()> {
404    if is_json_mode() {
405        let result = VerifyArtifactResult {
406            file: file.to_string(),
407            valid: false,
408            digest_match: None,
409            chain_valid: None,
410            chain_report: None,
411            witness_quorum: None,
412            issuer: None,
413            commit_sha: None,
414            commit_verified: None,
415            error: Some(message.to_string()),
416        };
417        println!("{}", serde_json::to_string(&result)?);
418    } else {
419        eprintln!("Error: {}", message);
420    }
421    std::process::exit(exit_code);
422}
423
424/// Output the verification result.
425fn output_result(exit_code: i32, result: VerifyArtifactResult) -> Result<()> {
426    if is_json_mode() {
427        println!("{}", serde_json::to_string(&result)?);
428    } else if result.valid {
429        print!("Artifact verified");
430        if let Some(ref issuer) = result.issuer {
431            print!(": signed by {}", issuer);
432        }
433        if let Some(ref q) = result.witness_quorum {
434            print!(" (witnesses: {}/{})", q.verified, q.required);
435        }
436        println!();
437    } else {
438        eprint!("Verification failed");
439        if let Some(ref error) = result.error {
440            eprint!(": {}", error);
441        }
442        eprintln!();
443    }
444
445    if exit_code != 0 {
446        std::process::exit(exit_code);
447    }
448    Ok(())
449}
450
451/// Verify the commit an ephemeral attestation is bound to, KEL-natively.
452///
453/// Reads the raw commit via git2, then delegates trust to the SDK commit-trust
454/// resolver: the signer must be a device delegated under a root pinned in
455/// `.auths/roots`. No `.auths/allowed_signers`, no `ssh-keygen` allowlist, no
456/// `git verify-commit --raw` shell-out.
457async fn verify_commit_in_process(sha: &str) -> bool {
458    // Open the repository
459    let repo = match git2::Repository::discover(".") {
460        Ok(r) => r,
461        Err(e) => {
462            if !is_json_mode() {
463                eprintln!("Failed to open git repository: {e}");
464            }
465            return false;
466        }
467    };
468
469    // Parse the commit SHA
470    let oid = match git2::Oid::from_str(sha) {
471        Ok(o) => o,
472        Err(e) => {
473            if !is_json_mode() {
474                eprintln!("Invalid commit SHA '{}': {e}", &sha[..8.min(sha.len())]);
475            }
476            return false;
477        }
478    };
479
480    // The SSH signature is computed over the commit object's EXACT bytes
481    // (what `git cat-file commit <sha>` prints). Read them from the object
482    // database — never reconstruct them by joining header/message strings;
483    // a single byte of drift makes a valid signature unverifiable.
484    let commit_content = match raw_commit_bytes(&repo, oid) {
485        Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
486        Err(e) => {
487            if !is_json_mode() {
488                eprintln!("Commit {} not found: {e}", &sha[..8.min(sha.len())]);
489            }
490            return false;
491        }
492    };
493
494    // KEL-native trust: the commit's signer must be a device delegated under a root
495    // pinned in `.auths/roots`. The verdict logic lives in the SDK commit-trust resolver.
496    let provider = auths_crypto::RingCryptoProvider;
497    let auths_home = match auths_sdk::paths::auths_home() {
498        Ok(h) => h,
499        Err(e) => {
500            if !is_json_mode() {
501                eprintln!("Could not locate ~/.auths: {e}");
502            }
503            return false;
504        }
505    };
506    let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
507        auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
508    );
509    let pinned_roots = crate::commands::verify_helpers::load_project_pinned_roots();
510
511    let short = &sha[..8.min(sha.len())];
512    match auths_sdk::workflows::commit_trust::verify_commit_local(
513        &registry,
514        &pinned_roots,
515        commit_content.as_bytes(),
516        &provider,
517    )
518    .await
519    {
520        Ok(verdict) if verdict.is_valid() => true,
521        Ok(verdict) => {
522            if !is_json_mode() {
523                eprintln!("Commit {short} is not authorized by a pinned trusted root: {verdict:?}");
524            }
525            false
526        }
527        Err(e) => {
528            if !is_json_mode() {
529                eprintln!("Commit {short} trust could not be resolved: {e}");
530            }
531            false
532        }
533    }
534}
535
536/// Verify an air-gapped org bundle entirely offline (zero network), fail-closed.
537///
538/// Reads the fn-154.5 bundle, loads the verifier's pinned roots (from `roots` or the
539/// default `.auths/roots`, falling back to the bundle's declared roots if neither
540/// exists), and classifies the optional `member` at `signed_at` purely from the
541/// bundle's KEL contents. Exits non-zero on any non-authorized verdict so it can gate
542/// CI.
543///
544/// Args:
545/// * `file`: Path to the air-gapped bundle (`auths org bundle` output).
546/// * `roots`: Optional pinned-roots file (default `.auths/roots`).
547/// * `member`: Optional member `did:keri` to classify authority for.
548/// * `signed_at`: Optional in-band signing KEL position for the member's artifact.
549/// * `json`: Emit the typed report as JSON.
550///
551/// Usage:
552/// ```ignore
553/// handle_offline_verify(Path::new("acme.auths-offline"), None, None, None, false)?;
554/// ```
555pub fn handle_offline_verify(
556    file: &Path,
557    roots: Option<&Path>,
558    member: Option<&str>,
559    signed_at: Option<u128>,
560    json: bool,
561) -> Result<()> {
562    use auths_sdk::workflows::org::{AirGappedOrgBundle, AuthorityAtSigning, verify_org_bundle};
563    use auths_sdk::workflows::roots::parse_roots_typed;
564    use auths_verifier::Prefix;
565    use auths_verifier::types::IdentityDID;
566
567    let bundle_json =
568        fs::read_to_string(file).with_context(|| format!("Failed to read bundle file {file:?}"))?;
569    let bundle = AirGappedOrgBundle::from_json(&bundle_json)
570        .context("Failed to parse air-gapped org bundle")?;
571
572    let roots_path = roots
573        .map(Path::to_path_buf)
574        .unwrap_or_else(|| PathBuf::from(".auths/roots"));
575    let pinned_roots: Vec<IdentityDID> = if roots_path.exists() {
576        let content = fs::read_to_string(&roots_path)
577            .with_context(|| format!("Failed to read roots file {roots_path:?}"))?;
578        parse_roots_typed(&content).context("Failed to parse pinned roots")?
579    } else {
580        // No verifier-side roots configured — trust the bundle's declared roots
581        // (trust-on-first-use). Supply --roots to pin explicitly.
582        bundle.pinned_roots.clone()
583    };
584
585    let member_prefix =
586        member.map(|m| Prefix::new_unchecked(m.strip_prefix("did:keri:").unwrap_or(m).to_string()));
587    let query = member_prefix.as_ref().map(|p| (p, signed_at));
588
589    let report =
590        verify_org_bundle(&bundle, &pinned_roots, query).context("Offline verification failed")?;
591
592    if json {
593        println!("{}", serde_json::to_string_pretty(&report)?);
594    } else {
595        println!("Air-gapped verification of {file:?}");
596        println!("  Org:            {}", report.org_did.as_str());
597        println!(
598            "  Verified as-of: KEL seq {} (by position, not wall-clock)",
599            report.as_of_org_seq
600        );
601        let root = if report.root_pinned {
602            "✅ yes"
603        } else {
604            "🛑 NO (untrusted root)"
605        };
606        println!("  Root pinned:    {root}");
607        let dup = if report.duplicity_detected {
608            "🛑 DETECTED"
609        } else {
610            "✅ none"
611        };
612        println!("  Duplicity:      {dup}");
613        if let Some(authority) = &report.authority {
614            match authority {
615                AuthorityAtSigning::AuthorizedBeforeRevocation => {
616                    println!("  Authority:      ✅ AuthorizedBeforeRevocation")
617                }
618                AuthorityAtSigning::RejectedAfterRevocation { revoked_at } => {
619                    println!(
620                        "  Authority:      🛑 RejectedAfterRevocation {{ revoked_at: {revoked_at} }}"
621                    )
622                }
623                AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at } => {
624                    println!(
625                        "  Authority:      🛑 RejectedRevokedPositionUnknown {{ revoked_at: {revoked_at} }}"
626                    )
627                }
628                AuthorityAtSigning::NeverDelegated => {
629                    println!("  Authority:      ❌ NeverDelegated")
630                }
631            }
632        }
633    }
634
635    // Fail-closed exit: anything short of a trusted, non-duplicitous, authorized
636    // verdict is a hard failure (so CI gates reject it).
637    if !report.root_pinned {
638        return Err(anyhow!(
639            "unauthorized: the bundle's org is not in the pinned trust roots"
640        ));
641    }
642    if report.duplicity_detected {
643        return Err(anyhow!(
644            "org KEL duplicity detected — divergent history; resolve before trusting"
645        ));
646    }
647    match report.authority {
648        None | Some(AuthorityAtSigning::AuthorizedBeforeRevocation) => Ok(()),
649        Some(AuthorityAtSigning::RejectedAfterRevocation { revoked_at }) => Err(anyhow!(
650            "unauthorized: signed at/after revocation (KEL seq {revoked_at})"
651        )),
652        Some(AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at }) => Err(anyhow!(
653            "unauthorized: member revoked at KEL seq {revoked_at}; artifact has no in-band signing position"
654        )),
655        Some(AuthorityAtSigning::NeverDelegated) => {
656            Err(anyhow!("unauthorized: the org never delegated this member"))
657        }
658    }
659}
660
661/// The raw commit object bytes, exactly as `git cat-file commit <oid>` prints
662/// them — the payload an SSH commit signature is computed over.
663///
664/// Args:
665/// * `repo`: An open git repository.
666/// * `oid`: The commit's object id.
667///
668/// Usage:
669/// ```ignore
670/// let bytes = raw_commit_bytes(&repo, oid)?;
671/// ```
672pub(crate) fn raw_commit_bytes(repo: &git2::Repository, oid: git2::Oid) -> Result<Vec<u8>> {
673    let odb = repo.odb().context("open git object database")?;
674    let obj = odb.read(oid).context("read commit object")?;
675    Ok(obj.data().to_vec())
676}
677
678#[cfg(test)]
679mod tests {
680    use super::raw_commit_bytes;
681    use std::process::Command;
682
683    /// Regression: the bytes the verifier checks the SSH signature over must
684    /// be byte-identical to `git cat-file commit`. A prior implementation
685    /// reconstructed them from raw_header + "\n\n" + message, drifting by one
686    /// newline and making every valid signature report SshSignatureInvalid.
687    #[test]
688    fn raw_commit_bytes_matches_git_cat_file() {
689        let (dir, repo) = auths_test_utils::git::init_test_repo();
690        let sig = git2::Signature::now("t", "t@example.com").expect("sig");
691        let tree_id = {
692            let mut index = repo.index().expect("index");
693            index.write_tree().expect("tree")
694        };
695        let tree = repo.find_tree(tree_id).expect("find tree");
696        let oid = repo
697            .commit(
698                Some("HEAD"),
699                &sig,
700                &sig,
701                "subject line\n\nbody with trailing newline drift potential\n",
702                &tree,
703                &[],
704            )
705            .expect("commit");
706
707        let via_helper = raw_commit_bytes(&repo, oid).expect("helper");
708        let via_git = Command::new("git")
709            .args(["cat-file", "commit", &oid.to_string()])
710            .current_dir(dir.path())
711            .output()
712            .expect("git cat-file");
713        assert!(via_git.status.success());
714        assert_eq!(
715            via_helper, via_git.stdout,
716            "verifier payload must be byte-identical to git cat-file commit"
717        );
718    }
719}