use super::evidence::{
DECLARED_CLIENT_MAX, SDK_FIELD_MAX, UA_PRODUCT_MAX, UA_VERSION_MAX, bounded,
};
use super::{ClientAttestation, ClientEvidence, ClientKind, NativeMarker};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct StainlessHeaders<'a> {
pub lang: Option<&'a str>,
pub package_version: Option<&'a str>,
pub runtime: Option<&'a str>,
pub runtime_version: Option<&'a str>,
pub os: Option<&'a str>,
pub arch: Option<&'a str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClassificationInput<'a> {
pub principal_is_bridge: bool,
pub declared_client: Option<&'a str>,
pub declared_attestation: Option<&'a str>,
pub user_agent: Option<&'a str>,
pub stainless: StainlessHeaders<'a>,
pub body: &'a [u8],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Classified {
pub client: ClientKind,
pub attestation: ClientAttestation,
pub evidence: ClientEvidence,
pub conflicting: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ClassificationRejection {
#[error("x-systemprompt-client must be one of {}", declarable_vocabulary())]
MalformedDeclaredClient { evidence: Box<ClientEvidence> },
#[error("x-systemprompt-client-attestation is set by the bridge only")]
AttestationNotFromBridge { evidence: Box<ClientEvidence> },
#[error("x-systemprompt-client-attestation must be host-token or bridge-secret")]
MalformedAttestation { evidence: Box<ClientEvidence> },
#[error("host-token attestation requires x-systemprompt-client")]
HostTokenWithoutClient { evidence: Box<ClientEvidence> },
}
impl ClassificationRejection {
#[must_use]
pub const fn evidence(&self) -> &ClientEvidence {
match self {
Self::MalformedDeclaredClient { evidence }
| Self::AttestationNotFromBridge { evidence }
| Self::MalformedAttestation { evidence }
| Self::HostTokenWithoutClient { evidence } => evidence,
}
}
}
fn declarable_vocabulary() -> String {
ClientKind::DECLARABLE
.iter()
.map(|kind| kind.as_str())
.collect::<Vec<_>>()
.join(", ")
}
enum Channel {
HostToken,
BridgeSecret,
}
fn initial_evidence(
input: &ClassificationInput<'_>,
marker: Option<NativeMarker>,
ua: Option<&(String, Option<String>)>,
) -> ClientEvidence {
ClientEvidence {
kind_source: ClientAttestation::None,
attested_host: None,
declared_client: bounded(input.declared_client, DECLARED_CLIENT_MAX),
native_marker: marker,
ua_product: ua.and_then(|(p, _)| bounded(Some(p), UA_PRODUCT_MAX)),
ua_version: ua.and_then(|(_, v)| bounded(v.as_deref(), UA_VERSION_MAX)),
sdk_lang: bounded(input.stainless.lang, SDK_FIELD_MAX),
sdk_package_version: bounded(input.stainless.package_version, SDK_FIELD_MAX),
sdk_runtime: bounded(input.stainless.runtime, SDK_FIELD_MAX),
sdk_runtime_version: bounded(input.stainless.runtime_version, SDK_FIELD_MAX),
sdk_os: bounded(input.stainless.os, SDK_FIELD_MAX),
sdk_arch: bounded(input.stainless.arch, SDK_FIELD_MAX),
}
}
pub fn classify(input: &ClassificationInput<'_>) -> Result<Classified, ClassificationRejection> {
let marker = native_marker(input.body);
let ua = ua_product(input.user_agent);
let ua_client = ua
.as_ref()
.and_then(|(product, _)| ClientKind::from_ua_product(product));
let mut evidence = initial_evidence(input, marker, ua.as_ref());
let channel = match (input.principal_is_bridge, input.declared_attestation) {
(_, None) => None,
(false, Some(_)) => {
return Err(ClassificationRejection::AttestationNotFromBridge {
evidence: Box::new(evidence),
});
},
(true, Some(value)) => match ClientAttestation::parse(value) {
Ok(ClientAttestation::HostToken) => Some(Channel::HostToken),
Ok(ClientAttestation::BridgeSecret) => Some(Channel::BridgeSecret),
_ => {
return Err(ClassificationRejection::MalformedAttestation {
evidence: Box::new(evidence),
});
},
},
};
let declared = match input.declared_client {
None => None,
Some(value) => match ClientKind::DECLARABLE
.into_iter()
.find(|kind| kind.as_str() == value)
{
Some(kind) => Some(kind),
None => {
return Err(ClassificationRejection::MalformedDeclaredClient {
evidence: Box::new(evidence),
});
},
},
};
let (client, source) = match channel {
Some(Channel::HostToken) => {
let Some(host) = declared else {
return Err(ClassificationRejection::HostTokenWithoutClient {
evidence: Box::new(evidence),
});
};
evidence.attested_host = Some(host);
(host, ClientAttestation::HostToken)
},
_ => declared
.map(|kind| (kind, ClientAttestation::Declared))
.or_else(|| marker.map(|m| (m.client(), ClientAttestation::NativeMarker)))
.or_else(|| ua_client.map(|kind| (kind, ClientAttestation::UserAgent)))
.unwrap_or((ClientKind::Other, ClientAttestation::None)),
};
evidence.kind_source = source;
let attestation = match channel {
Some(Channel::BridgeSecret) => ClientAttestation::BridgeSecret,
_ => source,
};
let conflicting = [declared, marker.map(NativeMarker::client), ua_client]
.into_iter()
.flatten()
.any(|named| named != client);
Ok(Classified {
client,
attestation,
evidence,
conflicting,
})
}
#[must_use]
pub fn native_marker(body: &[u8]) -> Option<NativeMarker> {
if body.is_empty() {
return None;
}
let value: serde_json::Value = serde_json::from_slice(body).ok()?;
if value
.pointer("/client_metadata/x-codex-turn-metadata")
.is_some()
{
return Some(NativeMarker::CodexTurnMetadata);
}
let user_id = value.pointer("/metadata/user_id")?.as_str()?.trim();
if user_id.starts_with('{') {
return serde_json::from_str::<serde_json::Value>(user_id)
.ok()?
.get("session_id")
.is_some()
.then_some(NativeMarker::OpencodeSessionJson);
}
let mut parts = user_id.split('_');
let shape = [
parts.next() == Some("user"),
parts.next().is_some_and(|hex| !hex.is_empty()),
parts.next() == Some("account"),
parts
.next()
.is_some_and(|id| uuid::Uuid::parse_str(id).is_ok()),
parts.next() == Some("session"),
parts
.next()
.is_some_and(|id| uuid::Uuid::parse_str(id).is_ok()),
parts.next().is_none(),
];
shape
.iter()
.all(|ok| *ok)
.then_some(NativeMarker::ClaudeMetadataUserId)
}
#[must_use]
pub fn ua_product(user_agent: Option<&str>) -> Option<(String, Option<String>)> {
let first = user_agent?.split_ascii_whitespace().next()?;
let (product, version) = first
.split_once('/')
.map_or((first, None), |(p, v)| (p, Some(v)));
if product.is_empty() || !product.chars().all(is_token_char) {
return None;
}
Some((
product.to_ascii_lowercase(),
version.filter(|v| !v.is_empty()).map(str::to_owned),
))
}
fn is_token_char(c: char) -> bool {
c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c)
}