vpr_core/
versioned_files.rs

1//! Versioned file operations with Git-based version control for VPR.
2//!
3//! VPR stores patient data as files on disk and versions each patient directory using a
4//! local Git repository (`git2`/libgit2). This module provides high-level services for
5//! managing versioned files, ensuring:
6//!
7//! - **Atomic Multi-file Operations**: Write multiple files and commit them in a single
8//!   transaction with automatic rollback on failure
9//! - **Consistent Commit Creation**: Structured commit messages with controlled vocabulary
10//!   across all services (clinical, demographics, coordination)
11//! - **Cryptographic Signing**: ECDSA P-256 signatures with X.509 certificate validation
12//! - **Immutable Audit Trail**: Nothing is ever deleted; all changes are preserved in
13//!   version control history for patient safety and legal compliance
14//!
15//! ## Purpose and Scope
16//!
17//! This module is the core of VPR's version control system. It centralises Git operations
18//! to ensure consistency and safety when modifying patient records. The [`VersionedFileService`]
19//! provides high-level operations that handle directory creation, file writing, Git commits,
20//! and automatic rollback on errors.
21//!
22//! The module supports both signed (with X.509 certificates) and unsigned commits, with
23//! signature verification available for auditing purposes.
24//!
25//! ## Architecture
26//!
27//! The module provides four main components:
28//!
29//! - **File Operations**: [`FileToWrite`] struct for describing atomic file write operations
30//! - **Repository Management**: [`VersionedFileService`] for high-level Git operations
31//! - **Commit Messages**: [`VprCommitMessage`] with structured domains and actions
32//! - **Cryptographic Signing**: ECDSA P-256 signature creation and verification
33//!
34//! ## Branch Policy
35//!
36//! VPR standardises on `refs/heads/main` for all patient repositories.
37//!
38//! libgit2's `commit_signed` creates a commit object but **does not update refs** (no branch
39//! movement and no `HEAD` update). For signed commits, this module explicitly updates
40//! `refs/heads/main` and points `HEAD` to it to maintain proper branch state.
41//!
42//! ## Signature Format
43//!
44//! When `Author.signature` is present, VPR signs commits using ECDSA P-256.
45//!
46//! - Signed payload: the *unsigned commit buffer* produced by `Repository::commit_create_buffer`
47//! - Signature bytes: raw 64 bytes (`r || s`, not DER)
48//! - Stored form: base64 of a deterministic JSON container passed to `commit_signed` and
49//!   written into the commit header field `gpgsig`
50//!
51//! The container embeds:
52//! - `signature`: base64 of raw 64-byte `r||s`
53//! - `public_key`: base64 of SEC1-encoded public key bytes
54//! - `certificate` (optional): base64 of the certificate bytes (PEM or DER)
55//!
56//! ## Safety and Immutability
57//!
58//! VPR maintains an immutable audit trail where nothing is ever truly deleted. The
59//! [`VprCommitAction`] enum documents the four allowed operations (Create, Update,
60//! Superseded, Redact), all of which preserve historical data in version control.
61//! This design ensures patient safety, legal compliance, and complete accountability
62//! for all modifications to patient records.
63//!
64//! The verifier in clinical code (`ClinicalService::verify_commit_signature`) expects this
65//! exact scheme.
66
67use crate::author::Author;
68use crate::error::{PatientError, PatientResult};
69use crate::NonEmptyText;
70use crate::ShardableUuid;
71use base64::{engine::general_purpose, Engine as _};
72use p256::ecdsa::signature::{Signer, Verifier};
73use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
74use p256::pkcs8::{DecodePrivateKey, DecodePublicKey};
75use serde::{Deserialize, Serialize};
76use std::fmt;
77use std::fs;
78use std::path::{Path, PathBuf};
79use x509_parser::prelude::*;
80
81#[cfg(test)]
82use std::collections::HashSet;
83#[cfg(test)]
84use std::sync::{LazyLock, Mutex};
85
86const MAIN_REF: &str = "refs/heads/main";
87
88/// Deterministic container for VPR commit signatures.
89///
90/// This struct holds the cryptographic components of a VPR commit signature.
91/// It is serialized to JSON with a stable field order (struct order), then base64-encoded
92/// and stored as the `gpgsig` header value via `git2::Repository::commit_signed`.
93///
94/// The container ensures that all signature metadata is embedded directly in the Git commit,
95/// making signatures self-contained and verifiable without external dependencies.
96#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
97struct VprCommitSignaturePayloadV1 {
98    /// Base64 of raw 64-byte ECDSA P-256 signature (`r || s`).
99    signature: String,
100    /// Base64 of SEC1-encoded public key bytes.
101    public_key: String,
102    /// Base64 of X.509 certificate bytes (PEM or DER), if provided.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    certificate: Option<String>,
105}
106
107/// Extract the SEC1-encoded public key bytes from an X.509 certificate.
108///
109/// Accepts both PEM and DER certificate formats. The certificate is parsed to extract
110/// the public key, which is then encoded in SEC1 format for use with ECDSA verification.
111///
112/// This function is used during commit signing to validate that the author's certificate
113/// matches their signing key.
114///
115/// # Arguments
116///
117/// * `cert_bytes` - Raw certificate bytes (PEM or DER format)
118///
119/// # Returns
120///
121/// SEC1-encoded public key bytes on success.
122///
123/// # Errors
124///
125/// Returns `PatientError::EcdsaPublicKeyParse` if the certificate cannot be parsed
126/// or the public key cannot be extracted.
127fn extract_cert_public_key_sec1(cert_bytes: &[u8]) -> PatientResult<Vec<u8>> {
128    // Accept PEM or DER; treat as opaque bytes for storage but parse to validate key match.
129    let cert_der: Vec<u8> = if cert_bytes
130        .windows("-----BEGIN CERTIFICATE-----".len())
131        .any(|w| w == b"-----BEGIN CERTIFICATE-----")
132    {
133        let (_, pem) = x509_parser::pem::parse_x509_pem(cert_bytes)
134            .map_err(|e| PatientError::EcdsaPublicKeyParse(Box::new(e)))?;
135        pem.contents.to_vec()
136    } else {
137        cert_bytes.to_vec()
138    };
139
140    let (_, cert) = X509Certificate::from_der(cert_der.as_slice())
141        .map_err(|e| PatientError::EcdsaPublicKeyParse(Box::new(e)))?;
142
143    let spk = cert.public_key();
144    Ok(spk.subject_public_key.data.to_vec())
145}
146
147/// Clinical domain categories for commit messages.
148///
149/// These represent different types of clinical data being modified.
150#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
151#[serde(rename_all = "lowercase")]
152pub(crate) enum ClinicalDomain {
153    Record,
154    Observation,
155    Diagnosis,
156    Treatment,
157    Administration,
158    Correction,
159    Metadata,
160}
161
162impl ClinicalDomain {
163    #[allow(dead_code)]
164    pub(crate) const fn as_str(self) -> &'static str {
165        match self {
166            Self::Record => "record",
167            Self::Observation => "observation",
168            Self::Diagnosis => "diagnosis",
169            Self::Treatment => "treatment",
170            Self::Administration => "administration",
171            Self::Correction => "correction",
172            Self::Metadata => "metadata",
173        }
174    }
175}
176
177/// Coordination domain categories for commit messages.
178///
179/// These represent different types of care coordination activities.
180#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
181#[serde(rename_all = "lowercase")]
182pub(crate) enum CoordinationDomain {
183    Record,
184    Messaging,
185}
186
187impl CoordinationDomain {
188    #[allow(dead_code)]
189    pub(crate) const fn as_str(self) -> &'static str {
190        match self {
191            Self::Record => "record",
192            Self::Messaging => "messaging",
193        }
194    }
195}
196
197/// Demographics domain categories for commit messages.
198///
199/// These represent different types of demographic data being modified.
200#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
201#[serde(rename_all = "lowercase")]
202pub(crate) enum DemographicsDomain {
203    Record,
204}
205
206impl DemographicsDomain {
207    #[allow(dead_code)]
208    pub(crate) const fn as_str(self) -> &'static str {
209        match self {
210            Self::Record => "record",
211        }
212    }
213}
214
215/// Controlled vocabulary for VPR commit message domains.
216///
217/// Hierarchical structure organizing commits by repository type (Clinical, Coordination, Demographics)
218/// and specific domain within that repository.
219///
220/// Safety/intent: Do not include patient identifiers or raw clinical data in commit messages.
221#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
222pub(crate) enum VprCommitDomain {
223    Clinical(ClinicalDomain),
224    Coordination(CoordinationDomain),
225    Demographics(DemographicsDomain),
226}
227
228impl VprCommitDomain {
229    pub(crate) const fn as_str(self) -> &'static str {
230        match self {
231            Self::Clinical(subdomain) => subdomain.as_str(),
232            Self::Coordination(subdomain) => subdomain.as_str(),
233            Self::Demographics(subdomain) => subdomain.as_str(),
234        }
235    }
236}
237
238impl fmt::Display for VprCommitDomain {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        f.write_str(self.as_str())
241    }
242}
243
244impl Serialize for VprCommitDomain {
245    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
246    where
247        S: serde::Serializer,
248    {
249        serializer.serialize_str(self.as_str())
250    }
251}
252
253impl<'de> Deserialize<'de> for VprCommitDomain {
254    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
255    where
256        D: serde::Deserializer<'de>,
257    {
258        let s = String::deserialize(deserializer)?;
259        match s.as_str() {
260            "record" => Ok(Self::Clinical(ClinicalDomain::Record)),
261            "observation" => Ok(Self::Clinical(ClinicalDomain::Observation)),
262            "diagnosis" => Ok(Self::Clinical(ClinicalDomain::Diagnosis)),
263            "treatment" => Ok(Self::Clinical(ClinicalDomain::Treatment)),
264            "administration" => Ok(Self::Clinical(ClinicalDomain::Administration)),
265            "correction" => Ok(Self::Clinical(ClinicalDomain::Correction)),
266            "metadata" => Ok(Self::Clinical(ClinicalDomain::Metadata)),
267            "messaging" => Ok(Self::Coordination(CoordinationDomain::Messaging)),
268            _ => Err(serde::de::Error::unknown_variant(
269                &s,
270                &[
271                    "record",
272                    "observation",
273                    "diagnosis",
274                    "treatment",
275                    "administration",
276                    "correction",
277                    "metadata",
278                    "messaging",
279                ],
280            )),
281        }
282    }
283}
284
285/// Controlled vocabulary for VPR commit message actions.
286///
287/// These define the specific operation being performed on patient data.
288/// Actions are designed to be machine-readable and support audit trails.
289///
290/// # Immutability Philosophy
291///
292/// VPR maintains an **immutable audit trail** - nothing is ever truly deleted from the
293/// version control history. This ensures complete auditability and supports patient safety
294/// by preserving all changes made to a record.
295///
296/// # Commit Actions
297///
298/// - **`Create`**: Used when adding new content to an existing record (e.g., creating a new
299///   letter, adding a new observation, initializing a new patient record). This is the
300///   most common action for new data entry.
301///
302/// - **`Update`**: Used when modifying existing content (e.g., correcting a typo in a letter,
303///   updating demographics, linking records). The previous version remains in Git history.
304///
305/// - **`Superseded`**: Used when newer information makes previous content obsolete
306///   (e.g., a revised diagnosis, an updated care plan). The superseded content remains
307///   in history but is marked as no longer current. This is distinct from `Update` as it
308///   represents a clinical decision that previous information should be replaced rather
309///   than corrected.
310///
311/// - **`Redact`**: Used when data was entered into the wrong patient's repository
312///   by mistake (can occur in clinical, demographics, or coordination repositories).
313///   The data is removed from the current view, encrypted, and stored in the
314///   Redaction Retention Repository with a tombstone/pointer remaining in the original
315///   repository's Git history. This maintains audit trail integrity while protecting
316///   patient privacy. **This is the only action that removes data from active view**,
317///   but even redacted data is preserved in secure storage for audit purposes.
318///
319/// # What VPR Never Does
320///
321/// VPR **never deletes data** from the version control history. Even redacted data is
322/// moved to secure storage rather than destroyed. This immutability is fundamental to:
323///
324/// - Patient safety: all changes are traceable
325/// - Legal compliance: complete audit trail preservation
326/// - Clinical governance: accountability for all modifications
327/// - Research and quality improvement: historical data remains available for authorized use
328///
329/// # Safety/Intent
330///
331/// Do not include patient identifiers or raw clinical data in commit messages.
332/// Use structured trailers for metadata only.
333#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
334#[serde(rename_all = "lowercase")]
335pub(crate) enum VprCommitAction {
336    Create,
337    Update,
338    Superseded,
339    Redact,
340}
341
342impl VprCommitAction {
343    pub(crate) const fn as_str(self) -> &'static str {
344        match self {
345            Self::Create => "create",
346            Self::Update => "update",
347            Self::Superseded => "superseded",
348            Self::Redact => "redact",
349        }
350    }
351}
352
353impl fmt::Display for VprCommitAction {
354    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355        f.write_str(self.as_str())
356    }
357}
358
359/// A single commit trailer line in standard Git trailer format.
360///
361/// Renders as `Key: Value`. Trailers provide additional structured metadata
362/// beyond the main commit subject line. They follow Git's standard trailer
363/// conventions and are sorted deterministically in rendered output.
364#[derive(Clone, Debug, Eq, PartialEq, Hash)]
365pub(crate) struct VprCommitTrailer {
366    key: NonEmptyText,
367    value: NonEmptyText,
368}
369
370impl VprCommitTrailer {
371    /// Create a new commit trailer with validation.
372    ///
373    /// # Arguments
374    ///
375    /// * `key` - Trailer key (cannot contain ':', newlines, or be empty)
376    /// * `value` - Trailer value (cannot contain newlines or be empty)
377    ///
378    /// # Errors
379    ///
380    /// Returns `PatientError::InvalidInput` if validation fails.
381    #[allow(dead_code)]
382    pub(crate) fn new(key: impl Into<String>, value: impl Into<String>) -> PatientResult<Self> {
383        let key_str = key.into().trim().to_string();
384        let value_str = value.into().trim().to_string();
385
386        if key_str.is_empty()
387            || key_str.contains(['\n', '\r'])
388            || key_str.contains(':')
389            || value_str.is_empty()
390            || value_str.contains(['\n', '\r'])
391        {
392            return Err(PatientError::InvalidInput(
393                "commit trailer key/value must be non-empty and single-line (key cannot contain ':')".into()
394            ));
395        }
396
397        let key = NonEmptyText::new(key_str).map_err(|_| {
398            PatientError::InvalidInput("commit trailer key must be non-empty".into())
399        })?;
400        let value = NonEmptyText::new(value_str).map_err(|_| {
401            PatientError::InvalidInput("commit trailer value must be non-empty".into())
402        })?;
403
404        Ok(Self { key, value })
405    }
406
407    /// Get the trailer key.
408    pub(crate) fn key(&self) -> &str {
409        self.key.as_str()
410    }
411
412    /// Get the trailer value.
413    pub(crate) fn value(&self) -> &str {
414        self.value.as_str()
415    }
416}
417
418/// A structured, predictable VPR commit message.
419///
420/// Rendering rules:
421///
422/// - Subject line: `<domain>:<action>: <summary>`
423/// - Trailers (optional): standard Git trailer lines `Key: Value`
424/// - If there are trailers, a single blank line separates subject from trailers.
425/// - No free-form prose paragraphs.
426///
427/// Safety/intent: Commit messages are labels and indexes; do not include patient identifiers or
428/// raw clinical data.
429#[derive(Clone, Debug, Eq, PartialEq)]
430pub(crate) struct VprCommitMessage {
431    domain: VprCommitDomain,
432    action: VprCommitAction,
433    summary: NonEmptyText,
434    care_location: NonEmptyText,
435    trailers: Vec<VprCommitTrailer>,
436}
437
438impl VprCommitMessage {
439    /// Create a new commit message with required fields.
440    ///
441    /// # Arguments
442    ///
443    /// * `domain` - The category of change (e.g., Record, Obs, Dx)
444    /// * `action` - The specific operation (e.g., Init, Add, Update)
445    /// * `summary` - Brief description of the change (single line, no newlines)
446    /// * `care_location` - Where the change occurred (e.g., "St Elsewhere Hospital")
447    ///
448    /// # Errors
449    ///
450    /// Returns `PatientError::InvalidInput` if summary contains newlines or is empty.
451    /// Returns `PatientError::MissingCareLocation` if care_location is empty.
452    /// Returns `PatientError::InvalidCareLocation` if care_location contains newlines.
453    #[allow(dead_code)]
454    pub(crate) fn new(
455        domain: VprCommitDomain,
456        action: VprCommitAction,
457        summary: impl AsRef<str>,
458        care_location: impl AsRef<str>,
459    ) -> PatientResult<Self> {
460        let summary_str = summary.as_ref().trim();
461        if summary_str.contains(['\n', '\r']) {
462            return Err(PatientError::InvalidInput(
463                "commit summary must be single-line".into(),
464            ));
465        }
466        let summary = NonEmptyText::new(summary_str)
467            .map_err(|_| PatientError::InvalidInput("commit summary must be non-empty".into()))?;
468
469        let care_location_str = care_location.as_ref().trim();
470        if care_location_str.contains(['\n', '\r']) {
471            return Err(PatientError::InvalidCareLocation);
472        }
473        let care_location =
474            NonEmptyText::new(care_location_str).map_err(|_| PatientError::MissingCareLocation)?;
475
476        Ok(Self {
477            domain,
478            action,
479            summary,
480            care_location,
481            trailers: Vec::new(),
482        })
483    }
484
485    /// Add a trailer to the commit message.
486    ///
487    /// Trailers provide additional structured metadata. Certain trailer keys
488    /// are reserved (Author-* and Care-Location) and cannot be set manually.
489    ///
490    /// # Arguments
491    ///
492    /// * `key` - Trailer key (cannot contain ':', newlines, or be reserved)
493    /// * `value` - Trailer value (cannot contain newlines or be empty)
494    ///
495    /// # Errors
496    ///
497    /// Returns `PatientError::ReservedAuthorTrailerKey` for Author-* keys.
498    /// Returns `PatientError::ReservedCareLocationTrailerKey` for Care-Location key.
499    /// Returns `PatientError::InvalidInput` for invalid key/value format.
500    #[allow(dead_code)]
501    pub(crate) fn with_trailer(
502        mut self,
503        key: impl Into<String>,
504        value: impl Into<String>,
505    ) -> PatientResult<Self> {
506        let key_str = key.into();
507        if key_str.trim_start().starts_with("Author-") {
508            return Err(PatientError::ReservedAuthorTrailerKey);
509        }
510        if key_str.trim() == "Care-Location" {
511            return Err(PatientError::ReservedCareLocationTrailerKey);
512        }
513        self.trailers
514            .push(VprCommitTrailer::new(key_str, value.into())?);
515        Ok(self)
516    }
517
518    /// Get the commit domain.
519    #[allow(dead_code)]
520    pub(crate) fn domain(&self) -> VprCommitDomain {
521        self.domain
522    }
523
524    /// Get the commit action.
525    #[allow(dead_code)]
526    pub(crate) fn action(&self) -> VprCommitAction {
527        self.action
528    }
529
530    /// Get the commit summary.
531    #[allow(dead_code)]
532    pub(crate) fn summary(&self) -> &str {
533        self.summary.as_str()
534    }
535
536    /// Get the commit trailers.
537    #[allow(dead_code)]
538    pub(crate) fn trailers(&self) -> &[VprCommitTrailer] {
539        &self.trailers
540    }
541
542    /// Render the commit message without author information.
543    ///
544    /// Produces a standard Git commit message format with subject line and trailers.
545    /// Does not include Author-* trailers (use `render_with_author` for that).
546    ///
547    /// # Returns
548    ///
549    /// A properly formatted Git commit message string.
550    ///
551    /// # Errors
552    ///
553    /// Returns `PatientError` if the message contains invalid data (should not happen
554    /// if constructed via `new()` and `with_trailer()`).
555    #[allow(dead_code)]
556    pub(crate) fn render(&self) -> PatientResult<String> {
557        // With NonEmptyText, validation is enforced at construction time
558        let mut rendered = format!("{}:{}: {}", self.domain, self.action, self.summary.as_str());
559
560        // Sort non-reserved trailers deterministically.
561        let mut other = self.trailers.clone();
562        other.sort_by(|a, b| {
563            let a_key = (a.key().trim(), a.value().trim());
564            let b_key = (b.key().trim(), b.value().trim());
565            a_key.cmp(&b_key)
566        });
567
568        rendered.push_str("\n\n");
569        rendered.push_str("Care-Location: ");
570        rendered.push_str(self.care_location.as_str());
571
572        for trailer in other {
573            if trailer.key().contains(['\n', '\r'])
574                || trailer.key().trim().is_empty()
575                || trailer.key().contains(':')
576                || trailer.value().contains(['\n', '\r'])
577            {
578                return Err(PatientError::InvalidInput(
579                    "commit trailer key/value must be non-empty and single-line (key cannot contain ':')".into()
580                ));
581            }
582
583            // Author trailers are rendered via `render_with_author` only.
584            if trailer.key().trim_start().starts_with("Author-") {
585                return Err(PatientError::ReservedAuthorTrailerKey);
586            }
587
588            // Care-Location is rendered via `with_care_location` only.
589            if trailer.key().trim() == "Care-Location" {
590                return Err(PatientError::ReservedCareLocationTrailerKey);
591            }
592
593            rendered.push('\n');
594            rendered.push_str(trailer.key().trim());
595            rendered.push_str(": ");
596            rendered.push_str(trailer.value().trim());
597        }
598
599        Ok(rendered)
600    }
601
602    /// Render a commit message including mandatory Author trailers.
603    ///
604    /// The Author trailers are rendered deterministically in the order:
605    ///
606    /// - `Author-Name`
607    /// - `Author-Role`
608    /// - `Author-Registration` (0..N; sorted)
609    ///
610    /// This is the method used by `VersionedFileService` for creating commits.
611    ///
612    /// # Arguments
613    ///
614    /// * `author` - Author information including name, role, and registrations
615    ///
616    /// # Returns
617    ///
618    /// A complete Git commit message with author metadata.
619    ///
620    /// # Errors
621    ///
622    /// Returns `PatientError` from author validation or message rendering.
623    pub(crate) fn render_with_author(&self, author: &Author) -> PatientResult<String> {
624        author.validate_commit_author()?;
625
626        // Author trailers are reserved and must only be emitted from the structured metadata.
627        if self
628            .trailers
629            .iter()
630            .any(|t| t.key().trim_start().starts_with("Author-"))
631        {
632            return Err(PatientError::ReservedAuthorTrailerKey);
633        }
634
635        // Care-Location is reserved and must only be emitted from the structured metadata.
636        if self
637            .trailers
638            .iter()
639            .any(|t| t.key().trim() == "Care-Location")
640        {
641            return Err(PatientError::ReservedCareLocationTrailerKey);
642        }
643
644        // With NonEmptyText, validation is enforced at construction time
645        let mut rendered = format!("{}:{}: {}", self.domain, self.action, self.summary.as_str());
646
647        // Sort registrations deterministically, but do not require selecting a primary authority.
648        let mut regs = author.registrations.clone();
649        regs.sort_by(|a, b| {
650            let a_key = (a.authority.as_str(), a.number.as_str());
651            let b_key = (b.authority.as_str(), b.number.as_str());
652            a_key.cmp(&b_key)
653        });
654
655        // Sort non-author trailers deterministically.
656        let mut other = self.trailers.clone();
657        other.sort_by(|a, b| {
658            let a_key = (a.key().trim(), a.value().trim());
659            let b_key = (b.key().trim(), b.value().trim());
660            a_key.cmp(&b_key)
661        });
662
663        rendered.push_str("\n\n");
664        rendered.push_str("Author-Name: ");
665        rendered.push_str(author.name.as_str());
666        rendered.push('\n');
667        rendered.push_str("Author-Role: ");
668        rendered.push_str(author.role.as_str());
669
670        for reg in regs {
671            rendered.push('\n');
672            rendered.push_str("Author-Registration: ");
673            rendered.push_str(reg.authority.as_str());
674            rendered.push(' ');
675            rendered.push_str(reg.number.as_str());
676        }
677
678        rendered.push('\n');
679        rendered.push_str("Care-Location: ");
680        rendered.push_str(self.care_location.as_str());
681
682        for trailer in other {
683            rendered.push('\n');
684            rendered.push_str(trailer.key().trim());
685            rendered.push_str(": ");
686            rendered.push_str(trailer.value().trim());
687        }
688
689        Ok(rendered)
690    }
691}
692
693/// Service for common Git operations on a repository rooted at `workdir`.
694///
695/// This bundles the repository handle and its workdir to make workflows like “initialise repo
696/// then commit files” ergonomic at call sites.
697/// Represents a file to be written and committed.
698///
699/// Used with [`VersionedFileService::write_and_commit_files`] to write multiple files
700/// in a single atomic commit operation.
701#[derive(Debug, Clone)]
702pub struct FileToWrite<'a> {
703    /// The relative path to the file within the repository directory.
704    pub relative_path: &'a Path,
705    /// The new content to write to the file.
706    pub content: &'a str,
707    /// The previous file content for rollback. `None` if this is a new file.
708    pub old_content: Option<&'a str>,
709}
710
711/// Service for managing versioned files with Git version control.
712///
713/// `VersionedFileService` provides high-level operations for working with Git repositories
714/// in VPR's patient record system. It handles atomic file write and commit operations with
715/// automatic rollback on failure, ensuring data consistency and integrity.
716///
717/// The service supports both signed commits (using ECDSA P-256 with X.509 certificates)
718/// and unsigned commits. All commits use structured [`VprCommitMessage`] format for
719/// consistency and auditability.
720///
721/// # Core Capabilities
722///
723/// - **Atomic Operations**: Write multiple files and commit in a single transaction
724/// - **Automatic Rollback**: Restore previous state if any operation fails
725/// - **Directory Creation**: Automatically create parent directories as needed
726/// - **Signed Commits**: Optional cryptographic signing with X.509 certificates
727/// - **Signature Verification**: Verify commit signatures for audit purposes
728///
729/// # Usage Pattern
730///
731/// The typical workflow is:
732/// 1. Create or open a repository with [`init`](Self::init) or [`open`](Self::open)
733/// 2. Prepare file changes using [`FileToWrite`] structs
734/// 3. Write and commit files with [`write_and_commit_files`](Self::write_and_commit_files)
735/// 4. Optionally verify signatures with [`verify_commit_signature`](Self::verify_commit_signature)
736pub struct VersionedFileService {
737    repo: git2::Repository,
738    workdir: PathBuf,
739}
740
741impl VersionedFileService {
742    /// Create a new Git repository at the specified working directory.
743    ///
744    /// Initialises a new Git repository using libgit2. The repository will be created
745    /// with a standard `.git` directory structure. The working directory path is captured
746    /// and used for all subsequent file operations.
747    ///
748    /// # Arguments
749    ///
750    /// * `workdir` - Path where the Git repository should be initialised
751    ///
752    /// # Returns
753    ///
754    /// A `VersionedFileService` instance bound to the newly created repository.
755    ///
756    /// # Errors
757    ///
758    /// Returns `PatientError` if:
759    /// - Repository initialisation fails (e.g., permissions, invalid path) - [`PatientError::GitInit`]
760    /// - The repository has no working directory (bare repo) - [`PatientError::GitInit`]
761    pub(crate) fn init(workdir: &Path) -> PatientResult<Self> {
762        let repo = git2::Repository::init(workdir).map_err(PatientError::GitInit)?;
763        // Use the actual workdir from the repository to ensure path stripping works correctly.
764        let actual_workdir = repo
765            .workdir()
766            .ok_or_else(|| {
767                PatientError::GitInit(git2::Error::from_str("repository has no working directory"))
768            })?
769            .to_path_buf();
770        Ok(Self {
771            repo,
772            workdir: actual_workdir,
773        })
774    }
775
776    /// Open an existing Git repository at the specified working directory.
777    ///
778    /// Opens an existing repository using `NO_SEARCH` flag to prevent git2 from searching
779    /// parent directories for a `.git` folder. This ensures we open exactly the repository
780    /// at the specified path, which is important for patient record isolation.
781    ///
782    /// The actual working directory path is extracted from the opened repository to handle
783    /// potential symlink resolution or path canonicalisation by git2.
784    ///
785    /// # Arguments
786    ///
787    /// * `workdir` - Path to the existing Git repository's working directory
788    ///
789    /// # Returns
790    ///
791    /// A `VersionedFileService` instance bound to the opened repository.
792    ///
793    /// # Errors
794    ///
795    /// Returns `PatientError` if:
796    /// - Repository does not exist at the specified path - [`PatientError::GitOpen`]
797    /// - Repository cannot be opened (e.g., permissions, corruption) - [`PatientError::GitOpen`]
798    /// - The repository has no working directory (bare repo) - [`PatientError::GitOpen`]
799    pub(crate) fn open(workdir: &Path) -> PatientResult<Self> {
800        let repo = git2::Repository::open_ext(
801            workdir,
802            git2::RepositoryOpenFlags::NO_SEARCH,
803            std::iter::empty::<&std::ffi::OsStr>(),
804        )
805        .map_err(PatientError::GitOpen)?;
806        // Use the actual workdir from the repository to ensure path stripping works correctly.
807        // git2 may resolve symlinks or canonicalize paths differently.
808        let actual_workdir = repo
809            .workdir()
810            .ok_or_else(|| {
811                PatientError::GitOpen(git2::Error::from_str("repository has no working directory"))
812            })?
813            .to_path_buf();
814        Ok(Self {
815            repo,
816            workdir: actual_workdir,
817        })
818    }
819
820    /// Consume this service and return the underlying `git2::Repository`.
821    ///
822    /// This method transfers ownership of the Git repository handle to the caller,
823    /// allowing direct access to lower-level Git operations when needed. The service
824    /// is consumed and cannot be used after this call.
825    ///
826    /// # Returns
827    ///
828    /// The underlying `git2::Repository` instance.
829    #[allow(dead_code)]
830    pub(crate) fn into_repo(self) -> git2::Repository {
831        self.repo
832    }
833
834    /// Ensure `HEAD` points at `refs/heads/main`.
835    ///
836    /// Sets the repository's HEAD reference to point to the main branch. For newly
837    /// initialised repositories this creates an "unborn" `main` branch that will be
838    /// born when the first commit is written.
839    ///
840    /// # Errors
841    ///
842    /// Returns `PatientError::GitSetHead` if the HEAD reference cannot be updated.
843    fn ensure_main_head(&self) -> PatientResult<()> {
844        self.repo
845            .set_head(MAIN_REF)
846            .map_err(PatientError::GitSetHead)?;
847        Ok(())
848    }
849
850    /// Create a commit including only the provided file paths (relative to the repo workdir).
851    ///
852    /// This is useful for “surgical” updates where you don’t want to commit everything.
853    ///
854    /// # Path rules
855    ///
856    /// `relative_paths` may contain:
857    ///
858    /// - repo-workdir-relative paths (recommended), or
859    /// - absolute paths under the repo workdir (they will be normalised to relative paths).
860    ///
861    /// Paths containing `..` are rejected.
862    pub(crate) fn commit_paths(
863        &self,
864        author: &Author,
865        message: &VprCommitMessage,
866        relative_paths: &[PathBuf],
867    ) -> PatientResult<git2::Oid> {
868        let rendered = message.render_with_author(author)?;
869        self.commit_paths_rendered(author, &rendered, relative_paths)
870    }
871
872    /// Writes multiple files and commits them to Git with rollback on failure.
873    ///
874    /// Opens an existing Git repository, creates any necessary parent directories,
875    /// writes all files, and commits them in a single Git commit. All operations are
876    /// wrapped in a closure to enable automatic rollback if any operation fails. On error:
877    /// - Files that previously existed are restored to their previous state
878    /// - New files are removed
879    /// - Any directories created during this operation are removed
880    ///
881    /// # Arguments
882    ///
883    /// * `repo_path` - Path to the existing Git repository (patient directory).
884    /// * `author` - The author information for the Git commit.
885    /// * `msg` - The commit message structure containing domain, action, and location.
886    /// * `files` - Slice of [`FileToWrite`] structs describing files to write.
887    ///
888    /// # Returns
889    ///
890    /// Returns `Ok(())` if repository opening, directory creation, all file writes,
891    /// and Git commit succeed.
892    ///
893    /// # Errors
894    ///
895    /// Returns a `PatientError` if:
896    /// - Repository opening fails (various Git-related error variants)
897    /// - Parent directory creation fails ([`PatientError::FileWrite`])
898    /// - Any file write fails ([`PatientError::FileWrite`])
899    /// - The Git commit fails (various Git-related error variants)
900    ///
901    /// On error, attempts to rollback all files and any newly created directories.
902    pub(crate) fn write_and_commit_files(
903        repo_path: &Path,
904        author: &Author,
905        msg: &VprCommitMessage,
906        files: &[FileToWrite],
907    ) -> PatientResult<()> {
908        let repo = Self::open(repo_path)?;
909
910        let mut created_dirs: Vec<PathBuf> = Vec::new();
911        let mut written_files: Vec<(PathBuf, Option<String>)> = Vec::new();
912
913        let result: PatientResult<()> = (|| {
914            // Collect all unique parent directories needed
915            let mut dirs_needed = std::collections::HashSet::new();
916            for file in files {
917                let full_path = repo.workdir.join(file.relative_path);
918                if let Some(parent) = full_path.parent() {
919                    let mut current = parent;
920                    while current != repo.workdir && !current.exists() {
921                        dirs_needed.insert(current.to_path_buf());
922                        if let Some(parent_of_current) = current.parent() {
923                            current = parent_of_current;
924                        } else {
925                            break;
926                        }
927                    }
928                }
929            }
930
931            // Sort directories by depth (shallowest first) for creation
932            let mut dirs_to_create: Vec<PathBuf> = dirs_needed.into_iter().collect();
933            dirs_to_create.sort_by_key(|p| p.components().count());
934
935            // Create directories
936            for dir in &dirs_to_create {
937                std::fs::create_dir(dir).map_err(PatientError::FileWrite)?;
938                created_dirs.push(dir.clone());
939            }
940
941            // Write all files
942            for file in files {
943                let full_path = repo.workdir.join(file.relative_path);
944                let old_content = file.old_content.map(|s| s.to_string());
945
946                std::fs::write(&full_path, file.content).map_err(PatientError::FileWrite)?;
947                written_files.push((full_path, old_content));
948            }
949
950            // Commit all files in a single commit
951            let paths: Vec<PathBuf> = files
952                .iter()
953                .map(|f| f.relative_path.to_path_buf())
954                .collect();
955            repo.commit_paths(author, msg, &paths)?;
956
957            Ok(())
958        })();
959
960        match result {
961            Ok(()) => Ok(()),
962            Err(write_error) => {
963                // Rollback file changes (in reverse order)
964                for (full_path, old_content) in written_files.iter().rev() {
965                    match old_content {
966                        Some(contents) => {
967                            let _ = std::fs::write(full_path, contents);
968                        }
969                        None => {
970                            let _ = std::fs::remove_file(full_path);
971                        }
972                    }
973                }
974
975                // Rollback newly created directories (from deepest to shallowest)
976                for dir in created_dirs.iter().rev() {
977                    let _ = std::fs::remove_dir(dir);
978                }
979
980                Err(write_error)
981            }
982        }
983    }
984
985    /// Initialise a Git repository, commit initial files, and clean up on failure.
986    ///
987    /// This method encapsulates the common pattern of:
988    /// 1. Initialising a Git repository in a new directory
989    /// 2. Writing and committing initial files
990    /// 3. Automatically removing the entire directory if any error occurs
991    ///
992    /// This ensures atomic repository creation - either the repository is fully
993    /// initialised with its initial commit, or the directory is completely removed.
994    /// This is critical for maintaining consistency in patient record storage.
995    ///
996    /// # Arguments
997    ///
998    /// * `patient_dir` - Path where the Git repository should be created. This entire
999    ///   directory will be removed if initialisation fails.
1000    /// * `author` - The author information for the initial Git commit.
1001    /// * `message` - The commit message structure containing domain, action, and location.
1002    /// * `files` - Slice of [`FileToWrite`] structs describing initial files to write.
1003    ///
1004    /// # Returns
1005    ///
1006    /// Returns `Ok(())` if repository initialisation, file writes, and commit succeed.
1007    ///
1008    /// # Errors
1009    ///
1010    /// Returns a `PatientError` if:
1011    /// - Repository initialisation fails ([`PatientError::GitInit`])
1012    /// - File writes fail ([`PatientError::FileWrite`])
1013    /// - Git commit fails (various Git-related error variants)
1014    ///
1015    /// On error, attempts to remove the entire `patient_dir` directory. If cleanup also
1016    /// fails, returns [`PatientError::CleanupAfterInitialiseFailed`] with both the
1017    /// original error and the cleanup error.
1018    ///
1019    /// # Example
1020    ///
1021    /// ```ignore
1022    /// let files = [FileToWrite {
1023    ///     relative_path: Path::new("STATUS.yaml"),
1024    ///     content: &status_yaml,
1025    ///     old_content: None,
1026    /// }];
1027    ///
1028    /// VersionedFileService::init_and_commit(
1029    ///     &patient_dir,
1030    ///     &author,
1031    ///     &commit_message,
1032    ///     &files,
1033    /// )?;
1034    /// ```
1035    pub(crate) fn init_and_commit(
1036        patient_dir: &Path,
1037        author: &Author,
1038        message: &VprCommitMessage,
1039        files: &[FileToWrite],
1040    ) -> PatientResult<()> {
1041        let result: PatientResult<()> = (|| {
1042            let _repo = Self::init(patient_dir)?;
1043            Self::write_and_commit_files(patient_dir, author, message, files)?;
1044            Ok(())
1045        })();
1046
1047        match result {
1048            Ok(()) => Ok(()),
1049            Err(init_error) => {
1050                // Attempt cleanup - remove entire patient_dir
1051                if let Err(cleanup_err) = cleanup_patient_dir(patient_dir) {
1052                    return Err(PatientError::CleanupAfterInitialiseFailed {
1053                        path: patient_dir.to_path_buf(),
1054                        init_error: Box::new(init_error),
1055                        cleanup_error: cleanup_err,
1056                    });
1057                }
1058                Err(init_error)
1059            }
1060        }
1061    }
1062
1063    /// Create a commit with rendered message for the specified paths.
1064    ///
1065    /// This is an internal helper that performs the actual commit operation with a
1066    /// pre-rendered commit message string. It handles path normalisation (absolute to
1067    /// relative) and validates that paths don't escape the repository directory.
1068    ///
1069    /// # Arguments
1070    ///
1071    /// * `author` - Author information for commit signature
1072    /// * `message` - Pre-rendered commit message string
1073    /// * `relative_paths` - Paths to commit (will be normalised if absolute)
1074    ///
1075    /// # Errors
1076    ///
1077    /// Returns `PatientError` if:
1078    /// - HEAD cannot be set to main branch
1079    /// - Git index operations fail
1080    /// - Path is outside repository or contains `..`
1081    /// - Commit creation fails
1082    fn commit_paths_rendered(
1083        &self,
1084        author: &Author,
1085        message: &str,
1086        relative_paths: &[PathBuf],
1087    ) -> PatientResult<git2::Oid> {
1088        self.ensure_main_head()?;
1089        let mut index = self.repo.index().map_err(PatientError::GitIndex)?;
1090
1091        for path in relative_paths {
1092            // `git2::Index::add_path` requires repo-workdir-relative paths.
1093            let rel = if path.is_absolute() {
1094                path.strip_prefix(&self.workdir)
1095                    .map_err(|_| {
1096                        PatientError::InvalidInput(
1097                            "path is outside the repository working directory".into(),
1098                        )
1099                    })?
1100                    .to_path_buf()
1101            } else {
1102                path.to_path_buf()
1103            };
1104
1105            if rel
1106                .components()
1107                .any(|c| matches!(c, std::path::Component::ParentDir))
1108            {
1109                return Err(PatientError::InvalidInput(
1110                    "path must not contain parent directory references (..)".into(),
1111                ));
1112            }
1113
1114            index.add_path(&rel).map_err(PatientError::GitAdd)?;
1115        }
1116
1117        self.commit_from_index(author, message, &mut index)
1118    }
1119
1120    /// Create a commit from the current Git index state.
1121    ///
1122    /// This is the lowest-level commit creation helper. It validates author information,
1123    /// writes the index as a tree, and creates either a signed or unsigned commit depending
1124    /// on whether the author has a signature key.
1125    ///
1126    /// For signed commits, this method:
1127    /// 1. Creates the unsigned commit buffer with correct parent list
1128    /// 2. Signs the buffer using ECDSA P-256
1129    /// 3. Validates certificate matches signing key (if certificate provided)
1130    /// 4. Creates the signed commit and manually updates refs
1131    ///
1132    /// # Arguments
1133    ///
1134    /// * `author` - Validated author information
1135    /// * `message` - Complete commit message text
1136    /// * `index` - Git index containing staged changes
1137    ///
1138    /// # Errors
1139    ///
1140    /// Returns `PatientError` if:
1141    /// - Author validation fails
1142    /// - Tree write or lookup fails
1143    /// - Signature creation fails
1144    /// - Certificate/key mismatch detected
1145    /// - Commit creation or ref update fails
1146    fn commit_from_index(
1147        &self,
1148        author: &Author,
1149        message: &str,
1150        index: &mut git2::Index,
1151    ) -> PatientResult<git2::Oid> {
1152        // Ensure author metadata is valid before creating any commit buffers or signatures.
1153        author.validate_commit_author()?;
1154
1155        let tree_id = index.write_tree().map_err(PatientError::GitWriteTree)?;
1156        let tree = self
1157            .repo
1158            .find_tree(tree_id)
1159            .map_err(PatientError::GitFindTree)?;
1160
1161        let sig = git2::Signature::now(author.name.as_str(), author.email.as_str())
1162            .map_err(PatientError::GitSignature)?;
1163
1164        if let Some(private_key_pem) = &author.signature {
1165            // Create the canonical unsigned commit buffer with correct parent list.
1166            let parents = self.resolve_head_parents()?;
1167            let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
1168
1169            let buf = self
1170                .repo
1171                .commit_create_buffer(&sig, &sig, message, &tree, &parent_refs)
1172                .map_err(PatientError::GitCommitBuffer)?;
1173            let buf_str = String::from_utf8(buf.as_ref().to_vec())
1174                .map_err(PatientError::CommitBufferToString)?;
1175
1176            let private_key_str = std::str::from_utf8(private_key_pem)
1177                .map_err(|e| PatientError::EcdsaPrivateKeyParse(Box::new(e)))?;
1178            let key_pem = Self::load_private_key_pem(private_key_str)?;
1179            let signing_key = SigningKey::from_pkcs8_pem(&key_pem)
1180                .map_err(|e| PatientError::EcdsaPrivateKeyParse(Box::new(e)))?;
1181
1182            let public_key_bytes = signing_key
1183                .verifying_key()
1184                .to_encoded_point(false)
1185                .as_bytes()
1186                .to_vec();
1187
1188            if let Some(cert_bytes) = author.certificate.as_deref() {
1189                let cert_public_key = extract_cert_public_key_sec1(cert_bytes)?;
1190                if cert_public_key != public_key_bytes {
1191                    return Err(PatientError::AuthorCertificatePublicKeyMismatch);
1192                }
1193            }
1194
1195            // Sign the unsigned commit buffer. Signature is raw 64-byte (r||s), base64-encoded.
1196            let signature: Signature = signing_key.sign(buf_str.as_bytes());
1197
1198            let payload = VprCommitSignaturePayloadV1 {
1199                signature: general_purpose::STANDARD.encode(signature.to_bytes()),
1200                public_key: general_purpose::STANDARD.encode(&public_key_bytes),
1201                certificate: author
1202                    .certificate
1203                    .as_deref()
1204                    .map(|b| general_purpose::STANDARD.encode(b)),
1205            };
1206
1207            let payload_json = serde_json::to_vec(&payload).map_err(PatientError::Serialization)?;
1208            let signature_str = general_purpose::STANDARD.encode(payload_json);
1209
1210            let oid = self
1211                .repo
1212                .commit_signed(&buf_str, &signature_str, None)
1213                .map_err(PatientError::GitCommitSigned)?;
1214
1215            // `commit_signed` creates the object but does not move refs.
1216            self.repo
1217                .reference(MAIN_REF, oid, true, "signed commit")
1218                .map_err(PatientError::GitReference)?;
1219            self.repo
1220                .set_head(MAIN_REF)
1221                .map_err(PatientError::GitSetHead)?;
1222
1223            Ok(oid)
1224        } else {
1225            let parents = self.resolve_head_parents()?;
1226            let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
1227            // Normal commit updates HEAD (and underlying ref).
1228            self.repo
1229                .commit(Some("HEAD"), &sig, &sig, message, &tree, &parent_refs)
1230                .map_err(PatientError::GitCommit)
1231        }
1232    }
1233
1234    /// Resolve the parent commit(s) for a new commit.
1235    ///
1236    /// Determines the appropriate parent list based on repository state:
1237    /// - If `HEAD` exists and points to a commit, returns that commit as the parent
1238    /// - If the repository is empty (unborn branch or not found), returns empty parent list
1239    /// - Other errors are propagated
1240    ///
1241    /// This handles the distinction between the first commit (no parents) and subsequent
1242    /// commits (one parent) in a linear history.
1243    ///
1244    /// # Errors
1245    ///
1246    /// Returns `PatientError::GitHead` if HEAD lookup fails for reasons other than
1247    /// unborn branch or not found.
1248    fn resolve_head_parents(&self) -> PatientResult<Vec<git2::Commit<'_>>> {
1249        match self.repo.head() {
1250            Ok(head) => {
1251                let commit = head.peel_to_commit().map_err(PatientError::GitPeel)?;
1252                Ok(vec![commit])
1253            }
1254            Err(e) if e.code() == git2::ErrorCode::UnbornBranch => Ok(vec![]),
1255            Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(vec![]),
1256            Err(e) => Err(PatientError::GitHead(e)),
1257        }
1258    }
1259
1260    /// Load an ECDSA private key in PKCS#8 PEM format.
1261    ///
1262    /// This method accepts private keys in three formats for compatibility:
1263    /// 1. Direct PEM string (contains `-----BEGIN` marker)
1264    /// 2. Filesystem path to a PEM file (must exist)
1265    /// 3. Base64-encoded PEM string
1266    ///
1267    /// The method tries each format in order until one succeeds.
1268    ///
1269    /// # Arguments
1270    ///
1271    /// * `private_key_pem` - Private key as PEM string, file path, or base64-encoded PEM
1272    ///
1273    /// # Errors
1274    ///
1275    /// Returns `PatientError::EcdsaPrivateKeyParse` if:
1276    /// - File cannot be read (if path format)
1277    /// - Base64 decode fails (if base64 format)
1278    /// - Result is not valid UTF-8
1279    fn load_private_key_pem(private_key_pem: &str) -> PatientResult<String> {
1280        if private_key_pem.contains("-----BEGIN") {
1281            Ok(private_key_pem.to_string())
1282        } else if Path::new(private_key_pem).exists() {
1283            fs::read_to_string(private_key_pem)
1284                .map_err(|e| PatientError::EcdsaPrivateKeyParse(Box::new(e)))
1285        } else {
1286            let decoded = general_purpose::STANDARD
1287                .decode(private_key_pem)
1288                .map_err(|e| PatientError::EcdsaPrivateKeyParse(Box::new(e)))?;
1289            String::from_utf8(decoded).map_err(|e| PatientError::EcdsaPrivateKeyParse(Box::new(e)))
1290        }
1291    }
1292
1293    /// Verifies the ECDSA signature of the latest commit in a patient's Git repository.
1294    ///
1295    /// VPR uses `git2::Repository::commit_signed` with an ECDSA P-256 signature over the
1296    /// *unsigned commit buffer* produced by `commit_create_buffer`.
1297    ///
1298    /// The signature, signing public key, and optional X.509 certificate are embedded directly
1299    /// in the commit object's `gpgsig` header as a base64-encoded JSON container.
1300    ///
1301    /// This method reconstructs the commit buffer and verifies the signature using the embedded
1302    /// public key, optionally checking that `public_key_pem` (if provided) matches it.
1303    ///
1304    /// # Arguments
1305    ///
1306    /// * `base_dir` - The base directory for the patient records (e.g., clinical or demographics directory).
1307    /// * `uuid` - The UUID of the patient record as a string.
1308    /// * `public_key_pem` - The PEM-encoded public key used for verification.
1309    ///
1310    /// # Returns
1311    ///
1312    /// Returns `true` if the signature is valid, `false` otherwise.
1313    ///
1314    /// # Errors
1315    ///
1316    /// Returns a `PatientError` if:
1317    /// - the UUID cannot be parsed,
1318    /// - the Git repository cannot be opened or the latest commit cannot be read,
1319    /// - `public_key_pem` is provided but cannot be parsed as a public key or X.509 certificate.
1320    #[allow(dead_code)]
1321    pub fn verify_commit_signature(
1322        base_dir: &Path,
1323        uuid: &str,
1324        public_key_pem: &str,
1325    ) -> PatientResult<bool> {
1326        let uuid = ShardableUuid::parse(uuid)?;
1327        let patient_dir = uuid.sharded_dir(base_dir);
1328        let repo = Self::open(&patient_dir)?;
1329
1330        let head = repo.repo.head().map_err(PatientError::GitHead)?;
1331        let commit = head.peel_to_commit().map_err(PatientError::GitPeel)?;
1332
1333        let embedded = match crate::author::extract_embedded_commit_signature(&commit) {
1334            Ok(v) => v,
1335            Err(_) => return Ok(false),
1336        };
1337
1338        let signature = match Signature::from_slice(embedded.signature.as_slice()) {
1339            Ok(s) => s,
1340            Err(_) => return Ok(false),
1341        };
1342
1343        let embedded_verifying_key =
1344            match VerifyingKey::from_sec1_bytes(embedded.public_key.as_slice()) {
1345                Ok(k) => k,
1346                Err(_) => return Ok(false),
1347            };
1348
1349        // If a trusted key/cert was provided by the caller, it must match the embedded key.
1350        if !public_key_pem.trim().is_empty() {
1351            let trusted_key = verifying_key_from_public_key_or_cert_pem(public_key_pem)?;
1352            let trusted_pub_bytes = trusted_key.to_encoded_point(false).as_bytes().to_vec();
1353            if trusted_pub_bytes != embedded.public_key {
1354                return Ok(false);
1355            }
1356        }
1357
1358        // Recreate the unsigned commit buffer for this commit.
1359        let tree = commit.tree().map_err(PatientError::GitFindTree)?;
1360        let parents: Vec<git2::Commit> = commit.parents().collect();
1361        let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
1362        let message = commit.message().unwrap_or("");
1363        let author = commit.author();
1364        let committer = commit.committer();
1365
1366        let buf = repo
1367            .repo
1368            .commit_create_buffer(&author, &committer, message, &tree, &parent_refs)
1369            .map_err(PatientError::GitCommitBuffer)?;
1370        let buf_str =
1371            String::from_utf8(buf.as_ref().to_vec()).map_err(PatientError::CommitBufferToString)?;
1372
1373        // Verify with the canonical payload.
1374        Ok(embedded_verifying_key
1375            .verify(buf_str.as_bytes(), &signature)
1376            .is_ok())
1377    }
1378}
1379
1380/// Parse a public key from PEM format or extract it from an X.509 certificate.
1381///
1382/// This function handles both raw ECDSA public keys in PEM format and X.509 certificates.
1383/// It's used during signature verification to parse trusted public keys provided by callers.
1384///
1385/// # Arguments
1386///
1387/// * `pem_or_cert` - Either a PEM-encoded public key or a PEM/DER-encoded X.509 certificate
1388///
1389/// # Returns
1390///
1391/// A `VerifyingKey` for ECDSA signature verification.
1392///
1393/// # Errors
1394///
1395/// Returns `PatientError::EcdsaPublicKeyParse` if parsing fails.
1396fn verifying_key_from_public_key_or_cert_pem(pem_or_cert: &str) -> PatientResult<VerifyingKey> {
1397    if pem_or_cert.contains("-----BEGIN CERTIFICATE-----") {
1398        let (_, pem) = x509_parser::pem::parse_x509_pem(pem_or_cert.as_bytes())
1399            .map_err(|e| PatientError::EcdsaPublicKeyParse(Box::new(e)))?;
1400        let (_, cert) = X509Certificate::from_der(pem.contents.as_ref())
1401            .map_err(|e| PatientError::EcdsaPublicKeyParse(Box::new(e)))?;
1402
1403        let spk = cert.public_key();
1404        let key_bytes = &spk.subject_public_key.data;
1405        VerifyingKey::from_sec1_bytes(key_bytes.as_ref())
1406            .map_err(|e| PatientError::EcdsaPublicKeyParse(Box::new(e)))
1407    } else {
1408        VerifyingKey::from_public_key_pem(pem_or_cert)
1409            .map_err(|e| PatientError::EcdsaPublicKeyParse(Box::new(e)))
1410    }
1411}
1412
1413#[cfg(test)]
1414static FORCE_CLEANUP_ERROR_FOR_THREADS: LazyLock<Mutex<HashSet<std::thread::ThreadId>>> =
1415    LazyLock::new(|| Mutex::new(HashSet::new()));
1416
1417fn cleanup_patient_dir(patient_dir: &Path) -> std::io::Result<()> {
1418    #[cfg(test)]
1419    {
1420        let current_id = std::thread::current().id();
1421        let mut guard = FORCE_CLEANUP_ERROR_FOR_THREADS
1422            .lock()
1423            .expect("FORCE_CLEANUP_ERROR_FOR_THREADS mutex poisoned");
1424
1425        if guard.remove(&current_id) {
1426            return Err(std::io::Error::other("forced cleanup failure (test hook)"));
1427        }
1428    }
1429
1430    std::fs::remove_dir_all(patient_dir)
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435    use super::ClinicalDomain::*;
1436    use super::*;
1437    use crate::{EmailAddress, NonEmptyText};
1438    use tempfile::TempDir;
1439
1440    #[test]
1441    fn domain_serialises_lowercase() {
1442        let s = serde_json::to_string(&VprCommitDomain::Clinical(Record)).unwrap();
1443        assert_eq!(s, "\"record\"");
1444    }
1445
1446    #[test]
1447    fn action_serialises_lowercase() {
1448        let s = serde_json::to_string(&VprCommitAction::Create).unwrap();
1449        assert_eq!(s, "\"create\"");
1450    }
1451
1452    #[test]
1453    fn can_get_domain() {
1454        let msg = VprCommitMessage::new(
1455            VprCommitDomain::Clinical(Record),
1456            VprCommitAction::Create,
1457            "test",
1458            "location",
1459        )
1460        .unwrap();
1461        assert_eq!(msg.domain(), VprCommitDomain::Clinical(Record));
1462    }
1463
1464    #[test]
1465    fn can_get_action() {
1466        let msg = VprCommitMessage::new(
1467            VprCommitDomain::Clinical(Record),
1468            VprCommitAction::Create,
1469            "test",
1470            "location",
1471        )
1472        .unwrap();
1473        assert_eq!(msg.action(), VprCommitAction::Create);
1474    }
1475
1476    #[test]
1477    fn can_get_summary() {
1478        let msg = VprCommitMessage::new(
1479            VprCommitDomain::Clinical(Record),
1480            VprCommitAction::Create,
1481            "test summary",
1482            "location",
1483        )
1484        .unwrap();
1485        assert_eq!(msg.summary(), "test summary");
1486    }
1487
1488    #[test]
1489    fn can_get_trailers() {
1490        let msg = VprCommitMessage::new(
1491            VprCommitDomain::Clinical(Record),
1492            VprCommitAction::Create,
1493            "test",
1494            "location",
1495        )
1496        .unwrap()
1497        .with_trailer("Key", "Value")
1498        .unwrap();
1499        let trailers = msg.trailers();
1500        assert_eq!(trailers.len(), 1);
1501        assert_eq!(trailers[0].key(), "Key");
1502        assert_eq!(trailers[0].value(), "Value");
1503    }
1504
1505    #[test]
1506    fn into_repo_consumes_and_returns_underlying() {
1507        let temp_dir = TempDir::new().unwrap();
1508        let service = VersionedFileService::init(temp_dir.path()).unwrap();
1509        let repo = service.into_repo();
1510        let workdir = repo.workdir().expect("repo should have workdir");
1511        let expected = temp_dir.path();
1512
1513        // Compare canonicalized paths to handle symlinks (e.g., /var -> /private/var on macOS)
1514        let workdir_canonical = workdir
1515            .canonicalize()
1516            .unwrap_or_else(|_| workdir.to_path_buf());
1517        let expected_canonical = expected
1518            .canonicalize()
1519            .unwrap_or_else(|_| expected.to_path_buf());
1520
1521        assert_eq!(workdir_canonical, expected_canonical);
1522    }
1523
1524    #[test]
1525    fn verifying_key_from_pem() {
1526        use p256::pkcs8::EncodePublicKey;
1527
1528        // Generate a valid ECDSA P-256 key pair
1529        let signing_key = SigningKey::random(&mut rand::thread_rng());
1530        let verifying_key = signing_key.verifying_key();
1531
1532        // Encode the public key as PEM
1533        let pem = verifying_key
1534            .to_public_key_pem(p256::pkcs8::LineEnding::LF)
1535            .expect("Failed to encode public key");
1536
1537        // Parse it back
1538        let parsed_key = verifying_key_from_public_key_or_cert_pem(&pem).unwrap();
1539
1540        // Verify they match
1541        assert_eq!(
1542            verifying_key.to_encoded_point(false).as_bytes(),
1543            parsed_key.to_encoded_point(false).as_bytes()
1544        );
1545    }
1546
1547    #[test]
1548    fn render_without_trailers_is_single_line() {
1549        let msg = VprCommitMessage::new(
1550            VprCommitDomain::Clinical(Record),
1551            VprCommitAction::Create,
1552            "Patient record created",
1553            "St Elsewhere Hospital",
1554        )
1555        .unwrap();
1556        assert_eq!(
1557            msg.render().unwrap(),
1558            "record:create: Patient record created\n\nCare-Location: St Elsewhere Hospital"
1559        );
1560    }
1561
1562    #[test]
1563    fn render_with_trailers_matches_git_trailer_format() {
1564        let msg = VprCommitMessage::new(
1565            VprCommitDomain::Clinical(Record),
1566            VprCommitAction::Create,
1567            "Patient record created",
1568            "St Elsewhere Hospital",
1569        )
1570        .unwrap()
1571        .with_trailer("Change-Reason", "Correction")
1572        .unwrap()
1573        .with_trailer("Authority", "GMC")
1574        .unwrap();
1575
1576        assert_eq!(
1577            msg.render().unwrap(),
1578            "record:create: Patient record created\n\nCare-Location: St Elsewhere Hospital\nAuthority: GMC\nChange-Reason: Correction"
1579        );
1580    }
1581
1582    #[test]
1583    fn render_with_author_includes_care_location_after_author_trailers() {
1584        let author = Author {
1585            name: NonEmptyText::new("Test Author").unwrap(),
1586            role: NonEmptyText::new("Clinician").unwrap(),
1587            email: EmailAddress::parse("test@example.com").unwrap(),
1588            registrations: vec![],
1589            signature: None,
1590            certificate: None,
1591        };
1592
1593        let msg = VprCommitMessage::new(
1594            VprCommitDomain::Clinical(Record),
1595            VprCommitAction::Create,
1596            "Patient record created",
1597            "St Elsewhere Hospital",
1598        )
1599        .unwrap()
1600        .with_trailer("Change-Reason", "Init")
1601        .unwrap();
1602
1603        assert_eq!(
1604            msg.render_with_author(&author).unwrap(),
1605            "record:create: Patient record created\n\nAuthor-Name: Test Author\nAuthor-Role: Clinician\nCare-Location: St Elsewhere Hospital\nChange-Reason: Init"
1606        );
1607    }
1608
1609    #[test]
1610    fn rejects_multiline_summary() {
1611        let err = VprCommitMessage::new(
1612            VprCommitDomain::Clinical(Record),
1613            VprCommitAction::Create,
1614            "line1\nline2",
1615            "St Elsewhere Hospital",
1616        )
1617        .unwrap_err();
1618
1619        assert!(matches!(err, PatientError::InvalidInput(_)));
1620    }
1621
1622    #[test]
1623    fn rejects_missing_care_location() {
1624        let err = VprCommitMessage::new(
1625            VprCommitDomain::Clinical(Record),
1626            VprCommitAction::Create,
1627            "Patient record created",
1628            "   ",
1629        )
1630        .unwrap_err();
1631
1632        assert!(matches!(err, PatientError::MissingCareLocation));
1633    }
1634
1635    #[test]
1636    fn rejects_multiline_care_location() {
1637        let err = VprCommitMessage::new(
1638            VprCommitDomain::Clinical(Record),
1639            VprCommitAction::Create,
1640            "Patient record created",
1641            "St Elsewhere\nHospital",
1642        )
1643        .unwrap_err();
1644
1645        assert!(matches!(err, PatientError::InvalidCareLocation));
1646    }
1647
1648    #[test]
1649    fn rejects_invalid_trailer_key() {
1650        let err = VprCommitTrailer::new("Bad:Key", "Value").unwrap_err();
1651        assert!(matches!(err, PatientError::InvalidInput(_)));
1652    }
1653}