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