1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct CtEntry {
13 pub cert_hash_hex: String,
15 pub issuer: String,
17 pub submitted_at: DateTime<Utc>,
19 pub signature_hex: Option<String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum CtSubmissionStatus {
27 Pending,
28 Accepted,
29 Rejected { reason: String },
30 AlreadyIncluded,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CtSubmissionResult {
36 pub entry: CtEntry,
37 pub status: CtSubmissionStatus,
38 pub log_id: String,
39 pub sct_hex: Option<String>, }
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct CtLogConfig {
45 pub log_id: String,
46 pub url: String,
47 pub public_key_hex: String,
48 pub max_entry_size: usize,
49}
50
51pub struct CtClient {
53 pub log: CtLogConfig,
54 pending: std::sync::Mutex<Vec<CtEntry>>,
55}
56
57impl CtClient {
58 pub fn new(log: CtLogConfig) -> Self {
59 Self {
60 log,
61 pending: std::sync::Mutex::new(Vec::new()),
62 }
63 }
64
65 pub fn queue_entry(&self, entry: CtEntry) {
67 self.pending.lock().unwrap().push(entry);
68 }
69
70 pub fn process_pending(&self) -> Vec<CtSubmissionResult> {
72 let mut pending = self.pending.lock().unwrap();
73 let results: Vec<CtSubmissionResult> = pending
74 .drain(..)
75 .map(|entry| {
76 let status = if entry.cert_hash_hex.len() / 2 <= self.log.max_entry_size {
78 CtSubmissionStatus::Accepted
79 } else {
80 CtSubmissionStatus::Rejected {
81 reason: "too large".into(),
82 }
83 };
84 let sct = if matches!(status, CtSubmissionStatus::Accepted) {
85 Some(format!("sct-{}", entry.cert_hash_hex))
86 } else {
87 None
88 };
89 CtSubmissionResult {
90 entry,
91 status,
92 log_id: self.log.log_id.clone(),
93 sct_hex: sct,
94 }
95 })
96 .collect();
97 results
98 }
99
100 pub fn inclusion_proof(&self, entry_hash: &str) -> Vec<String> {
102 vec![format!("proof-{}", entry_hash)]
105 }
106
107 pub fn pending_count(&self) -> usize {
109 self.pending.lock().unwrap().len()
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 fn make_entry(hash: impl Into<String>) -> CtEntry {
118 CtEntry {
119 cert_hash_hex: hash.into(),
120 issuer: "confium-test".into(),
121 submitted_at: Utc::now(),
122 signature_hex: Some("sig".into()),
123 }
124 }
125
126 fn make_log() -> CtLogConfig {
127 CtLogConfig {
128 log_id: "log-1".into(),
129 url: "https://ct.example.com/log".into(),
130 public_key_hex: "pubkey".into(),
131 max_entry_size: 1024,
132 }
133 }
134
135 #[test]
136 fn queue_and_process() {
137 let client = CtClient::new(make_log());
138 client.queue_entry(make_entry("a".repeat(64)));
139 client.queue_entry(make_entry("b".repeat(64)));
140 assert_eq!(client.pending_count(), 2);
141 let results = client.process_pending();
142 assert_eq!(results.len(), 2);
143 assert_eq!(client.pending_count(), 0);
144 }
145
146 #[test]
147 fn accepted_entries_have_sct() {
148 let client = CtClient::new(make_log());
149 client.queue_entry(make_entry("a".repeat(64)));
150 let results = client.process_pending();
151 assert!(results[0].sct_hex.is_some());
152 }
153
154 #[test]
155 fn oversized_entries_rejected() {
156 let client = CtClient::new(make_log());
157 let oversized = "a".repeat(3000);
158 client.queue_entry(make_entry(&oversized));
159 let results = client.process_pending();
160 assert!(matches!(
161 results[0].status,
162 CtSubmissionStatus::Rejected { .. }
163 ));
164 }
165
166 #[test]
167 fn inclusion_proof_generated() {
168 let client = CtClient::new(make_log());
169 let proof = client.inclusion_proof("hash123");
170 assert_eq!(proof.len(), 1);
171 assert!(proof[0].contains("hash123"));
172 }
173
174 #[test]
175 fn entry_serializes() {
176 let entry = make_entry("a".repeat(64));
177 let json = serde_json::to_string(&entry).unwrap();
178 assert!(json.contains("confium-test"));
179 }
180
181 #[test]
182 fn status_serialization() {
183 let status = CtSubmissionStatus::Pending;
184 let json = serde_json::to_string(&status).unwrap();
185 assert_eq!(json, "\"pending\"");
186 }
187}