use std::collections::BTreeMap;
use serde::Serialize;
use crate::error::HostError;
use crate::identity::{canonical_digest, Digest};
const SCHEMA_ENCODING_DOMAIN: &str = "hostbat.schema.v3";
const MAX_SCHEMA_ID_BYTES: usize = 256;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct SchemaId(String);
impl SchemaId {
pub fn new(id: impl Into<String>) -> Result<Self, HostError> {
let id = id.into();
let reject = |detail: &str| {
Err(HostError::SchemaInvalid {
schema: id.clone(),
detail: detail.to_owned(),
})
};
if id.is_empty() {
return reject("empty schema id");
}
if id.len() > MAX_SCHEMA_ID_BYTES {
return reject("schema id longer than 256 bytes");
}
if id.starts_with('.') || id.ends_with('.') {
return reject("schema id has a leading or trailing '.'");
}
if id.contains("..") {
return reject("schema id has a doubled '.'");
}
if !id.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
}) {
return reject("schema id has characters outside [a-z0-9._-]");
}
Ok(Self(id))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SchemaId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct SchemaVersion(pub u32);
impl SchemaVersion {
#[must_use]
pub const fn get(self) -> u32 {
self.0
}
}
impl std::fmt::Display for SchemaVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "v{}", self.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CanonicalEncoding(Digest);
impl CanonicalEncoding {
#[must_use]
pub const fn bytes(&self) -> &Digest {
&self.0
}
#[must_use]
pub fn to_hex(&self) -> String {
let mut out = String::with_capacity(64);
for byte in &self.0 {
out.push(char::from_digit(u32::from(byte >> 4), 16).unwrap_or('0'));
out.push(char::from_digit(u32::from(byte & 0x0f), 16).unwrap_or('0'));
}
out
}
}
impl std::fmt::Display for CanonicalEncoding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.to_hex())
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct GoldenVector {
pub case: String,
pub bytes: Vec<u8>,
}
impl GoldenVector {
pub fn new(case: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
Self {
case: case.into(),
bytes: bytes.into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct DiagnosticRustType(String);
impl DiagnosticRustType {
pub fn new(path: impl Into<String>) -> Self {
Self(path.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for DiagnosticRustType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SchemaRole {
OperationInput,
OperationOutput,
EventPayload,
ReceiptPayload,
SubscriptionPayload,
}
impl SchemaRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::OperationInput => "operation-input",
Self::OperationOutput => "operation-output",
Self::EventPayload => "event-payload",
Self::ReceiptPayload => "receipt-payload",
Self::SubscriptionPayload => "subscription-payload",
}
}
#[must_use]
pub const fn is_client_visible(self) -> bool {
true
}
}
impl std::fmt::Display for SchemaRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug)]
pub struct SchemaDescriptor {
id: SchemaId,
version: SchemaVersion,
role: SchemaRole,
golden: Vec<GoldenVector>,
diagnostic_rust_type: Option<DiagnosticRustType>,
encoding: CanonicalEncoding,
}
#[derive(Clone, Debug, Default)]
pub struct SchemaRegistry {
by_ref: BTreeMap<(String, SchemaRole), Vec<SchemaDescriptor>>,
}
#[derive(Serialize)]
struct SchemaEncodingView<'a> {
domain: &'a str,
id: &'a SchemaId,
version: SchemaVersion,
role: &'a str,
golden: &'a [GoldenVector],
}
fn compute_encoding(
id: &SchemaId,
version: SchemaVersion,
role: SchemaRole,
golden: &[GoldenVector],
) -> Result<CanonicalEncoding, HostError> {
let view = SchemaEncodingView {
domain: SCHEMA_ENCODING_DOMAIN,
id,
version,
role: role.as_str(),
golden,
};
canonical_digest(&view).map(CanonicalEncoding)
}
impl SchemaDescriptor {
pub fn new(
id: SchemaId,
version: SchemaVersion,
role: SchemaRole,
golden: Vec<GoldenVector>,
) -> Result<Self, HostError> {
let mut golden = golden;
golden.sort_by(|a, b| a.case.cmp(&b.case));
for pair in golden.windows(2) {
if let [a, b] = pair {
if a.case == b.case {
return Err(HostError::SchemaInvalid {
schema: id.as_str().to_owned(),
detail: format!("golden vector case {:?} is declared twice", a.case),
});
}
}
}
let encoding = compute_encoding(&id, version, role, &golden)?;
Ok(Self {
id,
version,
role,
golden,
diagnostic_rust_type: None,
encoding,
})
}
#[must_use]
pub fn with_diagnostic_rust_type(mut self, rust_type: DiagnosticRustType) -> Self {
self.diagnostic_rust_type = Some(rust_type);
self
}
#[must_use]
pub fn id(&self) -> &SchemaId {
&self.id
}
#[must_use]
pub fn version(&self) -> SchemaVersion {
self.version
}
#[must_use]
pub fn role(&self) -> SchemaRole {
self.role
}
#[must_use]
pub fn encoding(&self) -> CanonicalEncoding {
self.encoding
}
pub fn golden(&self) -> impl Iterator<Item = &GoldenVector> {
self.golden.iter()
}
#[must_use]
pub fn diagnostic_rust_type(&self) -> Option<&DiagnosticRustType> {
self.diagnostic_rust_type.as_ref()
}
#[must_use]
pub(crate) fn identity_key(&self) -> (&str, u32, SchemaRole) {
(self.id.as_str(), self.version.0, self.role)
}
pub fn verify_encoding(&self) -> Result<bool, HostError> {
let recomputed = compute_encoding(&self.id, self.version, self.role, &self.golden)?;
Ok(recomputed == self.encoding)
}
pub(crate) fn manifest_view(&self) -> SchemaManifestView<'_> {
SchemaManifestView {
id: &self.id,
version: self.version,
role: self.role.as_str(),
encoding: *self.encoding.bytes(),
}
}
#[cfg(any(test, gauntlet_red_fixture))]
pub(crate) fn corrupt_encoding_for_fixture(&mut self) {
let mut bytes = self.encoding.0;
bytes[0] ^= 0xff;
self.encoding = CanonicalEncoding(bytes);
}
}
impl SchemaRegistry {
pub fn from_descriptors<I>(descriptors: I) -> Self
where
I: IntoIterator<Item = SchemaDescriptor>,
{
let mut by_ref = BTreeMap::<(String, SchemaRole), Vec<SchemaDescriptor>>::new();
for descriptor in descriptors {
by_ref
.entry((descriptor.id().as_str().to_owned(), descriptor.role()))
.or_default()
.push(descriptor);
}
for descriptors in by_ref.values_mut() {
descriptors.sort_by_key(|descriptor| descriptor.version().get());
}
Self { by_ref }
}
pub fn validate(
&self,
schema_id: &str,
role: SchemaRole,
bytes: &[u8],
) -> Result<(), HostError> {
let descriptor = self.resolve(schema_id, role)?;
if !descriptor.verify_encoding()? {
return Err(schema_validation(
schema_id,
role,
"descriptor encoding no longer matches its golden vectors",
));
}
for golden in descriptor.golden() {
decode_canonical(&golden.bytes).map_err(|detail| {
schema_validation(
schema_id,
role,
format!(
"golden vector {:?} is not canonical bytes: {detail}",
golden.case
),
)
})?;
}
decode_canonical(bytes).map_err(|detail| {
schema_validation(
schema_id,
role,
format!("payload is not canonical bytes: {detail}"),
)
})?;
Ok(())
}
fn resolve(&self, schema_id: &str, role: SchemaRole) -> Result<&SchemaDescriptor, HostError> {
let Some(descriptors) = self.by_ref.get(&(schema_id.to_owned(), role)) else {
return Err(schema_validation(
schema_id,
role,
"required descriptor is not present",
));
};
if descriptors.len() != 1 {
let versions: Vec<String> = descriptors
.iter()
.map(|descriptor| descriptor.version().get().to_string())
.collect();
return Err(schema_validation(
schema_id,
role,
format!(
"schema ref is ambiguous across versions [{}]",
versions.join(", ")
),
));
}
descriptors
.first()
.ok_or_else(|| schema_validation(schema_id, role, "required descriptor is not present"))
}
}
fn decode_canonical(bytes: &[u8]) -> Result<(), String> {
batpak::canonical::from_bytes::<serde::de::IgnoredAny>(bytes)
.map(|_| ())
.map_err(|error| error.to_string())
}
fn schema_validation(
schema: impl Into<String>,
role: SchemaRole,
detail: impl Into<String>,
) -> HostError {
HostError::SchemaValidation {
schema: schema.into(),
role: role.to_string(),
detail: detail.into(),
}
}
#[derive(Serialize)]
pub(crate) struct SchemaManifestView<'a> {
id: &'a SchemaId,
version: SchemaVersion,
role: &'a str,
encoding: Digest,
}
#[cfg(test)]
#[path = "schema_tests.rs"]
mod schema_tests;