379 lines
14 KiB
Rust
379 lines
14 KiB
Rust
//! Bounded CBOR import/export for one explicit current record.
|
|
|
|
use kaptan_config::{
|
|
ConfigModel, ConfigValue, ConfigViolation, FieldWriteError, ValidationRule, ValueKind,
|
|
};
|
|
use kaptan_log::KaptanError;
|
|
use minicbor::{Decoder, Encoder, encode::Write};
|
|
|
|
use crate::{CandidateProfile, FORMAT_VERSION, SCHEMA_ID, VehicleCapabilities};
|
|
|
|
/// Maximum complete candidate snapshot accepted by the current vehicle schema.
|
|
pub const MAXIMUM_CANDIDATE_BYTES: usize = 4096;
|
|
const FORMAT_KEY: u16 = 0;
|
|
const SCHEMA_KEY: u16 = u16::MAX;
|
|
|
|
/// CBOR import failure.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
|
pub enum CborError {
|
|
/// The supplied payload exceeded the bounded configuration storage budget.
|
|
TooLarge,
|
|
/// CBOR was malformed or did not match a supported versioned wire record.
|
|
Malformed,
|
|
/// A second CBOR item followed the candidate snapshot.
|
|
TrailingData,
|
|
/// The decoded candidate violated complete schema/capability validation.
|
|
InvalidProfile(ConfigViolation),
|
|
}
|
|
|
|
/// Failure while encoding one complete candidate snapshot.
|
|
///
|
|
/// The candidate is validated before the first CBOR item is written, so a
|
|
/// caller can never export a locally valid but relationally invalid edit.
|
|
#[derive(Debug)]
|
|
pub enum CborEncodeError<E> {
|
|
/// The complete candidate did not validate against immutable board
|
|
/// capabilities.
|
|
InvalidProfile(ConfigViolation),
|
|
/// The caller-owned CBOR writer rejected the encoded snapshot.
|
|
Encode(minicbor::encode::Error<E>),
|
|
}
|
|
|
|
/// Decodes exactly one bounded current record and validates it against immutable
|
|
/// board capabilities before returning a candidate to its caller.
|
|
pub fn decode_candidate(
|
|
bytes: &[u8],
|
|
capabilities: &VehicleCapabilities,
|
|
) -> Result<CandidateProfile, CborError> {
|
|
if bytes.len() > MAXIMUM_CANDIDATE_BYTES {
|
|
return Err(CborError::TooLarge);
|
|
}
|
|
let candidate = decode_current(bytes)?;
|
|
candidate
|
|
.validate(capabilities)
|
|
.map_err(CborError::InvalidProfile)?;
|
|
Ok(candidate)
|
|
}
|
|
|
|
/// Validates and encodes one complete current snapshot to a caller-owned CBOR
|
|
/// writer.
|
|
///
|
|
/// Validation happens before creating the encoder, ensuring a failed
|
|
/// cross-field or capability check emits no partial CBOR record.
|
|
pub fn encode_candidate<W>(
|
|
candidate: CandidateProfile,
|
|
capabilities: &VehicleCapabilities,
|
|
writer: W,
|
|
) -> Result<(), CborEncodeError<W::Error>>
|
|
where
|
|
W: Write,
|
|
{
|
|
candidate
|
|
.validate(capabilities)
|
|
.map_err(CborEncodeError::InvalidProfile)?;
|
|
let mut encoder = Encoder::new(writer);
|
|
encoder
|
|
.map(CandidateProfile::FIELD_COUNT as u64 + 2)
|
|
.map_err(CborEncodeError::Encode)?;
|
|
encoder
|
|
.u16(FORMAT_KEY)
|
|
.and_then(|encoder| encoder.u16(FORMAT_VERSION))
|
|
.map_err(CborEncodeError::Encode)?;
|
|
encoder
|
|
.u16(SCHEMA_KEY)
|
|
.and_then(|encoder| encoder.bytes(&SCHEMA_ID))
|
|
.map_err(CborEncodeError::Encode)?;
|
|
for descriptor in CandidateProfile::descriptors() {
|
|
encoder
|
|
.u16(descriptor.id.get())
|
|
.map_err(CborEncodeError::Encode)?;
|
|
encode_value(
|
|
&mut encoder,
|
|
candidate
|
|
.field_value(descriptor.id)
|
|
.expect("descriptor field"),
|
|
)
|
|
.map_err(CborEncodeError::Encode)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn decode_current(bytes: &[u8]) -> Result<CandidateProfile, CborError> {
|
|
let mut decoder = Decoder::new(bytes);
|
|
let entry_count = CandidateProfile::FIELD_COUNT as u64 + 2;
|
|
if decoder.map().map_err(|_| CborError::Malformed)? != Some(entry_count) {
|
|
return Err(CborError::Malformed);
|
|
}
|
|
let mut format_version = None;
|
|
let mut schema_id = None;
|
|
let mut values = [None; CandidateProfile::FIELD_COUNT];
|
|
for _ in 0..entry_count {
|
|
let key = decoder.u16().map_err(|_| CborError::Malformed)?;
|
|
match key {
|
|
FORMAT_KEY => {
|
|
if format_version
|
|
.replace(decoder.u16().map_err(|_| CborError::Malformed)?)
|
|
.is_some()
|
|
{
|
|
return Err(CborError::Malformed);
|
|
}
|
|
}
|
|
SCHEMA_KEY => {
|
|
let bytes = decoder.bytes().map_err(|_| CborError::Malformed)?;
|
|
let value: [u8; 16] = bytes.try_into().map_err(|_| CborError::Malformed)?;
|
|
if schema_id.replace(value).is_some() {
|
|
return Err(CborError::Malformed);
|
|
}
|
|
}
|
|
_ => {
|
|
let index = CandidateProfile::descriptors()
|
|
.iter()
|
|
.position(|descriptor| descriptor.id.get() == key)
|
|
.ok_or(CborError::Malformed)?;
|
|
if values[index].is_some() {
|
|
return Err(CborError::Malformed);
|
|
}
|
|
values[index] = Some(decode_value(
|
|
&mut decoder,
|
|
CandidateProfile::descriptors()[index].kind,
|
|
)?);
|
|
}
|
|
}
|
|
}
|
|
if decoder.position() != bytes.len() {
|
|
return Err(CborError::TrailingData);
|
|
}
|
|
if format_version != Some(FORMAT_VERSION) || schema_id != Some(SCHEMA_ID) {
|
|
return Err(CborError::InvalidProfile(ConfigViolation::global(
|
|
ValidationRule::UnsupportedFormat,
|
|
)));
|
|
}
|
|
if values.iter().any(Option::is_none) {
|
|
return Err(CborError::Malformed);
|
|
}
|
|
let mut candidate = CandidateProfile::default();
|
|
for (index, value) in values.into_iter().enumerate() {
|
|
let descriptor = CandidateProfile::descriptors()
|
|
.get(index)
|
|
.ok_or(CborError::Malformed)?;
|
|
candidate
|
|
.set_field(descriptor.id, value.ok_or(CborError::Malformed)?)
|
|
.map_err(field_write_error)?;
|
|
}
|
|
Ok(candidate)
|
|
}
|
|
|
|
fn encode_value<W>(
|
|
encoder: &mut Encoder<W>,
|
|
value: ConfigValue,
|
|
) -> Result<(), minicbor::encode::Error<W::Error>>
|
|
where
|
|
W: Write,
|
|
{
|
|
match value {
|
|
ConfigValue::Bool(value) => encoder.bool(value)?,
|
|
ConfigValue::I32(value) => encoder.i32(value)?,
|
|
ConfigValue::U32(value) => encoder.u32(value)?,
|
|
ConfigValue::U64(value) => encoder.u64(value)?,
|
|
ConfigValue::F32(value) => encoder.f32(value)?,
|
|
ConfigValue::F64(value) => encoder.f64(value)?,
|
|
};
|
|
Ok(())
|
|
}
|
|
|
|
fn decode_value(decoder: &mut Decoder<'_>, kind: ValueKind) -> Result<ConfigValue, CborError> {
|
|
match kind {
|
|
ValueKind::Bool => decoder.bool().map(ConfigValue::Bool),
|
|
ValueKind::I32 => decoder.i32().map(ConfigValue::I32),
|
|
ValueKind::U32 => decoder.u32().map(ConfigValue::U32),
|
|
ValueKind::U64 => decoder.u64().map(ConfigValue::U64),
|
|
ValueKind::F32 => decoder.f32().map(ConfigValue::F32),
|
|
ValueKind::F64 => decoder.f64().map(ConfigValue::F64),
|
|
}
|
|
.map_err(|_| CborError::Malformed)
|
|
}
|
|
|
|
fn field_write_error(error: FieldWriteError) -> CborError {
|
|
match error {
|
|
FieldWriteError::Violation(issue) => CborError::InvalidProfile(issue),
|
|
FieldWriteError::UnknownField(_) | FieldWriteError::WrongType { .. } => {
|
|
CborError::Malformed
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn round_trip_and_trailing_rejection_are_exact() {
|
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
|
let capabilities = VehicleCapabilities::quad_x();
|
|
encode_candidate(CandidateProfile::default(), &capabilities, &mut writer).unwrap();
|
|
let length = writer.position();
|
|
let bytes = writer.into_inner();
|
|
assert!(decode_candidate(&bytes[..length], &capabilities).is_ok());
|
|
let mut trailing = bytes;
|
|
trailing[length] = 0xf6;
|
|
assert!(matches!(
|
|
decode_candidate(&trailing[..=length], &capabilities),
|
|
Err(CborError::TrailingData)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn encoding_rejects_a_cross_field_invalid_transaction_before_writing() {
|
|
let capabilities = VehicleCapabilities::quad_x();
|
|
let mut candidate = CandidateProfile::default();
|
|
candidate
|
|
.set_field(kaptan_config::FieldId::new(2), ConfigValue::U64(50_001))
|
|
.expect("the scalar is locally valid");
|
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
|
|
|
assert!(matches!(
|
|
encode_candidate(candidate, &capabilities, &mut writer),
|
|
Err(CborEncodeError::InvalidProfile(violation))
|
|
if violation.rule() == ValidationRule::Inconsistent
|
|
));
|
|
assert_eq!(writer.position(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_wrong_format_and_schema_before_candidate_conversion() {
|
|
let capabilities = VehicleCapabilities::quad_x();
|
|
let (bytes, length) = encoded_test_record(FORMAT_VERSION + 1, SCHEMA_ID, |key| key);
|
|
assert!(matches!(
|
|
decode_candidate(&bytes[..length], &capabilities),
|
|
Err(CborError::InvalidProfile(issue))
|
|
if issue.rule() == ValidationRule::UnsupportedFormat
|
|
));
|
|
|
|
let (bytes, length) = encoded_test_record(FORMAT_VERSION, [0; 16], |key| key);
|
|
assert!(matches!(
|
|
decode_candidate(&bytes[..length], &capabilities),
|
|
Err(CborError::InvalidProfile(issue))
|
|
if issue.rule() == kaptan_config::ValidationRule::UnsupportedFormat
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn exact_map_rejects_unknown_duplicate_and_missing_fields() {
|
|
let capabilities = VehicleCapabilities::quad_x();
|
|
let (bytes, length) = encoded_test_record(FORMAT_VERSION, SCHEMA_ID, |key| key);
|
|
assert!(decode_candidate(&bytes[..length], &capabilities).is_ok());
|
|
|
|
let (unknown, length) =
|
|
encoded_test_record(
|
|
FORMAT_VERSION,
|
|
SCHEMA_ID,
|
|
|key| {
|
|
if key == 1 { 25 } else { key }
|
|
},
|
|
);
|
|
assert!(matches!(
|
|
decode_candidate(&unknown[..length], &capabilities),
|
|
Err(CborError::Malformed)
|
|
));
|
|
|
|
let (duplicate, length) = encoded_test_record(FORMAT_VERSION, SCHEMA_ID, |key| {
|
|
if key == 1 { FORMAT_KEY } else { key }
|
|
});
|
|
assert!(matches!(
|
|
decode_candidate(&duplicate[..length], &capabilities),
|
|
Err(CborError::Malformed)
|
|
));
|
|
|
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
|
let mut encoder = Encoder::new(&mut writer);
|
|
encoder
|
|
.map(CandidateProfile::FIELD_COUNT as u64 + 1)
|
|
.unwrap();
|
|
encoder
|
|
.u16(FORMAT_KEY)
|
|
.unwrap()
|
|
.u16(FORMAT_VERSION)
|
|
.unwrap();
|
|
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
|
|
let candidate = CandidateProfile::default();
|
|
for descriptor in CandidateProfile::descriptors().iter().skip(1) {
|
|
encoder.u16(descriptor.id.get()).unwrap();
|
|
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap();
|
|
}
|
|
let length = writer.position();
|
|
let missing = writer.into_inner();
|
|
assert!(matches!(
|
|
decode_candidate(&missing[..length], &capabilities),
|
|
Err(CborError::Malformed)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn cbor_scalar_write_preserves_the_generated_field_and_rule() {
|
|
let capabilities = VehicleCapabilities::quad_x();
|
|
let mass = CandidateProfile::descriptors()
|
|
.iter()
|
|
.find(|descriptor| descriptor.display_key == "thrust.mass_kg")
|
|
.expect("declared mass descriptor");
|
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
|
let mut encoder = Encoder::new(&mut writer);
|
|
encoder
|
|
.map(CandidateProfile::FIELD_COUNT as u64 + 2)
|
|
.unwrap();
|
|
encoder
|
|
.u16(FORMAT_KEY)
|
|
.unwrap()
|
|
.u16(FORMAT_VERSION)
|
|
.unwrap();
|
|
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
|
|
let candidate = CandidateProfile::default();
|
|
for descriptor in CandidateProfile::descriptors() {
|
|
encoder.u16(descriptor.id.get()).unwrap();
|
|
let value = if descriptor.id == mass.id {
|
|
ConfigValue::F32(0.009)
|
|
} else {
|
|
candidate.field_value(descriptor.id).unwrap()
|
|
};
|
|
encode_value(&mut encoder, value).unwrap();
|
|
}
|
|
let length = writer.position();
|
|
let bytes = writer.into_inner();
|
|
|
|
assert!(matches!(
|
|
decode_candidate(&bytes[..length], &capabilities),
|
|
Err(CborError::InvalidProfile(issue))
|
|
if issue.primary() == Some(mass.id)
|
|
&& issue.related_field().is_none()
|
|
&& issue.rule() == ValidationRule::BelowMinimum
|
|
));
|
|
}
|
|
|
|
fn encoded_test_record<F>(
|
|
format_version: u16,
|
|
schema_id: [u8; 16],
|
|
mut remap_key: F,
|
|
) -> ([u8; MAXIMUM_CANDIDATE_BYTES], usize)
|
|
where
|
|
F: FnMut(u16) -> u16,
|
|
{
|
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
|
let mut encoder = Encoder::new(&mut writer);
|
|
encoder
|
|
.map(CandidateProfile::FIELD_COUNT as u64 + 2)
|
|
.unwrap();
|
|
encoder
|
|
.u16(FORMAT_KEY)
|
|
.unwrap()
|
|
.u16(format_version)
|
|
.unwrap();
|
|
encoder.u16(SCHEMA_KEY).unwrap().bytes(&schema_id).unwrap();
|
|
let candidate = CandidateProfile::default();
|
|
for descriptor in CandidateProfile::descriptors() {
|
|
encoder.u16(remap_key(descriptor.id.get())).unwrap();
|
|
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap();
|
|
}
|
|
let length = writer.position();
|
|
(writer.into_inner(), length)
|
|
}
|
|
}
|