use alloc::{string::String, vec::Vec};
use ciborium::value::Value;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{cbor::canonical, cbor_bytes::CborBytes, schema_id::SchemaSource};
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum QaSpecSource {
InlineCbor(CborBytes),
#[cfg(feature = "json-compat")]
InlineJson(String),
RefUri(String),
RefPackPath(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ExampleAnswers {
pub title: String,
pub answers_cbor: CborBytes,
pub notes: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum SetupOutput {
ConfigOnly,
TemplateScaffold {
template_ref: String,
output_layout: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SetupContract {
pub qa_spec: QaSpecSource,
pub answers_schema: Option<SchemaSource>,
pub examples: Vec<ExampleAnswers>,
pub outputs: Vec<SetupOutput>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CanonicalPolicy {
Off,
RequireCanonical,
Canonicalize,
}
#[derive(Debug, Error)]
pub enum ValidateAnswersError {
#[error("CBOR decode failed: {0}")]
Decode(String),
#[error("answers must be a CBOR map/object")]
NotMap,
#[error(transparent)]
Canonical(#[from] canonical::CanonicalError),
}
pub fn validate_answers(
schema: &SchemaSource,
answers_cbor: &CborBytes,
policy: CanonicalPolicy,
) -> Result<CborBytes, ValidateAnswersError> {
let _ = schema;
let value: Value = ciborium::de::from_reader(answers_cbor.as_slice())
.map_err(|err| ValidateAnswersError::Decode(err.to_string()))?;
if !matches!(value, Value::Map(_)) {
return Err(ValidateAnswersError::NotMap);
}
match policy {
CanonicalPolicy::Off => Ok(answers_cbor.clone()),
CanonicalPolicy::RequireCanonical => {
answers_cbor.ensure_canonical()?;
Ok(answers_cbor.clone())
}
CanonicalPolicy::Canonicalize => {
let canonical_bytes = canonical::canonicalize(answers_cbor.as_slice())?;
Ok(CborBytes(canonical_bytes))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::collections::BTreeMap;
fn schema_bytes() -> Vec<u8> {
let mut map = BTreeMap::new();
map.insert("key", "value");
match canonical::to_canonical_cbor(&map) {
Ok(bytes) => bytes,
Err(err) => panic!("schema canonicalization failed: {err:?}"),
}
}
fn schema_blob() -> CborBytes {
CborBytes(schema_bytes())
}
#[test]
fn validate_accepts_map_off_policy() {
let bytes = schema_blob();
let source = SchemaSource::InlineCbor(bytes.clone());
let result = match validate_answers(&source, &bytes, CanonicalPolicy::Off) {
Ok(value) => value,
Err(err) => panic!("validation failed: {err:?}"),
};
assert_eq!(result.as_slice(), bytes.as_slice());
}
#[test]
fn validate_rejects_non_map() {
let bytes = match canonical::to_canonical_cbor(&"string") {
Ok(value) => value,
Err(err) => panic!("canonicalize string failed: {err:?}"),
};
let source = SchemaSource::InlineCbor(CborBytes(bytes.clone()));
assert!(matches!(
validate_answers(&source, &CborBytes(bytes), CanonicalPolicy::Off),
Err(ValidateAnswersError::NotMap)
));
}
#[test]
fn canonicalize_policy_rewrites_indefinite_map() {
let indefinite = vec![0xBF, 0x61, b'a', 0x01, 0xFF];
let source = SchemaSource::InlineCbor(CborBytes::new(indefinite.clone()));
let canonical_bytes = match validate_answers(
&source,
&CborBytes(indefinite),
CanonicalPolicy::Canonicalize,
) {
Ok(bytes) => bytes,
Err(err) => panic!("validation failed: {err:?}"),
};
if let Err(err) = canonical::ensure_canonical(canonical_bytes.as_slice()) {
panic!("ensure canonical failed: {err:?}");
}
}
#[test]
fn require_canonical_rejects_indefinite() {
let indefinite = vec![0xBF, 0x61, b'a', 0x01, 0xFF];
let source = SchemaSource::InlineCbor(CborBytes::new(indefinite.clone()));
assert!(matches!(
validate_answers(
&source,
&CborBytes(indefinite),
CanonicalPolicy::RequireCanonical
),
Err(ValidateAnswersError::Canonical(_))
));
}
}