use super::*;
use chio_http_serve::{
apply_server_hygiene, run_until_drained, ServeError, ServeHygieneConfig, ShutdownController,
};
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
const RESERVED_HOLD_REAP_INTERVAL_SECS: u64 = 30;
pub(crate) async fn spawn_reserved_hold_reaper(state: &Arc<ProxyState>) {
if state.mediation_kernel.is_none() {
return;
}
let reaper_state = Arc::clone(state);
let handle = tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(
RESERVED_HOLD_REAP_INTERVAL_SECS,
));
ticker.tick().await;
loop {
ticker.tick().await;
let now = chrono::Utc::now().timestamp();
match reap_expired_reserved_holds_once(&reaper_state, now).await {
Ok(0) => {}
Ok(released) => {
info!(released, "reaped expired reserved budget holds");
}
Err(error) => {
warn!("reserved-hold reaper failed: {error}");
}
}
}
});
*state.reaper_handle.lock().await = Some(handle);
}
const PROXY_DRAIN_MARGIN: Duration = Duration::from_secs(5);
fn authority_sibling_paths(receipt_path: &str) -> (PathBuf, PathBuf) {
let base = chio_store_sqlite::sqlite_filesystem_path(receipt_path);
let mut lock_root = base.as_os_str().to_os_string();
lock_root.push(".authority-locks");
let lock_root = PathBuf::from(lock_root);
(lock_root.join("authority.db"), lock_root)
}
fn prepare_authority_lock_root(path: &std::path::Path) -> Result<(), ProtectError> {
fs::create_dir_all(path).map_err(|error| ProtectError::Config(error.to_string()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
.map_err(|error| ProtectError::Config(error.to_string()))?;
}
Ok(())
}
fn proxy_drain_timeout(upstream_request_timeout: Duration) -> Duration {
upstream_request_timeout.saturating_add(PROXY_DRAIN_MARGIN)
}
fn revocation_sibling_path(receipt_path: &str) -> String {
match receipt_path.split_once('?') {
Some((base, query)) => format!("{base}.revocations?{query}"),
None => format!("{receipt_path}.revocations"),
}
}
pub(crate) struct ReceiptLog {
pub(crate) receipts: Vec<HttpReceipt>,
}
pub(crate) struct ToolReceiptLog {
pub(crate) receipts: Vec<ChioReceipt>,
}
const RECEIPT_READINESS_PROBE_ID: &str = "__chio_readiness_probe__";
pub(crate) struct SqliteReceiptStore {
connection: Connection,
}
impl SqliteReceiptStore {
pub(crate) fn open(path: &str) -> Result<Self, ProtectError> {
let connection = Connection::open(path)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
connection
.execute_batch(
"
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;
",
)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
connection
.execute_batch(
"
CREATE TABLE IF NOT EXISTS http_receipts (
id TEXT PRIMARY KEY,
receipt_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tool_receipts (
id TEXT PRIMARY KEY,
receipt_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS revoked_capabilities (
capability_id TEXT PRIMARY KEY
);
",
)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
Ok(Self { connection })
}
pub(crate) fn is_reachable(&self) -> bool {
self.probe_receipt_write_path().is_ok()
}
fn probe_receipt_write_path(&self) -> Result<(), rusqlite::Error> {
let tx = self.connection.unchecked_transaction()?;
tx.execute(
"INSERT OR REPLACE INTO http_receipts (id, receipt_json) VALUES (?1, ?2)",
params![RECEIPT_READINESS_PROBE_ID, "{}"],
)?;
tx.execute(
"INSERT OR REPLACE INTO tool_receipts (id, receipt_json) VALUES (?1, ?2)",
params![RECEIPT_READINESS_PROBE_ID, "{}"],
)?;
tx.rollback()
}
pub(crate) fn load_receipts(&self) -> Result<Vec<HttpReceipt>, ProtectError> {
let mut statement = self
.connection
.prepare("SELECT receipt_json FROM http_receipts ORDER BY rowid ASC")
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
let rows = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
let mut receipts = Vec::new();
for row in rows {
let receipt_json =
row.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
let receipt: HttpReceipt = serde_json::from_str(&receipt_json)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
receipts.push(receipt);
}
Ok(receipts)
}
pub(crate) fn load_tool_receipts(&self) -> Result<Vec<ChioReceipt>, ProtectError> {
let mut statement = self
.connection
.prepare("SELECT receipt_json FROM tool_receipts ORDER BY rowid ASC")
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
let rows = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
let mut receipts = Vec::new();
for row in rows {
let receipt_json =
row.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
let receipt: ChioReceipt = serde_json::from_str(&receipt_json)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
receipts.push(receipt);
}
Ok(receipts)
}
pub(crate) fn append(&mut self, receipt: &HttpReceipt) -> Result<(), ProtectError> {
let receipt_json = serde_json::to_string(receipt)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
self.connection
.execute(
"INSERT OR REPLACE INTO http_receipts (id, receipt_json) VALUES (?1, ?2)",
params![receipt.id, receipt_json],
)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
Ok(())
}
pub(crate) fn append_tool_receipt(
&mut self,
receipt: &ChioReceipt,
) -> Result<(), ProtectError> {
let receipt_json = serde_json::to_string(receipt)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
self.connection
.execute(
"INSERT OR REPLACE INTO tool_receipts (id, receipt_json) VALUES (?1, ?2)",
params![receipt.id, receipt_json],
)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
Ok(())
}
pub(crate) fn load_revoked_capability_ids(&self) -> Result<HashSet<String>, ProtectError> {
let mut statement = self
.connection
.prepare("SELECT capability_id FROM revoked_capabilities ORDER BY rowid ASC")
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
let rows = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
let mut capability_ids = HashSet::new();
for row in rows {
let capability_id =
row.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
capability_ids.insert(capability_id);
}
Ok(capability_ids)
}
pub(crate) fn revoke_capability(&mut self, capability_id: &str) -> Result<(), ProtectError> {
self.connection
.execute(
"INSERT OR REPLACE INTO revoked_capabilities (capability_id) VALUES (?1)",
params![capability_id],
)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
Ok(())
}
}
pub(crate) struct MintedRequestIdWindow {
ttl_secs: i64,
expiries: HashMap<String, i64>,
}
impl MintedRequestIdWindow {
pub(crate) fn new(ttl_secs: u64) -> Self {
Self {
ttl_secs: ttl_secs as i64,
expiries: HashMap::new(),
}
}
pub(crate) fn claim(&mut self, request_id: &str, now: i64) -> bool {
self.prune(now);
if self.expiries.contains_key(request_id) {
return false;
}
self.expiries
.insert(request_id.to_string(), now.saturating_add(self.ttl_secs));
true
}
pub(crate) fn release(&mut self, request_id: &str) {
self.expiries.remove(request_id);
}
fn prune(&mut self, now: i64) {
self.expiries.retain(|_, expiry| *expiry > now);
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.expiries.len()
}
}
pub(crate) struct ProxyState {
pub(crate) evaluator: RequestEvaluator,
pub(crate) signer_keypair: Keypair,
pub(crate) upstream: String,
pub(crate) http_client: reqwest::Client,
pub(crate) egress_contract: HttpEgressContract,
pub(crate) approval_admin: ApprovalAdmin,
pub(crate) receipt_log: Mutex<ReceiptLog>,
pub(crate) tool_receipt_log: Mutex<ToolReceiptLog>,
pub(crate) receipt_store: Option<Mutex<SqliteReceiptStore>>,
pub(crate) revocation_store: Option<Arc<dyn chio_kernel::RevocationStore>>,
pub(crate) revoked_capability_ids: Mutex<HashSet<String>>,
pub(crate) trusted_capability_issuers: Vec<PublicKey>,
pub(crate) trusted_receipt_signers: Vec<PublicKey>,
pub(crate) sidecar_control_token: Option<String>,
pub(crate) budget_store: Option<Arc<dyn chio_kernel::budget_store::BudgetStore>>,
pub(crate) mediation_hold_capable: bool,
pub(crate) mediation_kernel: Option<Mutex<chio_kernel::ChioKernel>>,
pub(crate) minted_request_ids: Mutex<MintedRequestIdWindow>,
pub(crate) reaper_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
pub(crate) allow_advisory: bool,
pub(crate) receipt_backend: &'static str,
pub(crate) revocation_backend: &'static str,
}
impl ProxyState {
pub(crate) async fn capability_is_revoked(&self, capability_id: &str) -> bool {
if self
.revoked_capability_ids
.lock()
.await
.contains(capability_id)
{
return true;
}
if let Some(revocation_store) = &self.revocation_store {
match revocation_store.is_revoked(capability_id) {
Ok(false) => {}
Ok(true) => return true,
Err(error) => {
warn!("failed to query durable revocation store: {error}");
return true;
}
}
}
false
}
}
impl ProxyState {
pub(crate) async fn readiness_status(&self) -> SidecarStatus {
if let Some(store) = &self.receipt_store {
let store = store.lock().await;
if !store.is_reachable() {
return SidecarStatus::Unhealthy;
}
}
SidecarStatus::Healthy
}
}
pub struct ProtectProxy {
config: ProtectConfig,
payment_adapter: Option<Box<dyn chio_kernel::PaymentAdapter>>,
}
impl ProtectProxy {
pub fn new(config: ProtectConfig) -> Self {
Self {
config,
payment_adapter: None,
}
}
#[must_use]
pub fn with_payment_adapter(
mut self,
payment_adapter: Option<Box<dyn chio_kernel::PaymentAdapter>>,
) -> Self {
self.payment_adapter = payment_adapter;
self
}
async fn load_spec_content(&self) -> Result<String, ProtectError> {
if let Some(spec_content) = &self.config.spec_content {
return Ok(spec_content.clone());
}
if let Some(spec_path) = &self.config.spec_path {
return load_spec_from_file(spec_path);
}
discover_spec(&self.config.upstream).await
}
fn build_routes(spec_content: &str) -> Result<Vec<RouteEntry>, ProtectError> {
let spec = chio_openapi::OpenApiSpec::parse(spec_content)?;
let mut routes = Vec::new();
for (path, path_item) in &spec.paths {
for (method_str, operation) in &path_item.operations {
let method = match method_str.as_str() {
"GET" => HttpMethod::Get,
"POST" => HttpMethod::Post,
"PUT" => HttpMethod::Put,
"PATCH" => HttpMethod::Patch,
"DELETE" => HttpMethod::Delete,
"HEAD" => HttpMethod::Head,
"OPTIONS" => HttpMethod::Options,
_ => continue,
};
let extensions = ChioExtensions::from_operation(&operation.raw);
let policy = DefaultPolicy::for_method_with_extensions(method, &extensions);
routes.push(RouteEntry {
pattern: path.clone(),
method,
operation_id: operation.operation_id.clone(),
policy,
});
}
}
Ok(routes)
}
pub async fn run(self) -> Result<(), ProtectError> {
self.run_with_observer(|_| {}).await
}
pub async fn run_with_observer<F>(self, observer: F) -> Result<(), ProtectError>
where
F: FnOnce(SocketAddr),
{
let durable_receipt_db: Option<&str> = self
.config
.receipt_db
.as_deref()
.filter(|path| !chio_store_sqlite::is_in_memory_sqlite_path(path));
if durable_receipt_db.is_none() && !self.config.allow_ephemeral_receipts {
return Err(ProtectError::Config(
"refusing to start without a durable receipt store: set receipt_db to a durable \
SQLite path, or set allow_ephemeral_receipts to run with in-memory receipts that \
are lost on every restart"
.to_string(),
));
}
if durable_receipt_db.is_some() {
chio_store_sqlite::SqliteAuthorityStore::ensure_serving_supported()
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
}
let spec_content = self.load_spec_content().await?;
let routes = Self::build_routes(&spec_content)?;
let route_count = routes.len();
let keypair = match &self.config.signer_seed_hex {
Some(seed_hex) => Keypair::from_seed_hex(seed_hex)
.map_err(|error| ProtectError::Config(error.to_string()))?,
None => Keypair::generate(),
};
let policy_hash = chio_core_types::sha256_hex(spec_content.as_bytes());
let durable_receipt_store: Option<Arc<dyn chio_kernel::ReceiptStore>> =
match durable_receipt_db {
Some(path) => Some(Arc::new(
chio_store_sqlite::SqliteReceiptStore::open(path)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
)),
None => None,
};
let approval_store: Arc<dyn ApprovalStore> = if let Some(path) = durable_receipt_db {
Arc::new(
SqliteApprovalStore::open_colocated_with_receipt_store(path)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
)
} else {
Arc::new(InMemoryApprovalStore::new())
};
let threshold_collector_store: Arc<dyn ThresholdApprovalCollectorStore> =
if let Some(path) = durable_receipt_db {
Arc::new(
SqliteApprovalStore::open_colocated_with_receipt_store(path)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
)
} else {
Arc::new(InMemoryThresholdApprovalCollectorStore::new())
};
let threshold_collector = ThresholdApprovalCollector::new(
threshold_collector_store,
policy_hash.clone(),
vec![keypair.public_key()],
);
let mut trusted_capability_issuers = self.config.trusted_capability_issuers.clone();
let signer_public_key = keypair.public_key();
if !trusted_capability_issuers.contains(&signer_public_key) {
trusted_capability_issuers.push(signer_public_key.clone());
}
let trusted_receipt_signers = vec![signer_public_key];
let revocation_store: Option<Arc<dyn chio_kernel::RevocationStore>> =
match durable_receipt_db {
Some(path) => Some(Arc::new(
chio_store_sqlite::SqliteRevocationStore::open(revocation_sibling_path(path))
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
)),
None => Some(Arc::new(chio_kernel::InMemoryRevocationStore::new())),
};
let durable_admission = match durable_receipt_db {
Some(path) => {
let (database, lock_root) = authority_sibling_paths(path);
prepare_authority_lock_root(&lock_root)?;
chio_store_sqlite::SqliteAuthorityStore::provision(&database, &lock_root)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
let authority =
chio_store_sqlite::SqliteAuthorityStore::open_serving(&database, &lock_root)
.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
Some(DurableAdmissionStores {
store: Arc::new(authority.admission_operation_store()),
outcome_store: Arc::new(authority.tool_outcome_store()),
fence: authority.mutation_fence(),
})
}
None => None,
};
let evaluator = RequestEvaluator::new_with_durable_stores_and_admission(
routes,
keypair.clone(),
policy_hash,
Arc::clone(&approval_store),
self.config.trusted_capability_issuers.clone(),
durable_receipt_store,
revocation_store.clone(),
durable_admission.clone(),
self.config.allow_ephemeral_receipts,
)
.map_err(|error| ProtectError::Config(error.to_string()))?;
let receipt_backend = evaluator.receipt_backend();
let revocation_backend = evaluator.revocation_backend();
let (receipt_log, tool_receipt_log, receipt_store, mut revoked_capability_ids) =
if let Some(path) = &self.config.receipt_db {
let store = SqliteReceiptStore::open(path)?;
let receipts = store.load_receipts()?;
let tool_receipts = store.load_tool_receipts()?;
let revoked_capability_ids = store.load_revoked_capability_ids()?;
(
ReceiptLog { receipts },
ToolReceiptLog {
receipts: tool_receipts,
},
Some(Mutex::new(store)),
revoked_capability_ids,
)
} else {
(
ReceiptLog {
receipts: Vec::new(),
},
ToolReceiptLog {
receipts: Vec::new(),
},
None,
HashSet::new(),
)
};
if let Some(path) = self.config.revocation_db.as_deref() {
let durable = load_revocation_db_ids(&self.config)?;
let loaded = durable.len();
revoked_capability_ids.extend(durable);
info!(
revocation_db = path,
loaded,
enforced = revoked_capability_ids.len(),
"chio api protect: loaded durable revocations from --revocation-db; \
enforced on /v1/evaluate and every revoked-capability path. \
Revocations recorded after startup are not observed here: they \
require a sidecar restart or the in-process \
/v1/capabilities/release (or --control-url) channel"
);
}
let egress_contract = default_upstream_egress_contract(&self.config.upstream)?;
let http_client = client_builder_with_contract(&egress_contract)
.timeout(self.config.upstream_request_timeout)
.build()?;
let configured_budget_store = build_budget_store(&self.config)?;
let mediation_hold_capable = configured_budget_store
.as_ref()
.map(|configured| configured.hold_capable)
.unwrap_or(false);
let budget_store = configured_budget_store.map(|configured| configured.store);
if let Some(store) = budget_store.as_ref() {
match store.count_open_holds() {
Ok(0) => {}
Ok(count) => {
warn!(
count,
"startup: open budget hold(s) left reserved pending \
receipt-log arbitration; automatic reconcile requires \
the durable receipt log (ADR-0013) arbitration map"
);
}
Err(error) => {
warn!("startup: failed to count open budget holds: {error}");
}
}
}
let payment_adapter = self.payment_adapter;
let mediation_kernel = match budget_store.as_ref() {
Some(store) => Some(Mutex::new(build_mediation_kernel(
&keypair,
Arc::clone(store),
&trusted_capability_issuers,
Vec::new(),
payment_adapter,
durable_admission,
)?)),
None => None,
};
let state = Arc::new(ProxyState {
evaluator,
signer_keypair: keypair,
upstream: self.config.upstream.clone(),
http_client,
egress_contract,
approval_admin: ApprovalAdmin::with_threshold_collector(
approval_store,
threshold_collector,
),
receipt_log: Mutex::new(receipt_log),
tool_receipt_log: Mutex::new(tool_receipt_log),
receipt_store,
revocation_store,
revoked_capability_ids: Mutex::new(revoked_capability_ids),
trusted_capability_issuers,
trusted_receipt_signers,
sidecar_control_token: self.config.sidecar_control_token.clone(),
budget_store,
mediation_hold_capable,
mediation_kernel,
minted_request_ids: Mutex::new(MintedRequestIdWindow::new(
chio_kernel::DEFAULT_EXECUTION_NONCE_TTL_SECS,
)),
reaper_handle: Mutex::new(None),
allow_advisory: self.config.allow_advisory,
receipt_backend,
revocation_backend,
});
spawn_reserved_hold_reaper(&state).await;
let app = build_app(Arc::clone(&state));
let listener = tokio::net::TcpListener::bind(&self.config.listen_addr)
.await
.map_err(|e| {
ProtectError::Config(format!("cannot bind {}: {e}", self.config.listen_addr))
})?;
let local_addr = listener.local_addr().map_err(|error| {
ProtectError::Config(format!("cannot resolve bound address: {error}"))
})?;
info!(
has_budget_store = state.budget_store.is_some(),
"chio api protect: mediation layer ready"
);
info!(
"chio api protect: proxying {} routes to {} on {}",
route_count, self.config.upstream, local_addr
);
observer(local_addr);
let hygiene = ServeHygieneConfig {
request_timeout: None,
drain_timeout: proxy_drain_timeout(self.config.upstream_request_timeout),
..ServeHygieneConfig::default()
};
let app = apply_server_hygiene(app, &hygiene);
let controller = ShutdownController::install();
let listener =
MaxConnListener::new(listener, hygiene.max_connections.unwrap_or(usize::MAX));
let server = axum::serve(
listener,
app.into_make_service_with_connect_info::<CappedPeerAddr>(),
)
.with_graceful_shutdown(controller.signalled());
let serve_result = run_until_drained(
server,
controller.subscribe(),
hygiene.drain_timeout,
async { Ok::<(), String>(()) },
)
.await
.map(|_outcome| ())
.map_err(protect_serve_error);
if let Some(handle) = state.reaper_handle.lock().await.take() {
handle.abort();
}
serve_result?;
Ok(())
}
pub fn routes_from_spec(spec_content: &str) -> Result<Vec<RouteEntry>, ProtectError> {
Self::build_routes(spec_content)
}
}
#[cfg(test)]
mod proxy_builder_tests {
use super::*;
fn minimal_config() -> ProtectConfig {
ProtectConfig {
upstream: "http://127.0.0.1:1".to_string(),
spec_content: Some("{}".to_string()),
spec_path: None,
listen_addr: "127.0.0.1:0".to_string(),
receipt_db: None,
allow_ephemeral_receipts: true,
sidecar_control_token: None,
signer_seed_hex: None,
trusted_capability_issuers: Vec::new(),
control_url: None,
control_token: None,
budget_db: None,
revocation_db: None,
require_nonce: false,
allow_advisory: false,
upstream_request_timeout: crate::DEFAULT_UPSTREAM_REQUEST_TIMEOUT,
}
}
#[test]
fn with_payment_adapter_threads_adapter_and_defaults_none() {
let default = ProtectProxy::new(minimal_config());
assert!(
default.payment_adapter.is_none(),
"a proxy defaults to no payment adapter, keeping governed MustPrepay denied"
);
let configured = ProtectProxy::new(minimal_config()).with_payment_adapter(Some(Box::new(
chio_kernel::payment::SimPaymentAdapter::new(),
)));
assert!(
configured.payment_adapter.is_some(),
"with_payment_adapter must thread the configured adapter into the proxy"
);
}
}
#[cfg(all(test, windows))]
mod windows_authority_tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
#[tokio::test]
async fn durable_startup_rejects_windows_before_api_protect_mutation(
) -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let state_parent = directory.path().join("state");
let receipt_database = state_parent.join("receipts.sqlite3");
let receipt_database_string = receipt_database.to_string_lossy().into_owned();
let (authority_database, authority_lock_root) =
authority_sibling_paths(&receipt_database_string);
let missing_spec = directory.path().join("missing-openapi.json");
let observer_called = AtomicBool::new(false);
let result = ProtectProxy::new(ProtectConfig {
upstream: "http://127.0.0.1:1".to_string(),
spec_content: None,
spec_path: Some(missing_spec.to_string_lossy().into_owned()),
listen_addr: "127.0.0.1:0".to_string(),
receipt_db: Some(receipt_database_string),
allow_ephemeral_receipts: false,
sidecar_control_token: None,
signer_seed_hex: None,
trusted_capability_issuers: Vec::new(),
control_url: None,
control_token: None,
budget_db: None,
revocation_db: None,
require_nonce: false,
allow_advisory: false,
upstream_request_timeout: crate::DEFAULT_UPSTREAM_REQUEST_TIMEOUT,
})
.run_with_observer(|_| observer_called.store(true, Ordering::SeqCst))
.await;
let error = match result {
Ok(()) => {
return Err(std::io::Error::other(
"Windows durable API-protect startup unexpectedly succeeded",
)
.into());
}
Err(error) => error,
};
assert!(
matches!(
&error,
ProtectError::ReceiptStore(message)
if message.contains(
"sqlite authority serving requires Unix file identity and positioned I/O"
)
),
"the platform preflight must fail before attempting to load the missing spec: {error}"
);
assert!(!observer_called.load(Ordering::SeqCst));
assert!(!state_parent.exists());
assert!(!receipt_database.exists());
assert!(!authority_database.exists());
assert!(!authority_lock_root.exists());
Ok(())
}
}
fn protect_serve_error(error: ServeError) -> ProtectError {
match error {
ServeError::Io(source) => ProtectError::Io(source),
ServeError::Flush(message) => ProtectError::Io(std::io::Error::other(message)),
}
}
#[cfg(test)]
mod durability_tests {
use super::{authority_sibling_paths, revocation_sibling_path, SqliteReceiptStore};
use chio_test_support::prelude::*;
#[test]
fn revocation_sibling_path_appends_suffix_to_a_plain_path() {
assert_eq!(
revocation_sibling_path("/var/lib/chio/receipts.db"),
"/var/lib/chio/receipts.db.revocations"
);
}
#[test]
fn revocation_sibling_path_keeps_the_uri_query_after_the_suffix() {
assert_eq!(
revocation_sibling_path("file:/var/lib/chio/receipts.db?mode=rwc"),
"file:/var/lib/chio/receipts.db.revocations?mode=rwc"
);
}
#[test]
fn authority_sibling_paths_resolve_the_receipt_uri_to_filesystem_paths() {
let (database, lock_root) =
authority_sibling_paths("file:/var/lib/chio/receipts.db?mode=rwc");
assert_eq!(
database,
std::path::Path::new("/var/lib/chio/receipts.db.authority-locks/authority.db")
);
assert_eq!(
lock_root,
std::path::Path::new("/var/lib/chio/receipts.db.authority-locks")
);
}
#[test]
fn http_receipt_store_open_configures_wal_and_a_busy_timeout() {
let mut path = std::env::temp_dir();
path.push(format!("chio-http-receipts-{}.db", uuid::Uuid::now_v7()));
let path_str = path.to_string_lossy().into_owned();
let store = SqliteReceiptStore::open(&path_str).test_unwrap();
let busy_timeout: i64 = store
.connection
.query_row("PRAGMA busy_timeout", [], |row| row.get(0))
.test_unwrap();
assert!(
busy_timeout >= 5000,
"the http receipt writer must share the receipt store busy timeout, got {busy_timeout}"
);
let journal_mode: String = store
.connection
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
.test_unwrap();
assert!(
journal_mode.eq_ignore_ascii_case("wal"),
"the http receipt writer must run in WAL mode, got {journal_mode}"
);
let _ = std::fs::remove_file(&path);
}
}
#[cfg(test)]
mod tests {
use super::{proxy_drain_timeout, PROXY_DRAIN_MARGIN};
use crate::DEFAULT_UPSTREAM_REQUEST_TIMEOUT;
use chio_http_serve::DEFAULT_DRAIN_TIMEOUT;
use std::time::Duration;
#[test]
fn drain_window_always_outlasts_the_configured_upstream_timeout() {
for secs in [1u64, 20, 30, 60, 300] {
let upstream = Duration::from_secs(secs);
assert!(
proxy_drain_timeout(upstream) > upstream,
"drain window must outlast a {secs}s upstream timeout"
);
assert_eq!(proxy_drain_timeout(upstream), upstream + PROXY_DRAIN_MARGIN);
}
}
#[test]
fn default_upstream_timeout_preserves_the_default_drain_window() {
assert_eq!(
proxy_drain_timeout(DEFAULT_UPSTREAM_REQUEST_TIMEOUT),
DEFAULT_DRAIN_TIMEOUT
);
}
}