//! Bounded CBOR persistence codec for one explicit current record. use kaptan_config::{ ConfigModel, ConfigValue, ConfigViolation, FieldWriteError, ValidationRule, ValueKind, }; use kaptan_err_reportable::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 snapshot decoding failure. #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum CborError { /// The supplied payload exceeded the bounded configuration storage budget. #[kaptan_error(id = 3001)] TooLarge, /// CBOR was malformed or did not match a supported versioned wire record. #[kaptan_error(id = 3002)] Malformed, /// A second CBOR item followed the candidate snapshot. #[kaptan_error(id = 3003)] TrailingData, /// The decoded candidate violated complete schema/capability validation. #[kaptan_error(id = 3004)] 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 { /// 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), } /// 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 { 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( candidate: CandidateProfile, capabilities: &VehicleCapabilities, writer: W, ) -> Result<(), CborEncodeError> 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 { 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, descriptor) = CandidateProfile::descriptors() .enumerate() .find(|(_, 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, descriptor.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::factory(); for (index, value) in values.into_iter().enumerate() { let descriptor = CandidateProfile::descriptors() .nth(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( encoder: &mut Encoder, value: ConfigValue, ) -> Result<(), minicbor::encode::Error> where W: Write, { match value { ConfigValue::Bool(value) => encoder.bool(value)?, ConfigValue::U8(value) => encoder.u8(value)?, ConfigValue::U16(value) => encoder.u16(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 { match kind { ValueKind::Bool => decoder.bool().map(ConfigValue::Bool), ValueKind::U8 => decoder.u8().map(ConfigValue::U8), ValueKind::U16 => decoder.u16().map(ConfigValue::U16), 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::factory(), &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_candidate_before_writing() { let capabilities = VehicleCapabilities::quad_x(); let mut candidate = CandidateProfile::factory(); 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::factory(); for descriptor in CandidateProfile::descriptors().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() .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::factory(); 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( 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::factory(); 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) } }