1use super::*;
2
3use chio_http_serve::{
4 apply_server_hygiene, run_until_drained, ServeError, ServeHygieneConfig, ShutdownController,
5};
6use std::fs;
7use std::path::PathBuf;
8use std::time::Duration;
9
10const RESERVED_HOLD_REAP_INTERVAL_SECS: u64 = 30;
14
15pub(crate) async fn spawn_reserved_hold_reaper(state: &Arc<ProxyState>) {
21 if state.mediation_kernel.is_none() {
22 return;
23 }
24 let reaper_state = Arc::clone(state);
25 let handle = tokio::spawn(async move {
26 let mut ticker = tokio::time::interval(std::time::Duration::from_secs(
27 RESERVED_HOLD_REAP_INTERVAL_SECS,
28 ));
29 ticker.tick().await;
30 loop {
31 ticker.tick().await;
32 let now = chrono::Utc::now().timestamp();
33 match reap_expired_reserved_holds_once(&reaper_state, now).await {
34 Ok(0) => {}
35 Ok(released) => {
36 info!(released, "reaped expired reserved budget holds");
37 }
38 Err(error) => {
39 warn!("reserved-hold reaper failed: {error}");
40 }
41 }
42 }
43 });
44 *state.reaper_handle.lock().await = Some(handle);
45}
46
47const PROXY_DRAIN_MARGIN: Duration = Duration::from_secs(5);
51
52fn authority_sibling_paths(receipt_path: &str) -> (PathBuf, PathBuf) {
53 let base = chio_store_sqlite::sqlite_filesystem_path(receipt_path);
54 let mut lock_root = base.as_os_str().to_os_string();
55 lock_root.push(".authority-locks");
56 let lock_root = PathBuf::from(lock_root);
57 (lock_root.join("authority.db"), lock_root)
58}
59
60fn prepare_authority_lock_root(path: &std::path::Path) -> Result<(), ProtectError> {
61 fs::create_dir_all(path).map_err(|error| ProtectError::Config(error.to_string()))?;
62 #[cfg(unix)]
63 {
64 use std::os::unix::fs::PermissionsExt;
65 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
66 .map_err(|error| ProtectError::Config(error.to_string()))?;
67 }
68 Ok(())
69}
70
71fn proxy_drain_timeout(upstream_request_timeout: Duration) -> Duration {
83 upstream_request_timeout.saturating_add(PROXY_DRAIN_MARGIN)
84}
85
86fn revocation_sibling_path(receipt_path: &str) -> String {
97 match receipt_path.split_once('?') {
98 Some((base, query)) => format!("{base}.revocations?{query}"),
99 None => format!("{receipt_path}.revocations"),
100 }
101}
102
103pub(crate) struct ReceiptLog {
105 pub(crate) receipts: Vec<HttpReceipt>,
106}
107
108pub(crate) struct ToolReceiptLog {
110 pub(crate) receipts: Vec<ChioReceipt>,
111}
112
113const RECEIPT_READINESS_PROBE_ID: &str = "__chio_readiness_probe__";
116
117pub(crate) struct SqliteReceiptStore {
118 connection: Connection,
119}
120
121impl SqliteReceiptStore {
122 pub(crate) fn open(path: &str) -> Result<Self, ProtectError> {
123 let connection = Connection::open(path)
124 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
125 connection
132 .execute_batch(
133 "
134 PRAGMA journal_mode = WAL;
135 PRAGMA synchronous = FULL;
136 PRAGMA busy_timeout = 5000;
137 PRAGMA foreign_keys = ON;
138 ",
139 )
140 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
141 connection
142 .execute_batch(
143 "
144 CREATE TABLE IF NOT EXISTS http_receipts (
145 id TEXT PRIMARY KEY,
146 receipt_json TEXT NOT NULL
147 );
148 CREATE TABLE IF NOT EXISTS tool_receipts (
149 id TEXT PRIMARY KEY,
150 receipt_json TEXT NOT NULL
151 );
152 CREATE TABLE IF NOT EXISTS revoked_capabilities (
153 capability_id TEXT PRIMARY KEY
154 );
155 ",
156 )
157 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
158 Ok(Self { connection })
159 }
160
161 pub(crate) fn is_reachable(&self) -> bool {
169 self.probe_receipt_write_path().is_ok()
170 }
171
172 fn probe_receipt_write_path(&self) -> Result<(), rusqlite::Error> {
173 let tx = self.connection.unchecked_transaction()?;
174 tx.execute(
175 "INSERT OR REPLACE INTO http_receipts (id, receipt_json) VALUES (?1, ?2)",
176 params![RECEIPT_READINESS_PROBE_ID, "{}"],
177 )?;
178 tx.execute(
179 "INSERT OR REPLACE INTO tool_receipts (id, receipt_json) VALUES (?1, ?2)",
180 params![RECEIPT_READINESS_PROBE_ID, "{}"],
181 )?;
182 tx.rollback()
183 }
184
185 pub(crate) fn load_receipts(&self) -> Result<Vec<HttpReceipt>, ProtectError> {
186 let mut statement = self
187 .connection
188 .prepare("SELECT receipt_json FROM http_receipts ORDER BY rowid ASC")
189 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
190 let rows = statement
191 .query_map([], |row| row.get::<_, String>(0))
192 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
193
194 let mut receipts = Vec::new();
195 for row in rows {
196 let receipt_json =
197 row.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
198 let receipt: HttpReceipt = serde_json::from_str(&receipt_json)
199 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
200 receipts.push(receipt);
201 }
202 Ok(receipts)
203 }
204
205 pub(crate) fn load_tool_receipts(&self) -> Result<Vec<ChioReceipt>, ProtectError> {
206 let mut statement = self
207 .connection
208 .prepare("SELECT receipt_json FROM tool_receipts ORDER BY rowid ASC")
209 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
210 let rows = statement
211 .query_map([], |row| row.get::<_, String>(0))
212 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
213
214 let mut receipts = Vec::new();
215 for row in rows {
216 let receipt_json =
217 row.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
218 let receipt: ChioReceipt = serde_json::from_str(&receipt_json)
219 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
220 receipts.push(receipt);
221 }
222 Ok(receipts)
223 }
224
225 pub(crate) fn append(&mut self, receipt: &HttpReceipt) -> Result<(), ProtectError> {
226 let receipt_json = serde_json::to_string(receipt)
227 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
228 self.connection
229 .execute(
230 "INSERT OR REPLACE INTO http_receipts (id, receipt_json) VALUES (?1, ?2)",
231 params![receipt.id, receipt_json],
232 )
233 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
234 Ok(())
235 }
236
237 pub(crate) fn append_tool_receipt(
238 &mut self,
239 receipt: &ChioReceipt,
240 ) -> Result<(), ProtectError> {
241 let receipt_json = serde_json::to_string(receipt)
242 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
243 self.connection
244 .execute(
245 "INSERT OR REPLACE INTO tool_receipts (id, receipt_json) VALUES (?1, ?2)",
246 params![receipt.id, receipt_json],
247 )
248 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
249 Ok(())
250 }
251
252 pub(crate) fn load_revoked_capability_ids(&self) -> Result<HashSet<String>, ProtectError> {
253 let mut statement = self
254 .connection
255 .prepare("SELECT capability_id FROM revoked_capabilities ORDER BY rowid ASC")
256 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
257 let rows = statement
258 .query_map([], |row| row.get::<_, String>(0))
259 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
260
261 let mut capability_ids = HashSet::new();
262 for row in rows {
263 let capability_id =
264 row.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
265 capability_ids.insert(capability_id);
266 }
267 Ok(capability_ids)
268 }
269
270 pub(crate) fn revoke_capability(&mut self, capability_id: &str) -> Result<(), ProtectError> {
271 self.connection
272 .execute(
273 "INSERT OR REPLACE INTO revoked_capabilities (capability_id) VALUES (?1)",
274 params![capability_id],
275 )
276 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
277 Ok(())
278 }
279}
280
281pub(crate) struct MintedRequestIdWindow {
292 ttl_secs: i64,
293 expiries: HashMap<String, i64>,
294}
295
296impl MintedRequestIdWindow {
297 pub(crate) fn new(ttl_secs: u64) -> Self {
298 Self {
299 ttl_secs: ttl_secs as i64,
300 expiries: HashMap::new(),
301 }
302 }
303
304 pub(crate) fn claim(&mut self, request_id: &str, now: i64) -> bool {
309 self.prune(now);
310 if self.expiries.contains_key(request_id) {
311 return false;
312 }
313 self.expiries
314 .insert(request_id.to_string(), now.saturating_add(self.ttl_secs));
315 true
316 }
317
318 pub(crate) fn release(&mut self, request_id: &str) {
322 self.expiries.remove(request_id);
323 }
324
325 fn prune(&mut self, now: i64) {
326 self.expiries.retain(|_, expiry| *expiry > now);
327 }
328
329 #[cfg(test)]
330 pub(crate) fn len(&self) -> usize {
331 self.expiries.len()
332 }
333}
334
335pub(crate) struct ProxyState {
337 pub(crate) evaluator: RequestEvaluator,
338 pub(crate) signer_keypair: Keypair,
339 pub(crate) upstream: String,
340 pub(crate) http_client: reqwest::Client,
341 pub(crate) egress_contract: HttpEgressContract,
342 pub(crate) approval_admin: ApprovalAdmin,
343 pub(crate) receipt_log: Mutex<ReceiptLog>,
344 pub(crate) tool_receipt_log: Mutex<ToolReceiptLog>,
345 pub(crate) receipt_store: Option<Mutex<SqliteReceiptStore>>,
346 pub(crate) revocation_store: Option<Arc<dyn chio_kernel::RevocationStore>>,
353 pub(crate) revoked_capability_ids: Mutex<HashSet<String>>,
354 pub(crate) trusted_capability_issuers: Vec<PublicKey>,
355 pub(crate) trusted_receipt_signers: Vec<PublicKey>,
356 pub(crate) sidecar_control_token: Option<String>,
357 pub(crate) budget_store: Option<Arc<dyn chio_kernel::budget_store::BudgetStore>>,
358 pub(crate) mediation_hold_capable: bool,
366 pub(crate) mediation_kernel: Option<Mutex<chio_kernel::ChioKernel>>,
373 pub(crate) minted_request_ids: Mutex<MintedRequestIdWindow>,
380 pub(crate) reaper_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
386 pub(crate) allow_advisory: bool,
387 pub(crate) receipt_backend: &'static str,
388 pub(crate) revocation_backend: &'static str,
389}
390
391impl ProxyState {
392 pub(crate) async fn capability_is_revoked(&self, capability_id: &str) -> bool {
398 if self
399 .revoked_capability_ids
400 .lock()
401 .await
402 .contains(capability_id)
403 {
404 return true;
405 }
406 if let Some(revocation_store) = &self.revocation_store {
407 match revocation_store.is_revoked(capability_id) {
408 Ok(false) => {}
409 Ok(true) => return true,
410 Err(error) => {
411 warn!("failed to query durable revocation store: {error}");
412 return true;
413 }
414 }
415 }
416 false
417 }
418}
419
420impl ProxyState {
421 pub(crate) async fn readiness_status(&self) -> SidecarStatus {
429 if let Some(store) = &self.receipt_store {
430 let store = store.lock().await;
431 if !store.is_reachable() {
432 return SidecarStatus::Unhealthy;
433 }
434 }
435 SidecarStatus::Healthy
436 }
437}
438
439pub struct ProtectProxy {
441 config: ProtectConfig,
442 payment_adapter: Option<Box<dyn chio_kernel::PaymentAdapter>>,
448}
449
450impl ProtectProxy {
451 pub fn new(config: ProtectConfig) -> Self {
452 Self {
453 config,
454 payment_adapter: None,
455 }
456 }
457
458 #[must_use]
465 pub fn with_payment_adapter(
466 mut self,
467 payment_adapter: Option<Box<dyn chio_kernel::PaymentAdapter>>,
468 ) -> Self {
469 self.payment_adapter = payment_adapter;
470 self
471 }
472
473 async fn load_spec_content(&self) -> Result<String, ProtectError> {
474 if let Some(spec_content) = &self.config.spec_content {
475 return Ok(spec_content.clone());
476 }
477 if let Some(spec_path) = &self.config.spec_path {
478 return load_spec_from_file(spec_path);
479 }
480 discover_spec(&self.config.upstream).await
481 }
482
483 fn build_routes(spec_content: &str) -> Result<Vec<RouteEntry>, ProtectError> {
486 let spec = chio_openapi::OpenApiSpec::parse(spec_content)?;
487 let mut routes = Vec::new();
488
489 for (path, path_item) in &spec.paths {
490 for (method_str, operation) in &path_item.operations {
491 let method = match method_str.as_str() {
492 "GET" => HttpMethod::Get,
493 "POST" => HttpMethod::Post,
494 "PUT" => HttpMethod::Put,
495 "PATCH" => HttpMethod::Patch,
496 "DELETE" => HttpMethod::Delete,
497 "HEAD" => HttpMethod::Head,
498 "OPTIONS" => HttpMethod::Options,
499 _ => continue,
500 };
501
502 let extensions = ChioExtensions::from_operation(&operation.raw);
503 let policy = DefaultPolicy::for_method_with_extensions(method, &extensions);
504 routes.push(RouteEntry {
505 pattern: path.clone(),
506 method,
507 operation_id: operation.operation_id.clone(),
508 policy,
509 });
510 }
511 }
512
513 Ok(routes)
514 }
515
516 pub async fn run(self) -> Result<(), ProtectError> {
518 self.run_with_observer(|_| {}).await
519 }
520
521 pub async fn run_with_observer<F>(self, observer: F) -> Result<(), ProtectError>
530 where
531 F: FnOnce(SocketAddr),
532 {
533 let durable_receipt_db: Option<&str> = self
547 .config
548 .receipt_db
549 .as_deref()
550 .filter(|path| !chio_store_sqlite::is_in_memory_sqlite_path(path));
551
552 if durable_receipt_db.is_none() && !self.config.allow_ephemeral_receipts {
553 return Err(ProtectError::Config(
554 "refusing to start without a durable receipt store: set receipt_db to a durable \
555 SQLite path, or set allow_ephemeral_receipts to run with in-memory receipts that \
556 are lost on every restart"
557 .to_string(),
558 ));
559 }
560
561 if durable_receipt_db.is_some() {
562 chio_store_sqlite::SqliteAuthorityStore::ensure_serving_supported()
563 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
564 }
565
566 let spec_content = self.load_spec_content().await?;
567 let routes = Self::build_routes(&spec_content)?;
568 let route_count = routes.len();
569
570 let keypair = match &self.config.signer_seed_hex {
571 Some(seed_hex) => Keypair::from_seed_hex(seed_hex)
572 .map_err(|error| ProtectError::Config(error.to_string()))?,
573 None => Keypair::generate(),
574 };
575 let policy_hash = chio_core_types::sha256_hex(spec_content.as_bytes());
576
577 let durable_receipt_store: Option<Arc<dyn chio_kernel::ReceiptStore>> =
584 match durable_receipt_db {
585 Some(path) => Some(Arc::new(
586 chio_store_sqlite::SqliteReceiptStore::open(path)
587 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
588 )),
589 None => None,
590 };
591
592 let approval_store: Arc<dyn ApprovalStore> = if let Some(path) = durable_receipt_db {
593 Arc::new(
594 SqliteApprovalStore::open_colocated_with_receipt_store(path)
595 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
596 )
597 } else {
598 Arc::new(InMemoryApprovalStore::new())
599 };
600 let threshold_collector_store: Arc<dyn ThresholdApprovalCollectorStore> =
601 if let Some(path) = durable_receipt_db {
602 Arc::new(
603 SqliteApprovalStore::open_colocated_with_receipt_store(path)
604 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
605 )
606 } else {
607 Arc::new(InMemoryThresholdApprovalCollectorStore::new())
608 };
609 let threshold_collector = ThresholdApprovalCollector::new(
610 threshold_collector_store,
611 policy_hash.clone(),
612 vec![keypair.public_key()],
613 );
614
615 let mut trusted_capability_issuers = self.config.trusted_capability_issuers.clone();
616 let signer_public_key = keypair.public_key();
617 if !trusted_capability_issuers.contains(&signer_public_key) {
618 trusted_capability_issuers.push(signer_public_key.clone());
619 }
620 let trusted_receipt_signers = vec![signer_public_key];
621
622 let revocation_store: Option<Arc<dyn chio_kernel::RevocationStore>> =
629 match durable_receipt_db {
630 Some(path) => Some(Arc::new(
631 chio_store_sqlite::SqliteRevocationStore::open(revocation_sibling_path(path))
632 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
633 )),
634 None => Some(Arc::new(chio_kernel::InMemoryRevocationStore::new())),
635 };
636
637 let durable_admission = match durable_receipt_db {
638 Some(path) => {
639 let (database, lock_root) = authority_sibling_paths(path);
640 prepare_authority_lock_root(&lock_root)?;
641 chio_store_sqlite::SqliteAuthorityStore::provision(&database, &lock_root)
642 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
643 let authority =
644 chio_store_sqlite::SqliteAuthorityStore::open_serving(&database, &lock_root)
645 .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
646 Some(DurableAdmissionStores {
647 store: Arc::new(authority.admission_operation_store()),
648 outcome_store: Arc::new(authority.tool_outcome_store()),
649 fence: authority.mutation_fence(),
650 })
651 }
652 None => None,
653 };
654
655 let evaluator = RequestEvaluator::new_with_durable_stores_and_admission(
656 routes,
657 keypair.clone(),
658 policy_hash,
659 Arc::clone(&approval_store),
660 self.config.trusted_capability_issuers.clone(),
661 durable_receipt_store,
662 revocation_store.clone(),
663 durable_admission.clone(),
664 self.config.allow_ephemeral_receipts,
665 )
666 .map_err(|error| ProtectError::Config(error.to_string()))?;
667 let receipt_backend = evaluator.receipt_backend();
668 let revocation_backend = evaluator.revocation_backend();
669
670 let (receipt_log, tool_receipt_log, receipt_store, mut revoked_capability_ids) =
671 if let Some(path) = &self.config.receipt_db {
672 let store = SqliteReceiptStore::open(path)?;
673 let receipts = store.load_receipts()?;
674 let tool_receipts = store.load_tool_receipts()?;
675 let revoked_capability_ids = store.load_revoked_capability_ids()?;
676 (
677 ReceiptLog { receipts },
678 ToolReceiptLog {
679 receipts: tool_receipts,
680 },
681 Some(Mutex::new(store)),
682 revoked_capability_ids,
683 )
684 } else {
685 (
686 ReceiptLog {
687 receipts: Vec::new(),
688 },
689 ToolReceiptLog {
690 receipts: Vec::new(),
691 },
692 None,
693 HashSet::new(),
694 )
695 };
696
697 if let Some(path) = self.config.revocation_db.as_deref() {
704 let durable = load_revocation_db_ids(&self.config)?;
705 let loaded = durable.len();
706 revoked_capability_ids.extend(durable);
707 info!(
708 revocation_db = path,
709 loaded,
710 enforced = revoked_capability_ids.len(),
711 "chio api protect: loaded durable revocations from --revocation-db; \
712 enforced on /v1/evaluate and every revoked-capability path. \
713 Revocations recorded after startup are not observed here: they \
714 require a sidecar restart or the in-process \
715 /v1/capabilities/release (or --control-url) channel"
716 );
717 }
718
719 let egress_contract = default_upstream_egress_contract(&self.config.upstream)?;
720 let http_client = client_builder_with_contract(&egress_contract)
721 .timeout(self.config.upstream_request_timeout)
722 .build()?;
723 let configured_budget_store = build_budget_store(&self.config)?;
724 let mediation_hold_capable = configured_budget_store
725 .as_ref()
726 .map(|configured| configured.hold_capable)
727 .unwrap_or(false);
728 let budget_store = configured_budget_store.map(|configured| configured.store);
729
730 if let Some(store) = budget_store.as_ref() {
740 match store.count_open_holds() {
741 Ok(0) => {}
742 Ok(count) => {
743 warn!(
744 count,
745 "startup: open budget hold(s) left reserved pending \
746 receipt-log arbitration; automatic reconcile requires \
747 the durable receipt log (ADR-0013) arbitration map"
748 );
749 }
750 Err(error) => {
751 warn!("startup: failed to count open budget holds: {error}");
752 }
753 }
754 }
755
756 let payment_adapter = self.payment_adapter;
763 let mediation_kernel = match budget_store.as_ref() {
764 Some(store) => Some(Mutex::new(build_mediation_kernel(
765 &keypair,
766 Arc::clone(store),
767 &trusted_capability_issuers,
768 Vec::new(),
769 payment_adapter,
770 durable_admission,
771 )?)),
772 None => None,
773 };
774
775 let state = Arc::new(ProxyState {
776 evaluator,
777 signer_keypair: keypair,
778 upstream: self.config.upstream.clone(),
779 http_client,
780 egress_contract,
781 approval_admin: ApprovalAdmin::with_threshold_collector(
782 approval_store,
783 threshold_collector,
784 ),
785 receipt_log: Mutex::new(receipt_log),
786 tool_receipt_log: Mutex::new(tool_receipt_log),
787 receipt_store,
788 revocation_store,
789 revoked_capability_ids: Mutex::new(revoked_capability_ids),
790 trusted_capability_issuers,
791 trusted_receipt_signers,
792 sidecar_control_token: self.config.sidecar_control_token.clone(),
793 budget_store,
794 mediation_hold_capable,
795 mediation_kernel,
796 minted_request_ids: Mutex::new(MintedRequestIdWindow::new(
797 chio_kernel::DEFAULT_EXECUTION_NONCE_TTL_SECS,
798 )),
799 reaper_handle: Mutex::new(None),
800 allow_advisory: self.config.allow_advisory,
801 receipt_backend,
802 revocation_backend,
803 });
804
805 spawn_reserved_hold_reaper(&state).await;
811
812 let app = build_app(Arc::clone(&state));
813
814 let listener = tokio::net::TcpListener::bind(&self.config.listen_addr)
815 .await
816 .map_err(|e| {
817 ProtectError::Config(format!("cannot bind {}: {e}", self.config.listen_addr))
818 })?;
819
820 let local_addr = listener.local_addr().map_err(|error| {
821 ProtectError::Config(format!("cannot resolve bound address: {error}"))
822 })?;
823
824 info!(
825 has_budget_store = state.budget_store.is_some(),
826 "chio api protect: mediation layer ready"
827 );
828 info!(
829 "chio api protect: proxying {} routes to {} on {}",
830 route_count, self.config.upstream, local_addr
831 );
832
833 observer(local_addr);
834
835 let hygiene = ServeHygieneConfig {
844 request_timeout: None,
845 drain_timeout: proxy_drain_timeout(self.config.upstream_request_timeout),
846 ..ServeHygieneConfig::default()
847 };
848 let app = apply_server_hygiene(app, &hygiene);
849 let controller = ShutdownController::install();
850 let listener =
855 MaxConnListener::new(listener, hygiene.max_connections.unwrap_or(usize::MAX));
856 let server = axum::serve(
857 listener,
858 app.into_make_service_with_connect_info::<CappedPeerAddr>(),
859 )
860 .with_graceful_shutdown(controller.signalled());
861
862 let serve_result = run_until_drained(
866 server,
867 controller.subscribe(),
868 hygiene.drain_timeout,
869 async { Ok::<(), String>(()) },
870 )
871 .await
872 .map(|_outcome| ())
873 .map_err(protect_serve_error);
874
875 if let Some(handle) = state.reaper_handle.lock().await.take() {
879 handle.abort();
880 }
881
882 serve_result?;
883
884 Ok(())
885 }
886
887 pub fn routes_from_spec(spec_content: &str) -> Result<Vec<RouteEntry>, ProtectError> {
889 Self::build_routes(spec_content)
890 }
891}
892
893#[cfg(test)]
894mod proxy_builder_tests {
895 use super::*;
896
897 fn minimal_config() -> ProtectConfig {
898 ProtectConfig {
899 upstream: "http://127.0.0.1:1".to_string(),
900 spec_content: Some("{}".to_string()),
901 spec_path: None,
902 listen_addr: "127.0.0.1:0".to_string(),
903 receipt_db: None,
904 allow_ephemeral_receipts: true,
905 sidecar_control_token: None,
906 signer_seed_hex: None,
907 trusted_capability_issuers: Vec::new(),
908 control_url: None,
909 control_token: None,
910 budget_db: None,
911 revocation_db: None,
912 require_nonce: false,
913 allow_advisory: false,
914 upstream_request_timeout: crate::DEFAULT_UPSTREAM_REQUEST_TIMEOUT,
915 }
916 }
917
918 #[test]
919 fn with_payment_adapter_threads_adapter_and_defaults_none() {
920 let default = ProtectProxy::new(minimal_config());
925 assert!(
926 default.payment_adapter.is_none(),
927 "a proxy defaults to no payment adapter, keeping governed MustPrepay denied"
928 );
929
930 let configured = ProtectProxy::new(minimal_config()).with_payment_adapter(Some(Box::new(
931 chio_kernel::payment::SimPaymentAdapter::new(),
932 )));
933 assert!(
934 configured.payment_adapter.is_some(),
935 "with_payment_adapter must thread the configured adapter into the proxy"
936 );
937 }
938}
939
940#[cfg(all(test, windows))]
941mod windows_authority_tests {
942 use super::*;
943 use std::sync::atomic::{AtomicBool, Ordering};
944
945 #[tokio::test]
946 async fn durable_startup_rejects_windows_before_api_protect_mutation(
947 ) -> Result<(), Box<dyn std::error::Error>> {
948 let directory = tempfile::tempdir()?;
949 let state_parent = directory.path().join("state");
950 let receipt_database = state_parent.join("receipts.sqlite3");
951 let receipt_database_string = receipt_database.to_string_lossy().into_owned();
952 let (authority_database, authority_lock_root) =
953 authority_sibling_paths(&receipt_database_string);
954 let missing_spec = directory.path().join("missing-openapi.json");
955 let observer_called = AtomicBool::new(false);
956
957 let result = ProtectProxy::new(ProtectConfig {
958 upstream: "http://127.0.0.1:1".to_string(),
959 spec_content: None,
960 spec_path: Some(missing_spec.to_string_lossy().into_owned()),
961 listen_addr: "127.0.0.1:0".to_string(),
962 receipt_db: Some(receipt_database_string),
963 allow_ephemeral_receipts: false,
964 sidecar_control_token: None,
965 signer_seed_hex: None,
966 trusted_capability_issuers: Vec::new(),
967 control_url: None,
968 control_token: None,
969 budget_db: None,
970 revocation_db: None,
971 require_nonce: false,
972 allow_advisory: false,
973 upstream_request_timeout: crate::DEFAULT_UPSTREAM_REQUEST_TIMEOUT,
974 })
975 .run_with_observer(|_| observer_called.store(true, Ordering::SeqCst))
976 .await;
977
978 let error = match result {
979 Ok(()) => {
980 return Err(std::io::Error::other(
981 "Windows durable API-protect startup unexpectedly succeeded",
982 )
983 .into());
984 }
985 Err(error) => error,
986 };
987
988 assert!(
989 matches!(
990 &error,
991 ProtectError::ReceiptStore(message)
992 if message.contains(
993 "sqlite authority serving requires Unix file identity and positioned I/O"
994 )
995 ),
996 "the platform preflight must fail before attempting to load the missing spec: {error}"
997 );
998 assert!(!observer_called.load(Ordering::SeqCst));
999 assert!(!state_parent.exists());
1000 assert!(!receipt_database.exists());
1001 assert!(!authority_database.exists());
1002 assert!(!authority_lock_root.exists());
1003 Ok(())
1004 }
1005}
1006
1007fn protect_serve_error(error: ServeError) -> ProtectError {
1008 match error {
1009 ServeError::Io(source) => ProtectError::Io(source),
1010 ServeError::Flush(message) => ProtectError::Io(std::io::Error::other(message)),
1011 }
1012}
1013
1014#[cfg(test)]
1015mod durability_tests {
1016 use super::{authority_sibling_paths, revocation_sibling_path, SqliteReceiptStore};
1017 use chio_test_support::prelude::*;
1018
1019 #[test]
1020 fn revocation_sibling_path_appends_suffix_to_a_plain_path() {
1021 assert_eq!(
1022 revocation_sibling_path("/var/lib/chio/receipts.db"),
1023 "/var/lib/chio/receipts.db.revocations"
1024 );
1025 }
1026
1027 #[test]
1028 fn revocation_sibling_path_keeps_the_uri_query_after_the_suffix() {
1029 assert_eq!(
1033 revocation_sibling_path("file:/var/lib/chio/receipts.db?mode=rwc"),
1034 "file:/var/lib/chio/receipts.db.revocations?mode=rwc"
1035 );
1036 }
1037
1038 #[test]
1039 fn authority_sibling_paths_resolve_the_receipt_uri_to_filesystem_paths() {
1040 let (database, lock_root) =
1041 authority_sibling_paths("file:/var/lib/chio/receipts.db?mode=rwc");
1042 assert_eq!(
1043 database,
1044 std::path::Path::new("/var/lib/chio/receipts.db.authority-locks/authority.db")
1045 );
1046 assert_eq!(
1047 lock_root,
1048 std::path::Path::new("/var/lib/chio/receipts.db.authority-locks")
1049 );
1050 }
1051
1052 #[test]
1053 fn http_receipt_store_open_configures_wal_and_a_busy_timeout() {
1054 let mut path = std::env::temp_dir();
1055 path.push(format!("chio-http-receipts-{}.db", uuid::Uuid::now_v7()));
1056 let path_str = path.to_string_lossy().into_owned();
1057
1058 let store = SqliteReceiptStore::open(&path_str).test_unwrap();
1059
1060 let busy_timeout: i64 = store
1061 .connection
1062 .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
1063 .test_unwrap();
1064 assert!(
1065 busy_timeout >= 5000,
1066 "the http receipt writer must share the receipt store busy timeout, got {busy_timeout}"
1067 );
1068
1069 let journal_mode: String = store
1070 .connection
1071 .query_row("PRAGMA journal_mode", [], |row| row.get(0))
1072 .test_unwrap();
1073 assert!(
1074 journal_mode.eq_ignore_ascii_case("wal"),
1075 "the http receipt writer must run in WAL mode, got {journal_mode}"
1076 );
1077
1078 let _ = std::fs::remove_file(&path);
1079 }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::{proxy_drain_timeout, PROXY_DRAIN_MARGIN};
1085 use crate::DEFAULT_UPSTREAM_REQUEST_TIMEOUT;
1086 use chio_http_serve::DEFAULT_DRAIN_TIMEOUT;
1087 use std::time::Duration;
1088
1089 #[test]
1094 fn drain_window_always_outlasts_the_configured_upstream_timeout() {
1095 for secs in [1u64, 20, 30, 60, 300] {
1096 let upstream = Duration::from_secs(secs);
1097 assert!(
1098 proxy_drain_timeout(upstream) > upstream,
1099 "drain window must outlast a {secs}s upstream timeout"
1100 );
1101 assert_eq!(proxy_drain_timeout(upstream), upstream + PROXY_DRAIN_MARGIN);
1102 }
1103 }
1104
1105 #[test]
1108 fn default_upstream_timeout_preserves_the_default_drain_window() {
1109 assert_eq!(
1110 proxy_drain_timeout(DEFAULT_UPSTREAM_REQUEST_TIMEOUT),
1111 DEFAULT_DRAIN_TIMEOUT
1112 );
1113 }
1114}