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(¤t_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}