1use crate::error::{PatientError, PatientResult};
7use crate::{EmailAddress, NonEmptyText};
8use base64::{engine::general_purpose, Engine as _};
9use serde::Deserialize;
10use x509_parser::prelude::*;
11
12#[derive(Clone, Debug)]
14pub struct Author {
15 pub name: NonEmptyText,
17
18 pub role: NonEmptyText,
20
21 pub email: EmailAddress,
23
24 pub registrations: Vec<AuthorRegistration>,
26
27 pub signature: Option<Vec<u8>>,
29
30 pub certificate: Option<Vec<u8>>,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct EmbeddedCommitSignature {
37 pub signature: Vec<u8>,
39 pub public_key: Vec<u8>,
41 pub certificate: Option<Vec<u8>>,
43}
44
45#[derive(Deserialize)]
46struct VprCommitSignaturePayloadV1 {
47 signature: String,
48 public_key: String,
49 #[serde(default)]
50 certificate: Option<String>,
51}
52
53fn extract_cert_public_key_sec1(cert_bytes: &[u8]) -> PatientResult<Vec<u8>> {
54 let cert_der: Vec<u8> = if cert_bytes
55 .windows("-----BEGIN CERTIFICATE-----".len())
56 .any(|w| w == b"-----BEGIN CERTIFICATE-----")
57 {
58 let (_, pem) = x509_parser::pem::parse_x509_pem(cert_bytes)
59 .map_err(|e| PatientError::EcdsaPublicKeyParse(Box::new(e)))?;
60 pem.contents.to_vec()
61 } else {
62 cert_bytes.to_vec()
63 };
64
65 let (_, cert) = X509Certificate::from_der(cert_der.as_slice())
66 .map_err(|e| PatientError::EcdsaPublicKeyParse(Box::new(e)))?;
67 let spk = cert.public_key();
68 Ok(spk.subject_public_key.data.to_vec())
69}
70
71pub fn extract_embedded_commit_signature(
80 commit: &git2::Commit<'_>,
81) -> PatientResult<EmbeddedCommitSignature> {
82 let sig_field = commit
83 .header_field_bytes("gpgsig")
84 .map_err(|_| PatientError::InvalidCommitSignaturePayload)?;
85 if sig_field.is_empty() {
86 return Err(PatientError::InvalidCommitSignaturePayload);
87 }
88
89 let sig_field_str = std::str::from_utf8(sig_field.as_ref())
90 .map_err(|_| PatientError::InvalidCommitSignaturePayload)?;
91 let sig_b64: String = sig_field_str.lines().map(|l| l.trim()).collect();
92
93 let payload_bytes = general_purpose::STANDARD
94 .decode(sig_b64)
95 .map_err(|_| PatientError::InvalidCommitSignaturePayload)?;
96 let payload: VprCommitSignaturePayloadV1 = serde_json::from_slice(&payload_bytes)
97 .map_err(|_| PatientError::InvalidCommitSignaturePayload)?;
98
99 let signature = general_purpose::STANDARD
100 .decode(payload.signature)
101 .map_err(|_| PatientError::InvalidCommitSignaturePayload)?;
102 let public_key = general_purpose::STANDARD
103 .decode(payload.public_key)
104 .map_err(|_| PatientError::InvalidCommitSignaturePayload)?;
105 let certificate = match payload.certificate {
106 Some(cert_b64) => Some(
107 general_purpose::STANDARD
108 .decode(cert_b64)
109 .map_err(|_| PatientError::InvalidCommitSignaturePayload)?,
110 ),
111 None => None,
112 };
113
114 if let Some(cert_bytes) = certificate.as_deref() {
115 let cert_public_key = extract_cert_public_key_sec1(cert_bytes)?;
116 if cert_public_key != public_key {
117 return Err(PatientError::AuthorCertificatePublicKeyMismatch);
118 }
119 }
120
121 Ok(EmbeddedCommitSignature {
122 signature,
123 public_key,
124 certificate,
125 })
126}
127
128#[derive(Clone, Debug, Eq, PartialEq, Hash)]
134pub struct AuthorRegistration {
135 pub authority: NonEmptyText,
136 pub number: NonEmptyText,
137}
138
139impl AuthorRegistration {
140 pub fn new(authority: impl Into<String>, number: impl Into<String>) -> PatientResult<Self> {
141 let authority_str = authority.into().trim().to_string();
142 let number_str = number.into().trim().to_string();
143
144 if authority_str.is_empty()
145 || number_str.is_empty()
146 || authority_str.contains(['\n', '\r'])
147 || number_str.contains(['\n', '\r'])
148 || authority_str.chars().any(char::is_whitespace)
149 || number_str.chars().any(char::is_whitespace)
150 {
151 return Err(PatientError::InvalidAuthorRegistration);
152 }
153
154 let authority = NonEmptyText::new(authority_str)
155 .map_err(|_| PatientError::InvalidAuthorRegistration)?;
156 let number =
157 NonEmptyText::new(number_str).map_err(|_| PatientError::InvalidAuthorRegistration)?;
158
159 Ok(Self { authority, number })
160 }
161}
162
163impl Author {
164 pub fn validate_commit_author(&self) -> PatientResult<()> {
168 for reg in &self.registrations {
172 AuthorRegistration::new(reg.authority.as_str(), reg.number.as_str())?;
173 }
174
175 Ok(())
176 }
177}
178
179#[cfg(test)]
180mod author_tests {
181 use super::*;
182
183 fn base_author() -> Author {
184 Author {
185 name: NonEmptyText::new("Test Author").unwrap(),
186 role: NonEmptyText::new("Clinician").unwrap(),
187 email: EmailAddress::parse("test@example.com").unwrap(),
188 registrations: vec![],
189 signature: None,
190 certificate: None,
191 }
192 }
193
194 #[test]
195 fn validate_commit_author_rejects_invalid_registration() {
196 let _author = base_author();
197 let err =
199 AuthorRegistration::new("G MC", "12345").expect_err("expected validation failure");
200 assert!(matches!(err, PatientError::InvalidAuthorRegistration));
201 }
202
203 #[test]
204 fn validate_commit_author_accepts_valid_author() {
205 let mut author = base_author();
206 author.registrations =
207 vec![AuthorRegistration::new("GMC", "12345").expect("valid registration")];
208
209 author
210 .validate_commit_author()
211 .expect("expected validation to succeed");
212 }
213}