use std::collections::BTreeSet;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use super::ProvidersConfig;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum DataControlScope {
PerRequest,
Account,
None,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum DataControlLocation {
Body,
Header,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum DataControlEffect {
Retention,
Training,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum RetentionDefault {
Retained,
NotRetained,
AbuseMonitoringOnly,
Unspecified,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum TrainingDefault {
Trains,
DoesNotTrain,
Unspecified,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum DataControlDialect {
AnthropicSse,
OpenAiSse,
OllamaNdjson,
GeminiJson,
GeminiInteractionsSse,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DataControlDef {
pub location: DataControlLocation,
pub name: String,
pub value: DataControlValue,
pub effect: DataControlEffect,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub applies_to: Vec<DataControlDialect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caveat: Option<String>,
}
impl DataControlDef {
pub fn applies_to_dialect(&self, dialect: DataControlDialect) -> bool {
self.applies_to.is_empty() || self.applies_to.contains(&dialect)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum DataControlValue {
Bool(bool),
Text(String),
}
impl DataControlValue {
pub fn as_json(&self) -> serde_json::Value {
match self {
Self::Bool(value) => serde_json::Value::Bool(*value),
Self::Text(value) => serde_json::Value::String(value.clone()),
}
}
pub fn as_header_value(&self) -> String {
match self {
Self::Bool(value) => value.to_string(),
Self::Text(value) => value.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DataControlsDef {
pub control_scope: DataControlScope,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub request_controls: Vec<DataControlDef>,
pub retention_default: RetentionDefault,
pub training_default: TrainingDefault,
pub checked_on: String,
pub sources: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
impl DataControlsDef {
pub fn controls_for_dialect(
&self,
dialect: DataControlDialect,
) -> impl Iterator<Item = &DataControlDef> {
self.request_controls
.iter()
.filter(move |control| control.applies_to_dialect(dialect))
}
pub fn offers_per_request_control(&self) -> bool {
self.control_scope == DataControlScope::PerRequest && !self.request_controls.is_empty()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DataPosture {
#[default]
Default,
StrictestAvailable,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct DataControlsPolicy {
#[serde(default)]
pub default_posture: DataPosture,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DataControlsAuditRegistry {
pub reviewed_on: String,
pub expires_on: String,
pub tracking_issue: u64,
#[serde(default)]
pub unverified: Vec<String>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct DataControlsAuditValidation {
pub provider_count: usize,
pub declared_count: usize,
pub unverified_count: usize,
pub errors: Vec<String>,
}
impl DataControlsAuditValidation {
pub fn is_clean(&self) -> bool {
self.errors.is_empty()
}
}
pub fn validate_data_controls_audit(
config: &ProvidersConfig,
as_of: &str,
) -> DataControlsAuditValidation {
let providers: BTreeSet<&str> = config.providers.keys().map(String::as_str).collect();
let mut result = DataControlsAuditValidation {
provider_count: providers.len(),
..DataControlsAuditValidation::default()
};
if providers.is_empty() {
result
.errors
.push("data-controls audit reached no providers".to_string());
return result;
}
let Some(registry) = &config.data_controls_audit else {
result
.errors
.push("data_controls_audit registry is missing".to_string());
return result;
};
let as_of = parse_date("data-controls audit check date", as_of, &mut result);
let reviewed_on = parse_date(
"data_controls_audit.reviewed_on",
®istry.reviewed_on,
&mut result,
);
let expires_on = parse_date(
"data_controls_audit.expires_on",
®istry.expires_on,
&mut result,
);
if registry.tracking_issue == 0 {
result
.errors
.push("data_controls_audit.tracking_issue must be positive".to_string());
}
if let (Some(reviewed_on), Some(expires_on)) = (reviewed_on, expires_on) {
if expires_on < reviewed_on {
result.errors.push(format!(
"data-controls audit expires_on {} precedes reviewed_on {}",
registry.expires_on, registry.reviewed_on
));
}
if !registry.unverified.is_empty() && as_of.is_some_and(|as_of| expires_on < as_of) {
result.errors.push(format!(
"data-controls unverified queue expired on {} (tracking issue #{})",
registry.expires_on, registry.tracking_issue
));
}
}
let mut queued = BTreeSet::new();
for provider in ®istry.unverified {
if !queued.insert(provider.as_str()) {
result
.errors
.push(format!("duplicate data-controls audit row for {provider}"));
}
if !providers.contains(provider.as_str()) {
result.errors.push(format!(
"data-controls audit row {provider} does not name a catalog provider"
));
continue;
}
if config
.providers
.get(provider)
.is_some_and(|definition| definition.data_controls.is_some())
{
result.errors.push(format!(
"provider {provider} declares data_controls but is queued as unverified"
));
}
}
result.unverified_count = queued.len();
for (id, provider) in &config.providers {
let Some(controls) = &provider.data_controls else {
if !queued.contains(id.as_str()) {
result.errors.push(format!(
"provider {id} has neither a data_controls declaration nor an unverified audit entry"
));
}
continue;
};
result.declared_count += 1;
validate_declaration(id, controls, &mut result);
}
result
}
fn validate_declaration(
id: &str,
controls: &DataControlsDef,
result: &mut DataControlsAuditValidation,
) {
parse_date(
&format!("providers.{id}.data_controls.checked_on"),
&controls.checked_on,
result,
);
if controls.sources.is_empty()
|| controls
.sources
.iter()
.any(|source| !source.starts_with("https://"))
{
result.errors.push(format!(
"provider {id} data_controls must cite at least one HTTPS documentation source"
));
}
match controls.control_scope {
DataControlScope::PerRequest => {
if controls.request_controls.is_empty() {
result.errors.push(format!(
"provider {id} declares control_scope per_request but names no request control"
));
}
}
DataControlScope::Account | DataControlScope::None => {
if !controls.request_controls.is_empty() {
result.errors.push(format!(
"provider {id} names a request control but declares control_scope {:?}",
controls.control_scope
));
}
}
}
let mut seen = BTreeSet::new();
for control in &controls.request_controls {
if control.name.trim().is_empty() {
result.errors.push(format!(
"provider {id} data_controls names an empty control"
));
}
if !seen.insert((control.location, control.name.as_str())) {
result.errors.push(format!(
"provider {id} repeats data control {}",
control.name
));
}
}
}
fn parse_date(
field: &str,
value: &str,
result: &mut DataControlsAuditValidation,
) -> Option<NaiveDate> {
match NaiveDate::parse_from_str(value, "%Y-%m-%d") {
Ok(date) => Some(date),
Err(_) => {
result
.errors
.push(format!("{field} must be an ISO date, got {value:?}"));
None
}
}
}