feat(mavlink): adopt the kaptan-native dialect

This commit is contained in:
Onur Özkan committed 2026-08-24 00:56:06 +03:00
1 parent 1c90870634
commit 5f91465e66
87 files changed
+7733 -6850

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,53 @@
//! Allocation-free CRC-32C shared by transferable artifacts and flash records.
/// Incremental CRC-32C calculation.
pub(crate) struct Crc32c(u32);
impl Crc32c {
/// Starts a CRC-32C calculation.
pub(crate) const fn new() -> Self {
Self(!0)
}
/// Extends the calculation with one byte span.
pub(crate) fn update(&mut self, bytes: &[u8]) {
for byte in bytes {
self.update_byte(*byte);
}
}
/// Extends the calculation with one byte.
pub(crate) fn update_byte(&mut self, byte: u8) {
self.0 ^= u32::from(byte);
for _ in 0..8 {
self.0 = (self.0 >> 1) ^ (0x82f6_3b78 & 0_u32.wrapping_sub(self.0 & 1));
}
}
/// Returns the finalized CRC-32C value.
pub(crate) const fn finish(&self) -> u32 {
!self.0
}
}
/// Calculates CRC-32C over one byte span.
pub(crate) fn crc32c(bytes: &[u8]) -> u32 {
let mut crc = Crc32c::new();
crc.update(bytes);
crc.finish()
}
#[cfg(test)]
mod tests {
use super::{Crc32c, crc32c};
#[test]
fn incremental_and_contiguous_crc_match_the_standard_check_value() {
let mut incremental = Crc32c::new();
incremental.update(b"1234");
incremental.update(b"56789");
assert_eq!(incremental.finish(), 0xe306_9283);
assert_eq!(crc32c(b"123456789"), 0xe306_9283);
}
}
+16
View File
@@ -29,6 +29,22 @@ impl ValidationRule {
pub const fn code(self) -> u16 {
self as u16
}
/// Parses a stable nonzero code.
pub(crate) const fn try_from_code(code: u16) -> Option<Self> {
let rule = match code {
1 => Self::NonFinite,
2 => Self::Zero,
3 => Self::BelowMinimum,
4 => Self::AboveMaximum,
5 => Self::Inconsistent,
6 => Self::UnsupportedCapability,
7 => Self::UnsupportedFormat,
8 => Self::InvalidEnumValue,
_ => return None,
};
Some(rule)
}
}
/// An allocation-free validation issue.
+67 -57
View File
@@ -8,9 +8,9 @@ use kaptan_err_reportable::KaptanError;
/// Enum values and indexed discovery use their native `u8` domain. Options
/// are immutable flash metadata, so no smaller runtime resource limit is
/// necessary.
pub const MAX_ENUM_OPTIONS_PER_FIELD: usize = u8::MAX as usize;
pub(crate) const MAX_ENUM_OPTIONS_PER_FIELD: usize = u8::MAX as usize;
/// Maximum UTF-8 bytes advertised for one enum variant name.
pub const MAX_ENUM_OPTION_LABEL_BYTES: usize = 32;
pub(crate) const MAX_ENUM_OPTION_LABEL_BYTES: usize = 32;
/// A stable, schema-local identity for one editable configuration field.
///
@@ -53,6 +53,10 @@ impl FieldId {
pub enum ConfigValue {
/// Boolean value.
Bool(bool),
/// Unsigned 8-bit integer.
U8(u8),
/// Unsigned 16-bit integer.
U16(u16),
/// Signed 32-bit integer.
I32(i32),
/// Unsigned 32-bit integer.
@@ -65,13 +69,6 @@ pub enum ConfigValue {
F64(f64),
}
/// One named value advertised for an enum-backed configuration field.
///
/// Enum metadata belongs to the compiled schema. Generic transports can
/// therefore render choices without knowing field keys or firmware-specific
/// enum types. The tuple is `(stable u8 representation, display label)`.
pub type EnumOption = (u8, &'static str);
/// Failure to project a generic configuration scalar into its native type.
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -84,9 +81,9 @@ pub enum ScalarValueError {
/// Native representation used by generated configuration fields.
///
/// The generic manager and transports retain their compact fixed scalar
/// protocol while board and runtime structures keep the narrow or enum type
/// that actually models the value.
/// Generic artifacts and private snapshots retain the native scalar kind while
/// board and runtime structures keep the narrow or enum type that models the
/// value.
#[doc(hidden)]
pub trait ConfigScalar: Copy {
/// Generic scalar kind used at schema and transport boundaries.
@@ -117,41 +114,21 @@ macro_rules! direct_config_scalar {
}
direct_config_scalar!(bool, Bool, Bool);
direct_config_scalar!(u8, U8, U8);
direct_config_scalar!(u16, U16, U16);
direct_config_scalar!(i32, I32, I32);
direct_config_scalar!(u32, U32, U32);
direct_config_scalar!(u64, U64, U64);
direct_config_scalar!(f32, F32, F32);
direct_config_scalar!(f64, F64, F64);
macro_rules! narrow_unsigned_config_scalar {
($type:ty) => {
impl ConfigScalar for $type {
const KIND: ValueKind = ValueKind::U32;
fn into_config_value(self) -> ConfigValue {
ConfigValue::U32(u32::from(self))
}
fn try_from_config_value(value: ConfigValue) -> Result<Self, ScalarValueError> {
match value {
ConfigValue::U32(value) => {
Self::try_from(value).map_err(|_| ScalarValueError::OutOfDomain)
}
value => Err(ScalarValueError::WrongKind(value.kind())),
}
}
}
};
}
narrow_unsigned_config_scalar!(u8);
narrow_unsigned_config_scalar!(u16);
impl ConfigValue {
/// The declared value kind.
pub const fn kind(self) -> ValueKind {
match self {
Self::Bool(_) => ValueKind::Bool,
Self::U8(_) => ValueKind::U8,
Self::U16(_) => ValueKind::U16,
Self::I32(_) => ValueKind::I32,
Self::U32(_) => ValueKind::U32,
Self::U64(_) => ValueKind::U64,
@@ -161,9 +138,11 @@ impl ConfigValue {
}
/// Canonical scalar bits for compact transport encodings.
pub const fn raw_bits(self) -> u64 {
pub(crate) const fn raw_bits(self) -> u64 {
match self {
Self::Bool(value) => value as u64,
Self::U8(value) => value as u64,
Self::U16(value) => value as u64,
Self::I32(value) => value as u32 as u64,
Self::U32(value) => value as u64,
Self::U64(value) => value,
@@ -172,28 +151,37 @@ impl ConfigValue {
}
}
/// Decodes the canonical scalar representation used by snapshots and the
/// configuration wire protocol.
pub const fn try_from_raw_bits(kind: ValueKind, bits: u64) -> Option<Self> {
pub(crate) fn write_le_bytes(self, output: &mut [u8]) {
let bytes = self.raw_bits().to_le_bytes();
let width = self.kind().width();
output[..width].copy_from_slice(&bytes[..width]);
}
pub(crate) fn from_le_bytes(kind: ValueKind, bytes: &[u8]) -> Option<Self> {
if bytes.len() != kind.width() {
return None;
}
let mut raw = [0; 8];
raw[..bytes.len()].copy_from_slice(bytes);
let bits = u64::from_le_bytes(raw);
match kind {
ValueKind::Bool => match bits {
0 => Some(Self::Bool(false)),
1 => Some(Self::Bool(true)),
_ => None,
},
ValueKind::I32 if bits <= u32::MAX as u64 => Some(Self::I32(bits as u32 as i32)),
ValueKind::U32 if bits <= u32::MAX as u64 => Some(Self::U32(bits as u32)),
ValueKind::U8 => Some(Self::U8(bits as u8)),
ValueKind::U16 => Some(Self::U16(bits as u16)),
ValueKind::I32 => Some(Self::I32(bits as u32 as i32)),
ValueKind::U32 => Some(Self::U32(bits as u32)),
ValueKind::U64 => Some(Self::U64(bits)),
ValueKind::F32 if bits <= u32::MAX as u64 => {
Some(Self::F32(f32::from_bits(bits as u32)))
}
ValueKind::F32 => Some(Self::F32(f32::from_bits(bits as u32))),
ValueKind::F64 => Some(Self::F64(f64::from_bits(bits))),
ValueKind::I32 | ValueKind::U32 | ValueKind::F32 => None,
}
}
}
/// The scalar kind carried in a descriptor or typed protocol message.
/// The scalar kind carried in a descriptor, KCFG field, or private snapshot.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum ValueKind {
@@ -209,6 +197,36 @@ pub enum ValueKind {
F32 = 5,
/// Double-precision float.
F64 = 6,
/// Unsigned 8-bit integer.
U8 = 7,
/// Unsigned 16-bit integer.
U16 = 8,
}
impl ValueKind {
#[doc(hidden)]
pub const fn width(self) -> usize {
match self {
Self::Bool | Self::U8 => 1,
Self::U16 => 2,
Self::I32 | Self::U32 | Self::F32 => 4,
Self::U64 | Self::F64 => 8,
}
}
pub(crate) const fn try_from_code(code: u8) -> Option<Self> {
match code {
1 => Some(Self::Bool),
2 => Some(Self::I32),
3 => Some(Self::U32),
4 => Some(Self::U64),
5 => Some(Self::F32),
6 => Some(Self::F64),
7 => Some(Self::U8),
8 => Some(Self::U16),
_ => None,
}
}
}
/// Static metadata for one configuration field.
@@ -225,12 +243,12 @@ pub struct FieldDescriptor {
/// Inclusive upper bound when the value is ordered.
pub maximum: Option<ConfigValue>,
/// Named choices for an enum-backed field, otherwise empty.
pub enum_options: &'static [EnumOption],
pub enum_options: &'static [(u8, &'static str)],
/// Human-readable unit for schema metadata.
pub unit: &'static str,
}
/// Failure while changing one transaction field.
/// Failure while changing one candidate field.
#[derive(Clone, Copy, Debug, PartialEq, KaptanError)]
pub enum FieldWriteError {
/// No descriptor exposes the requested identity.
@@ -252,14 +270,6 @@ pub enum FieldWriteError {
}
impl FieldWriteError {
/// Returns the typed local validation issue, if this was one.
pub const fn violation(self) -> Option<ConfigViolation> {
match self {
Self::Violation(violation) => Some(violation),
Self::UnknownField(_) | Self::WrongType { .. } => None,
}
}
/// Returns a field-local non-finite error.
pub const fn non_finite(field: FieldId) -> Self {
Self::Violation(ConfigViolation::field(field, ValidationRule::NonFinite))
+151 -71
View File
@@ -5,9 +5,9 @@
//! the payload. The previous committed slot is never erased until the complete
//! replacement is programmed, verified, and marked committed.
use core::marker::PhantomData;
use crate::{ConfigModel, ConfigStore, decode_scalar_snapshot, encode_scalar_snapshot};
use crate::{
ConfigModel, ConfigStore, checksum::Crc32c, decode_scalar_snapshot, encode_scalar_snapshot,
};
const MAGIC: u32 = u32::from_le_bytes(*b"KJNL");
const RECORD_VERSION: u16 = 1;
@@ -15,6 +15,7 @@ const HEADER_LENGTH: usize = 32;
const CRC_OFFSET: usize = 24;
const COMMIT_OFFSET: usize = 28;
const COMMIT_MARKER: u32 = u32::from_le_bytes(*b"DONE");
const VERIFY_CHUNK_LENGTH: usize = 32;
/// Exactly two independently erasable NOR slots used by [`DualSlotJournal`].
pub trait DualSlotNor {
@@ -51,20 +52,18 @@ pub enum JournalError<E> {
}
/// Generic persistent store backed by two independently erasable NOR slots.
pub struct DualSlotJournal<C, F, const MAX_PAYLOAD: usize> {
pub struct DualSlotJournal<F, const MAX_PAYLOAD: usize> {
flash: F,
factory: C,
_payload: PhantomData<[u8; MAX_PAYLOAD]>,
// The committed slot selected during recovery must survive until its replacement commits.
protected_slot: Option<Slot>,
}
impl<C, F, const MAX_PAYLOAD: usize> DualSlotJournal<C, F, MAX_PAYLOAD> {
/// Creates a journal whose erased-flash decode base is the board factory
/// candidate. Construction performs no flash access.
pub const fn new(flash: F, factory: C) -> Self {
impl<F, const MAX_PAYLOAD: usize> DualSlotJournal<F, MAX_PAYLOAD> {
/// Creates a journal without accessing flash.
pub const fn new(flash: F) -> Self {
Self {
flash,
factory,
_payload: PhantomData,
protected_slot: None,
}
}
@@ -74,16 +73,36 @@ impl<C, F, const MAX_PAYLOAD: usize> DualSlotJournal<C, F, MAX_PAYLOAD> {
}
}
#[derive(Clone)]
struct Record<C> {
slot: usize,
slot: Slot,
sequence: u64,
candidate: C,
}
impl<C, F, const MAX_PAYLOAD: usize> DualSlotJournal<C, F, MAX_PAYLOAD>
#[derive(Clone, Copy)]
#[repr(u8)]
enum Slot {
Left = 0,
Right = 1,
}
impl Slot {
const fn index(self) -> usize {
self as usize
}
const fn other(self) -> Self {
match self {
Self::Left => Self::Right,
Self::Right => Self::Left,
}
}
}
const _: () = assert!(core::mem::size_of::<Option<Slot>>() == 1);
impl<F, const MAX_PAYLOAD: usize> DualSlotJournal<F, MAX_PAYLOAD>
where
C: ConfigModel,
F: DualSlotNor,
{
fn geometry(&self) -> Result<(), JournalError<F::Error>> {
@@ -97,10 +116,14 @@ where
Ok(())
}
fn record(&mut self, slot: usize) -> Result<Option<Record<C>>, JournalError<F::Error>> {
fn record<C: ConfigModel>(
&mut self,
slot: Slot,
decode_base: &C,
) -> Result<Option<Record<C>>, JournalError<F::Error>> {
let mut header = [0_u8; HEADER_LENGTH];
self.flash
.read(slot, 0, &mut header)
.read(slot.index(), 0, &mut header)
.map_err(JournalError::Storage)?;
if header[..4] == [0xff; 4] {
return Ok(None);
@@ -125,15 +148,14 @@ where
}
let mut payload = [0_u8; MAX_PAYLOAD];
self.flash
.read(slot, HEADER_LENGTH, &mut payload[..payload_length])
.read(slot.index(), HEADER_LENGTH, &mut payload[..payload_length])
.map_err(JournalError::Storage)?;
if record_crc(&header, &payload[..payload_length])
!= read_u32(&header[CRC_OFFSET..COMMIT_OFFSET])
{
return Ok(None);
}
let Ok(candidate) = decode_scalar_snapshot(&payload[..payload_length], &self.factory)
else {
let Ok(candidate) = decode_scalar_snapshot(&payload[..payload_length], decode_base) else {
return Ok(None);
};
Ok(Some(Record {
@@ -143,9 +165,12 @@ where
}))
}
fn newest(&mut self) -> Result<Option<Record<C>>, JournalError<F::Error>> {
let left = self.record(0)?;
let right = self.record(1)?;
fn newest<C: ConfigModel>(
&mut self,
decode_base: &C,
) -> Result<Option<Record<C>>, JournalError<F::Error>> {
let left = self.record(Slot::Left, decode_base)?;
let right = self.record(Slot::Right, decode_base)?;
Ok(match (left, right) {
(Some(left), Some(right)) if right.sequence > left.sequence => Some(right),
(Some(left), Some(_)) | (Some(left), None) => Some(left),
@@ -156,7 +181,7 @@ where
fn program_bytes(
&mut self,
slot: usize,
slot: Slot,
offset: usize,
bytes: &[u8],
) -> Result<(), JournalError<F::Error>> {
@@ -167,32 +192,34 @@ where
let mut word = [0xff; 4];
word[..chunk.len()].copy_from_slice(chunk);
self.flash
.program_word(slot, offset + index * 4, u32::from_le_bytes(word))
.program_word(slot.index(), offset + index * 4, u32::from_le_bytes(word))
.map_err(JournalError::Storage)?;
}
Ok(())
}
}
impl<C, F, const MAX_PAYLOAD: usize> ConfigStore<C> for DualSlotJournal<C, F, MAX_PAYLOAD>
impl<C, F, const MAX_PAYLOAD: usize> ConfigStore<C> for DualSlotJournal<F, MAX_PAYLOAD>
where
C: ConfigModel,
F: DualSlotNor,
{
type Error = JournalError<F::Error>;
fn load(&mut self) -> Result<Option<C>, Self::Error> {
fn load(&mut self, factory: &C) -> Result<Option<C>, Self::Error> {
self.geometry()?;
Ok(self.newest()?.map(|record| record.candidate))
let selected = self.newest(factory)?;
self.protected_slot = selected.as_ref().map(|record| record.slot);
Ok(selected.map(|record| record.candidate))
}
fn load_matching<P>(&mut self, mut compatible: P) -> Result<Option<C>, Self::Error>
fn load_matching<P>(&mut self, factory: &C, mut compatible: P) -> Result<Option<C>, Self::Error>
where
P: FnMut(&C) -> bool,
{
self.geometry()?;
let left = self.record(0)?;
let right = self.record(1)?;
let left = self.record(Slot::Left, factory)?;
let right = self.record(Slot::Right, factory)?;
let left = left.filter(|record| compatible(&record.candidate));
let right = right.filter(|record| compatible(&record.candidate));
let selected = match (left, right) {
@@ -201,12 +228,13 @@ where
(None, Some(right)) => Some(right),
(None, None) => None,
};
self.protected_slot = selected.as_ref().map(|record| record.slot);
Ok(selected.map(|record| record.candidate))
}
fn persist(&mut self, candidate: &C) -> Result<(), Self::Error> {
self.geometry()?;
let current = self.newest()?;
let current = self.newest(candidate)?;
let sequence = current
.as_ref()
.map_or(1, |record| record.sequence.saturating_add(1));
@@ -217,7 +245,10 @@ where
{
return Err(JournalError::SequenceExhausted);
}
let target_slot = current.as_ref().map_or(0, |record| 1 - record.slot);
let target_slot = self
.protected_slot
.or_else(|| current.as_ref().map(|record| record.slot))
.map_or(Slot::Left, Slot::other);
let mut payload = [0_u8; MAX_PAYLOAD];
let payload = encode_scalar_snapshot(candidate, &mut payload)
@@ -233,49 +264,52 @@ where
header[CRC_OFFSET..COMMIT_OFFSET].copy_from_slice(&crc.to_le_bytes());
self.flash
.erase_slot(target_slot)
.erase_slot(target_slot.index())
.map_err(JournalError::Storage)?;
self.program_bytes(target_slot, 0, &header[..COMMIT_OFFSET])?;
self.program_bytes(target_slot, HEADER_LENGTH, payload)?;
let mut verify_header = [0_u8; COMMIT_OFFSET];
self.flash
.read(target_slot, 0, &mut verify_header)
.read(target_slot.index(), 0, &mut verify_header)
.map_err(JournalError::Storage)?;
let mut verify_payload = [0_u8; MAX_PAYLOAD];
self.flash
.read(
target_slot,
HEADER_LENGTH,
&mut verify_payload[..payload.len()],
)
.map_err(JournalError::Storage)?;
if verify_header != header[..COMMIT_OFFSET] || verify_payload[..payload.len()] != *payload {
if verify_header != header[..COMMIT_OFFSET] {
return Err(JournalError::Verification);
}
let mut verify = [0_u8; VERIFY_CHUNK_LENGTH];
for (chunk_index, expected) in payload.chunks(VERIFY_CHUNK_LENGTH).enumerate() {
let actual = &mut verify[..expected.len()];
self.flash
.read(
target_slot.index(),
HEADER_LENGTH + chunk_index * VERIFY_CHUNK_LENGTH,
actual,
)
.map_err(JournalError::Storage)?;
if actual != expected {
return Err(JournalError::Verification);
}
}
self.flash
.program_word(target_slot, COMMIT_OFFSET, COMMIT_MARKER)
.program_word(target_slot.index(), COMMIT_OFFSET, COMMIT_MARKER)
.map_err(JournalError::Storage)?;
let Some(record) = self.record(target_slot)? else {
let Some(record) = self.record(target_slot, candidate)? else {
return Err(JournalError::Verification);
};
if record.sequence != sequence {
return Err(JournalError::Verification);
}
self.protected_slot = Some(target_slot);
Ok(())
}
}
fn record_crc(header: &[u8; HEADER_LENGTH], payload: &[u8]) -> u32 {
let mut crc = !0_u32;
for byte in header[..CRC_OFFSET].iter().chain(payload) {
crc ^= u32::from(*byte);
for _ in 0..8 {
crc = (crc >> 1) ^ (0x82f6_3b78 & 0_u32.wrapping_sub(crc & 1));
}
}
!crc
let mut crc = Crc32c::new();
crc.update(&header[..CRC_OFFSET]);
crc.update(payload);
crc.finish()
}
fn read_u16(bytes: &[u8]) -> u16 {
@@ -449,27 +483,28 @@ mod tests {
}
}
type Journal = DualSlotJournal<Candidate, FakeNor, 64>;
type Journal = DualSlotJournal<FakeNor, 64>;
const FACTORY: Candidate = Candidate(20);
#[test]
fn alternates_slots_and_recovers_the_newest_record() {
let mut journal = Journal::new(FakeNor::erased(), Candidate(20));
assert_eq!(journal.load().unwrap(), None);
let mut journal = Journal::new(FakeNor::erased());
assert_eq!(journal.load(&FACTORY).unwrap(), None);
journal.persist(&Candidate(30)).unwrap();
journal.persist(&Candidate(40)).unwrap();
let flash = journal.into_flash();
let mut recovered = Journal::new(flash, Candidate(20));
assert_eq!(recovered.load().unwrap(), Some(Candidate(40)));
let mut recovered = Journal::new(flash);
assert_eq!(recovered.load(&FACTORY).unwrap(), Some(Candidate(40)));
}
#[test]
fn every_interrupted_erase_or_word_program_preserves_the_old_record() {
let mut initial = Journal::new(FakeNor::erased(), Candidate(20));
let mut initial = Journal::new(FakeNor::erased());
initial.persist(&Candidate(30)).unwrap();
let baseline = initial.into_flash();
let mut successful = Journal::new(baseline.clone(), Candidate(20));
let mut successful = Journal::new(baseline.clone());
let start = successful.flash.operations;
successful.persist(&Candidate(40)).unwrap();
let operation_count = successful.flash.operations - start;
@@ -477,15 +512,15 @@ mod tests {
for interrupted_at in 0..operation_count {
let mut flash = baseline.clone();
flash.fail_at = Some(flash.operations + interrupted_at);
let mut interrupted = Journal::new(flash, Candidate(20));
let mut interrupted = Journal::new(flash);
assert!(matches!(
interrupted.persist(&Candidate(40)),
Err(JournalError::Storage(FakeError::PowerLoss))
));
let flash = interrupted.into_flash();
let mut recovered = Journal::new(flash, Candidate(20));
let mut recovered = Journal::new(flash);
assert_eq!(
recovered.load().unwrap(),
recovered.load(&FACTORY).unwrap(),
Some(Candidate(30)),
"mutation {interrupted_at} exposed a partial replacement"
);
@@ -494,31 +529,76 @@ mod tests {
#[test]
fn corrupt_newest_record_falls_back_to_the_previous_slot() {
let mut journal = Journal::new(FakeNor::erased(), Candidate(20));
let mut journal = Journal::new(FakeNor::erased());
journal.persist(&Candidate(30)).unwrap();
journal.persist(&Candidate(40)).unwrap();
let mut flash = journal.into_flash();
flash.slots[1][HEADER_LENGTH + 2] ^= 1;
let mut recovered = Journal::new(flash, Candidate(20));
assert_eq!(recovered.load().unwrap(), Some(Candidate(30)));
let mut recovered = Journal::new(flash);
assert_eq!(recovered.load(&FACTORY).unwrap(), Some(Candidate(30)));
}
#[test]
fn compatibility_filter_can_select_the_older_structurally_valid_slot() {
let mut journal = Journal::new(FakeNor::erased(), Candidate(20));
let mut journal = Journal::new(FakeNor::erased());
journal.persist(&Candidate(30)).unwrap();
journal.persist(&Candidate(40)).unwrap();
assert_eq!(
journal
.load_matching(|candidate| candidate.0 <= 35)
.load_matching(&FACTORY, |candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30))
);
assert_eq!(
journal.load_matching(|candidate| candidate.0 < 10).unwrap(),
journal
.load_matching(&FACTORY, |candidate| candidate.0 < 10)
.unwrap(),
None
);
}
#[test]
fn replacing_an_older_compatible_record_preserves_it_across_every_interruption() {
let mut initial = Journal::new(FakeNor::erased());
initial.persist(&Candidate(30)).unwrap();
initial.persist(&Candidate(40)).unwrap();
let baseline = initial.into_flash();
let mut successful = Journal::new(baseline.clone());
assert_eq!(
successful
.load_matching(&FACTORY, |candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30))
);
let start = successful.flash.operations;
successful.persist(&Candidate(35)).unwrap();
let operation_count = successful.flash.operations - start;
for interrupted_at in 0..operation_count {
let mut journal = Journal::new(baseline.clone());
assert_eq!(
journal
.load_matching(&FACTORY, |candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30))
);
journal.flash.fail_at = Some(journal.flash.operations + interrupted_at);
assert!(matches!(
journal.persist(&Candidate(35)),
Err(JournalError::Storage(FakeError::PowerLoss))
));
let mut recovered = Journal::new(journal.into_flash());
assert_eq!(
recovered
.load_matching(&FACTORY, |candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30)),
"mutation {interrupted_at} erased the selected compatible record"
);
}
}
}
+14 -8
View File
@@ -1,16 +1,18 @@
//! Runtime-configuration primitives shared by vehicle-specific schemas.
//!
//! This crate has no knowledge of MAVLink, filesystems, board routing, motors,
//! or a particular airframe. It owns the staged-versus-active lifecycle and
//! or a particular airframe. It owns the committed-versus-active lifecycle and
//! parameter metadata; vehicle crates provide the typed schema and complete
//! validation. Persistence framing and transports remain separate adapters;
//! this crate provides only the bounded scalar snapshot payload shared by
//! storage backends.
//! validation. It also owns the canonical public KCFG artifact and the compact
//! private scalar snapshot. Transport and storage framing remain separate
//! adapters.
#![no_std]
extern crate self as kaptan_config;
mod artifact;
mod checksum;
pub mod error;
pub mod field;
pub mod journal;
@@ -19,16 +21,20 @@ pub mod snapshot;
pub mod storage;
pub mod validation;
pub use artifact::{
KcfgArtifact, KcfgDecodeError, KcfgEncodeError, KcfgEnumOption, KcfgEnumOptions,
KcfgExportPlan, KcfgExporter, KcfgField, KcfgFields, KcfgImporter, KcfgResultDetail,
decode_kcfg, encode_edited_kcfg, kcfg_length,
};
pub use error::{ConfigViolation, ValidationRule};
pub use field::{
ConfigModel, ConfigScalar, ConfigValue, EnumOption, FieldDescriptor, FieldId, FieldWriteError,
MAX_ENUM_OPTION_LABEL_BYTES, MAX_ENUM_OPTIONS_PER_FIELD, ScalarValueError, ValueKind,
ConfigModel, ConfigScalar, ConfigValue, FieldDescriptor, FieldId, FieldWriteError,
ScalarValueError, ValueKind,
};
pub use journal::{DualSlotJournal, DualSlotNor, JournalError};
pub use kaptan_config_derive::{Config, ConfigEnum};
pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError};
pub use lifecycle::{ConfigManager, ReplaceError, ReplaceOutcome, StartupError};
pub use snapshot::{
SCALAR_SNAPSHOT_ENTRY_LENGTH, SCALAR_SNAPSHOT_HEADER_LENGTH, SCALAR_SNAPSHOT_VERSION,
ScalarSnapshotDecodeError, ScalarSnapshotEncodeError, SnapshotModelError,
decode_scalar_snapshot, encode_scalar_snapshot, scalar_snapshot_length,
};
+204 -234
View File
@@ -1,21 +1,8 @@
//! Transactional committed-versus-active configuration lifecycle.
//! Whole-candidate committed-versus-active configuration lifecycle.
use crate::{
ConfigModel, ConfigStore, ConfigValue, ConfigViolation, FieldDescriptor, FieldId,
FieldWriteError,
};
use kaptan_err_reportable::KaptanError;
/// Observable state of the bounded configuration transaction slot.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigState {
/// The boot-active and committed snapshots match and no edit is open.
Active,
/// One isolated RAM transaction is open.
Editing,
/// A newer committed snapshot will take effect after restart.
RestartRequired,
}
use crate::{ConfigModel, ConfigStore, ConfigViolation};
/// Failure while recovering a boot configuration from a store.
#[derive(Debug, KaptanError)]
@@ -26,55 +13,40 @@ pub enum StartupError<E> {
/// A committed record did not validate against this firmware's schema.
#[kaptan_error(id = 1102)]
InvalidPersisted(ConfigViolation),
/// The board's compiled factory candidate violates its immutable context.
#[kaptan_error(id = 1103)]
InvalidFactory(ConfigViolation),
}
/// Failure while beginning a transaction.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum BeginError {
/// Exactly one transaction is already open.
#[kaptan_error(id = 1201)]
TransactionActive,
/// Result of replacing the complete committed configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ReplaceOutcome {
/// Whether committed values differ from the immutable boot-active view.
pub restart_required: bool,
}
/// Failure while writing a scalar into the bounded transaction.
#[derive(Clone, Copy, Debug, PartialEq, KaptanError)]
pub enum SetError {
/// No transaction is open.
#[kaptan_error(id = 1301)]
NoTransaction,
/// The candidate rejected the local scalar without mutation.
#[kaptan_error(id = 1302)]
Field(FieldWriteError),
}
/// Failure while validating or committing a transaction.
/// Failure while validating and persisting one complete candidate.
#[derive(Debug, PartialEq, KaptanError)]
pub enum CommitError<E> {
/// No transaction is open.
#[kaptan_error(id = 1401)]
NoTransaction,
pub enum ReplaceError<E> {
/// The complete candidate violates a relational or capability invariant.
#[kaptan_error(id = 1402)]
InvalidCandidate(ConfigViolation),
/// The storage backend could not commit the snapshot.
/// The storage backend could not commit the validated candidate.
#[kaptan_error(id = 1403)]
Store(E),
}
/// Owns the immutable boot-active configuration, the latest durable candidate, and
/// exactly one RAM edit transaction.
/// Owns the immutable boot-active configuration and latest durable candidate.
///
/// P1 deliberately has no activation operation: [`Self::active`] never
/// changes after boot. `commit` atomically persists a complete validated
/// candidate, then reports [`ConfigState::RestartRequired`].
/// There is deliberately no in-place activation operation: [`Self::active`]
/// never changes after boot. A file endpoint owns its in-progress candidate;
/// this manager receives only complete candidates for validation and commit.
pub struct ConfigManager<C: ConfigModel, S> {
active: C::Active,
factory: C,
committed: C,
transaction: Option<C>,
context: C::Context,
store: S,
restart_required: bool,
}
impl<C, S> ConfigManager<C, S>
@@ -82,22 +54,6 @@ where
C: ConfigModel,
S: ConfigStore<C>,
{
/// Starts from a validated factory candidate when no persistent record is
/// available. Flight callers separately decide whether setup/calibration
/// is sufficient to arm; no recovery is implicit.
pub fn new(factory: C, context: C::Context, store: S) -> Result<Self, ConfigViolation> {
let active = factory.validate(&context)?;
Ok(Self {
active,
factory: factory.clone(),
committed: factory,
transaction: None,
context,
store,
restart_required: false,
})
}
/// Recovers the newest valid committed snapshot or uses the supplied
/// factory candidate when the store is empty.
pub fn recover(
@@ -105,20 +61,24 @@ where
context: C::Context,
mut store: S,
) -> Result<Self, StartupError<S::Error>> {
let Some(committed) = store.load().map_err(StartupError::Store)? else {
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
};
let active = committed
let factory_active = factory
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
.map_err(StartupError::InvalidFactory)?;
let (active, committed) = match store.load(&factory).map_err(StartupError::Store)? {
Some(committed) => {
let active = committed
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
(active, committed)
}
None => (factory_active, factory.clone()),
};
Ok(Self {
active,
factory,
committed,
transaction: None,
context,
store,
restart_required: false,
})
}
@@ -133,23 +93,27 @@ where
context: C::Context,
mut store: S,
) -> Result<Self, StartupError<S::Error>> {
let Some(committed) = store
.load_matching(|candidate| candidate.validate(&context).is_ok())
.map_err(StartupError::Store)?
else {
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
};
let active = committed
let factory_active = factory
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
.map_err(StartupError::InvalidFactory)?;
let committed = store
.load_matching(&factory, |candidate| candidate.validate(&context).is_ok())
.map_err(StartupError::Store)?;
let (active, committed) = match committed {
Some(committed) => {
let active = committed
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
(active, committed)
}
None => (factory_active, factory.clone()),
};
Ok(Self {
active,
factory,
committed,
transaction: None,
context,
store,
restart_required: false,
})
}
@@ -158,122 +122,92 @@ where
&self.active
}
/// Returns the last durably committed candidate value.
pub fn committed_value(&self, id: FieldId) -> Option<ConfigValue> {
self.committed.field_value(id)
/// Returns the complete last durably committed candidate.
pub const fn committed(&self) -> &C {
&self.committed
}
/// Returns the selected board/image factory value for descriptor metadata.
pub fn factory_value(&self, id: FieldId) -> Option<ConfigValue> {
self.factory.field_value(id)
/// Returns the complete board/image factory candidate.
pub const fn factory(&self) -> &C {
&self.factory
}
/// Returns a value from the immutable boot-active snapshot.
pub fn active_value(&self, id: FieldId) -> Option<ConfigValue> {
C::active_field_value(&self.active, id)
/// Whether committed values differ from the immutable boot-active view.
pub fn restart_required(&self) -> bool {
!active_matches_candidate::<C>(&self.active, &self.committed)
}
/// Returns a transaction value when an edit is open.
pub fn transaction_value(&self, id: FieldId) -> Option<ConfigValue> {
self.transaction.as_ref()?.field_value(id)
}
/// Returns descriptor metadata by stable schema index.
pub fn descriptor(&self, index: u16) -> Option<&'static FieldDescriptor> {
C::descriptor(index)
}
/// Number of fields exposed by the compiled registry.
pub fn field_count(&self) -> u16 {
C::field_count()
}
/// Returns the current lifecycle state.
pub const fn state(&self) -> ConfigState {
if self.transaction.is_some() {
ConfigState::Editing
} else if self.restart_required {
ConfigState::RestartRequired
} else {
ConfigState::Active
}
}
/// Opens the only transaction slot from the last durably committed state.
pub fn begin(&mut self) -> Result<(), BeginError> {
if self.transaction.is_some() {
return Err(BeginError::TransactionActive);
}
self.transaction = Some(self.committed.clone());
kaptan_log::debug!("edit transaction opened");
Ok(())
}
/// Applies one locally valid scalar in the open transaction.
/// Validates and atomically persists one complete replacement candidate.
///
/// Cross-field-invalid intermediate states are allowed so multi-field
/// edits can be made naturally. They are rejected by `validate`/`commit`.
pub fn set(&mut self, id: FieldId, value: ConfigValue) -> Result<ConfigValue, SetError> {
let candidate = self.transaction.as_mut().ok_or(SetError::NoTransaction)?;
candidate.set_field(id, value).map_err(SetError::Field)?;
/// An unchanged candidate does not consume a flash erase/write cycle. The
/// boot-active view remains immutable until a physical restart.
pub fn replace(&mut self, candidate: C) -> Result<ReplaceOutcome, ReplaceError<S::Error>> {
candidate
.field_value(id)
.ok_or(SetError::Field(FieldWriteError::UnknownField(id)))
.validate(&self.context)
.map_err(ReplaceError::InvalidCandidate)?;
if same_values(&candidate, &self.committed) {
return Ok(ReplaceOutcome {
restart_required: self.restart_required(),
});
}
self.store
.persist(&candidate)
.map_err(ReplaceError::Store)?;
self.committed = candidate;
let restart_required = self.restart_required();
if restart_required {
kaptan_log::info!("configuration saved; restart required");
} else {
kaptan_log::info!("configuration restored to active values");
}
Ok(ReplaceOutcome { restart_required })
}
}
/// Validates the complete open transaction without changing persistent or
/// active state.
pub fn validate(&self) -> Result<(), CommitError<S::Error>> {
let candidate = self
.transaction
.as_ref()
.ok_or(CommitError::NoTransaction)?;
match candidate.validate(&self.context) {
Ok(_) => {
kaptan_log::debug!("candidate validated");
Ok(())
}
Err(violation) => {
kaptan_log::warn!("candidate validation failed");
Err(CommitError::InvalidCandidate(violation))
}
fn same_values<C: ConfigModel>(left: &C, right: &C) -> bool {
for index in 0..C::field_count() {
let Some(descriptor) = C::descriptor(index) else {
return false;
};
let (Some(left), Some(right)) = (
left.field_value(descriptor.id),
right.field_value(descriptor.id),
) else {
return false;
};
if left.kind() != right.kind() || left.raw_bits() != right.raw_bits() {
return false;
}
}
true
}
/// Validates and durably commits the complete open transaction.
pub fn commit(&mut self) -> Result<(), CommitError<S::Error>> {
let candidate = self
.transaction
.as_ref()
.ok_or(CommitError::NoTransaction)?;
if let Err(violation) = candidate.validate(&self.context) {
kaptan_log::warn!("commit validation failed");
return Err(CommitError::InvalidCandidate(violation));
fn active_matches_candidate<C: ConfigModel>(active: &C::Active, candidate: &C) -> bool {
for index in 0..C::field_count() {
let Some(descriptor) = C::descriptor(index) else {
return false;
};
let (Some(active), Some(candidate)) = (
C::active_field_value(active, descriptor.id),
candidate.field_value(descriptor.id),
) else {
return false;
};
if active.kind() != candidate.kind() || active.raw_bits() != candidate.raw_bits() {
return false;
}
if let Err(error) = self.store.persist(candidate) {
return Err(CommitError::Store(error));
}
self.committed = candidate.clone();
self.transaction = None;
self.restart_required = true;
kaptan_log::info!("configuration saved; restart required");
Ok(())
}
/// Drops the RAM transaction without changing active or committed state.
pub fn abort(&mut self) -> bool {
let aborted = self.transaction.take().is_some();
if aborted {
kaptan_log::debug!("edit transaction aborted");
}
aborted
}
true
}
#[cfg(test)]
mod tests {
use core::cell::Cell;
use super::*;
use crate::{ConfigValue, ValidationRule, ValueKind};
use crate::{
ConfigValue, FieldDescriptor, FieldId, FieldWriteError, ValidationRule, ValueKind,
};
const VALUE: FieldId = FieldId::new(1);
const OTHER: FieldId = FieldId::new(2);
@@ -281,33 +215,33 @@ mod tests {
FieldDescriptor {
id: VALUE,
display_key: "test.value",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
kind: ValueKind::U8,
minimum: Some(ConfigValue::U8(1)),
maximum: Some(ConfigValue::U8(10)),
enum_options: &[],
unit: "count",
},
FieldDescriptor {
id: OTHER,
display_key: "test.other",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
kind: ValueKind::U8,
minimum: Some(ConfigValue::U8(1)),
maximum: Some(ConfigValue::U8(10)),
enum_options: &[],
unit: "count",
},
];
#[derive(Clone)]
#[derive(Clone, Debug, Eq, PartialEq)]
struct Candidate {
value: u32,
other: u32,
value: u8,
other: u8,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Active {
value: u32,
other: u32,
value: u8,
other: u8,
}
impl ConfigModel for Candidate {
@@ -325,34 +259,28 @@ mod tests {
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
match id {
VALUE => Some(ConfigValue::U32(self.value)),
OTHER => Some(ConfigValue::U32(self.other)),
VALUE => Some(ConfigValue::U8(self.value)),
OTHER => Some(ConfigValue::U8(self.other)),
_ => None,
}
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
match id {
VALUE => Some(ConfigValue::U32(active.value)),
OTHER => Some(ConfigValue::U32(active.other)),
VALUE => Some(ConfigValue::U8(active.value)),
OTHER => Some(ConfigValue::U8(active.other)),
_ => None,
}
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
let ConfigValue::U32(value) = value else {
let ConfigValue::U8(value) = value else {
return Err(FieldWriteError::WrongType {
field: id,
expected: ValueKind::U32,
expected: ValueKind::U8,
actual: value.kind(),
});
};
if !(1..=10).contains(&value) {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
ValidationRule::AboveMaximum,
)));
}
match id {
VALUE => self.value = value,
OTHER => self.other = value,
@@ -376,16 +304,17 @@ mod tests {
}
}
struct MemoryStore;
struct MemoryStore<'a>(&'a Cell<usize>);
impl ConfigStore<Candidate> for MemoryStore {
impl ConfigStore<Candidate> for MemoryStore<'_> {
type Error = ();
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
fn load(&mut self, _: &Candidate) -> Result<Option<Candidate>, Self::Error> {
Ok(None)
}
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
self.0.set(self.0.get() + 1);
Ok(())
}
}
@@ -395,7 +324,7 @@ mod tests {
impl ConfigStore<Candidate> for IncompatibleStore {
type Error = ();
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
fn load(&mut self, _: &Candidate) -> Result<Option<Candidate>, Self::Error> {
Ok(Some(Candidate { value: 9, other: 1 }))
}
@@ -404,44 +333,72 @@ mod tests {
}
}
#[test]
fn invalid_intermediate_transaction_never_changes_active_or_committed() {
let mut manager =
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap();
assert_eq!(
manager.set(VALUE, ConfigValue::U32(5)),
Ok(ConfigValue::U32(5))
);
assert_eq!(
manager.validate(),
Err(CommitError::InvalidCandidate(ConfigViolation::related(
VALUE,
OTHER,
ValidationRule::Inconsistent
)))
);
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(2)));
assert_eq!(manager.transaction_value(VALUE), Some(ConfigValue::U32(5)));
assert_eq!(manager.state(), ConfigState::Editing);
struct ValidStore;
impl ConfigStore<Candidate> for ValidStore {
type Error = ();
fn load(&mut self, _: &Candidate) -> Result<Option<Candidate>, Self::Error> {
Ok(Some(Candidate { value: 2, other: 4 }))
}
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
Ok(())
}
}
#[test]
fn commit_keeps_active_immutable_and_abort_retains_committed_snapshot() {
fn whole_replacement_is_validated_and_unchanged_values_skip_flash() {
let writes = Cell::new(0);
let factory = Candidate { value: 2, other: 4 };
let mut manager =
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(3)).unwrap();
assert_eq!(manager.commit(), Ok(()));
ConfigManager::recover(factory.clone(), (), MemoryStore(&writes)).unwrap();
assert_eq!(
manager.replace(Candidate { value: 5, other: 4 }),
Err(ReplaceError::InvalidCandidate(ConfigViolation::related(
VALUE,
OTHER,
ValidationRule::Inconsistent,
)))
);
assert_eq!(manager.committed(), &factory);
assert_eq!(writes.get(), 0);
assert_eq!(
manager.replace(factory.clone()),
Ok(ReplaceOutcome {
restart_required: false,
})
);
assert_eq!(writes.get(), 0);
}
#[test]
fn restart_requirement_is_derived_and_clears_when_active_values_are_restored() {
let writes = Cell::new(0);
let factory = Candidate { value: 2, other: 4 };
let mut manager =
ConfigManager::recover(factory.clone(), (), MemoryStore(&writes)).unwrap();
assert_eq!(
manager.replace(Candidate { value: 3, other: 4 }),
Ok(ReplaceOutcome {
restart_required: true,
})
);
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(4)).unwrap();
assert!(manager.abort());
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
assert!(manager.restart_required());
assert_eq!(
manager.replace(factory.clone()),
Ok(ReplaceOutcome {
restart_required: false,
})
);
assert_eq!(manager.committed(), &factory);
assert_eq!(manager.factory(), &factory);
assert_eq!(writes.get(), 2);
}
#[test]
@@ -456,4 +413,17 @@ mod tests {
ConfigManager::recover_compatible_or_factory(factory, (), IncompatibleStore).unwrap();
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
}
#[test]
fn recovery_rejects_an_invalid_factory_even_when_flash_contains_a_valid_record() {
let invalid_factory = Candidate { value: 9, other: 1 };
assert!(matches!(
ConfigManager::recover(invalid_factory.clone(), (), ValidStore),
Err(StartupError::InvalidFactory(_))
));
assert!(matches!(
ConfigManager::recover_compatible_or_factory(invalid_factory, (), ValidStore),
Err(StartupError::InvalidFactory(_))
));
}
}
+162 -180
View File
@@ -4,23 +4,23 @@
//! storage backend remains responsible for private ordering metadata, integrity,
//! power-loss-safe commit markers, and selecting the newest valid record.
//!
//! The fixed-width little-endian layout is:
//! The native-width little-endian layout is:
//!
//! ```text
//! magic[4] | version:u8 | schema_id[16] | field_count:u16
//! magic[4]="KCSP" | version:u8 | schema_id[16] | field_count:u16
//! repeated field_count times:
//! field_id:u16 | value_kind:u8 | value_bits:u64
//! field_id:u16 | value_kind:u8 | value:[value_kind.width]
//! ```
//!
//! Field entries are emitted in descriptor order. Decoding uses only stable
//! field IDs and therefore also accepts a complete, uniquely identified set in
//! another order.
//! Field entries are emitted and decoded in descriptor order. The schema
//! fingerprint already fixes that order, so accepting permutations would add
//! work without adding a compatible persistence state.
use kaptan_err_reportable::KaptanError;
use crate::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValueKind};
const MAGIC: [u8; 4] = *b"KCFG";
const MAGIC: [u8; 4] = *b"KCSP";
const MAGIC_OFFSET: usize = 0;
const VERSION_OFFSET: usize = 4;
const SCHEMA_OFFSET: usize = 5;
@@ -28,20 +28,22 @@ const FIELD_COUNT_OFFSET: usize = 21;
const ENTRIES_OFFSET: usize = 23;
/// Current format of the generic scalar snapshot payload.
pub const SCALAR_SNAPSHOT_VERSION: u8 = 1;
pub(crate) const SCALAR_SNAPSHOT_VERSION: u8 = 2;
/// Bytes preceding the first scalar entry.
pub const SCALAR_SNAPSHOT_HEADER_LENGTH: usize = ENTRIES_OFFSET;
pub(crate) const SCALAR_SNAPSHOT_HEADER_LENGTH: usize = ENTRIES_OFFSET;
/// Fixed bytes occupied by one scalar entry.
pub const SCALAR_SNAPSHOT_ENTRY_LENGTH: usize = 11;
/// Bytes preceding the native-width value in every scalar entry.
pub(crate) const SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH: usize = 3;
/// Returns the exact encoded length for a declared number of scalar fields.
/// Returns the exact encoded length for a schema's field and value widths.
///
/// The `u16` field-count domain makes this arithmetic safe on all targets
/// supported by this crate, including 32-bit microcontrollers.
pub const fn scalar_snapshot_length(field_count: u16) -> usize {
SCALAR_SNAPSHOT_HEADER_LENGTH + field_count as usize * SCALAR_SNAPSHOT_ENTRY_LENGTH
pub const fn scalar_snapshot_length(field_count: u16, value_bytes: usize) -> usize {
SCALAR_SNAPSHOT_HEADER_LENGTH
+ field_count as usize * SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH
+ value_bytes
}
/// A broken [`ConfigModel`] registry detected before encoding or decoding.
@@ -134,15 +136,14 @@ pub enum ScalarSnapshotDecodeError {
/// An entry used a reserved stable field identity.
#[kaptan_error(id = 1708)]
InvalidFieldId(u16),
/// An entry names no field in the target schema.
/// An entry differs from the field fixed at this schema position.
#[kaptan_error(id = 1709)]
UnknownField(FieldId),
/// The complete payload identifies one field more than once.
#[kaptan_error(id = 1710)]
DuplicateField(FieldId),
/// The complete payload does not identify a required schema field.
#[kaptan_error(id = 1711)]
MissingField(FieldId),
UnexpectedField {
/// Field fixed at this position by the schema.
expected: FieldId,
/// Field carried by the private snapshot.
actual: FieldId,
},
/// An entry contains no supported scalar-kind discriminator.
#[kaptan_error(id = 1712)]
InvalidValueKind(u8),
@@ -179,7 +180,7 @@ pub fn encode_scalar_snapshot<'a, C: ConfigModel>(
) -> Result<&'a [u8], ScalarSnapshotEncodeError> {
validate_model::<C>().map_err(ScalarSnapshotEncodeError::InvalidModel)?;
let field_count = C::field_count();
let required = scalar_snapshot_length(field_count);
let required = model_snapshot_length::<C>().map_err(ScalarSnapshotEncodeError::InvalidModel)?;
if output.len() < required {
return Err(ScalarSnapshotEncodeError::BufferTooSmall {
required,
@@ -192,6 +193,7 @@ pub fn encode_scalar_snapshot<'a, C: ConfigModel>(
output[SCHEMA_OFFSET..FIELD_COUNT_OFFSET].copy_from_slice(&C::SCHEMA_ID);
output[FIELD_COUNT_OFFSET..ENTRIES_OFFSET].copy_from_slice(&field_count.to_le_bytes());
let mut entry = ENTRIES_OFFSET;
for index in 0..field_count {
let descriptor = C::descriptor(index).ok_or(ScalarSnapshotEncodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(index),
@@ -211,11 +213,11 @@ pub fn encode_scalar_snapshot<'a, C: ConfigModel>(
},
));
}
let entry = entry_offset(index);
output[entry..entry + 2].copy_from_slice(&descriptor.id.get().to_le_bytes());
output[entry + 2] = descriptor.kind as u8;
output[entry + 3..entry + SCALAR_SNAPSHOT_ENTRY_LENGTH]
.copy_from_slice(&value.raw_bits().to_le_bytes());
let width = descriptor.kind.width();
value.write_le_bytes(&mut output[entry + 3..entry + 3 + width]);
entry += SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH + width;
}
Ok(&output[..required])
@@ -265,13 +267,7 @@ pub fn decode_scalar_snapshot<C: ConfigModel>(
actual: actual_count,
});
}
let expected_length = scalar_snapshot_length(actual_count);
if input.len() < expected_length {
return Err(ScalarSnapshotDecodeError::Truncated {
required: expected_length,
available: input.len(),
});
}
let expected_length = validate_entries::<C>(input, actual_count)?;
if input.len() > expected_length {
return Err(ScalarSnapshotDecodeError::TrailingData {
expected: expected_length,
@@ -279,24 +275,25 @@ pub fn decode_scalar_snapshot<C: ConfigModel>(
});
}
validate_entries::<C>(input, actual_count)?;
let mut candidate = factory.clone();
for index in 0..actual_count {
let entry = entry_offset(index);
let mut entry = ENTRIES_OFFSET;
for _ in 0..actual_count {
let id = FieldId::try_new(read_u16(&input[entry..entry + 2]))
.expect("entry identities were structurally validated");
let kind = decode_kind(input[entry + 2]).expect("entry kinds were structurally validated");
let bits = read_u64(&input[entry + 3..entry + SCALAR_SNAPSHOT_ENTRY_LENGTH]);
let value = decode_value(id, kind, bits)?;
let kind = ValueKind::try_from_code(input[entry + 2])
.expect("entry kinds were structurally validated");
let width = kind.width();
let value = ConfigValue::from_le_bytes(kind, &input[entry + 3..entry + 3 + width])
.ok_or(ScalarSnapshotDecodeError::InvalidValueBits { field: id, kind })?;
candidate
.set_field(id, value)
.map_err(ScalarSnapshotDecodeError::InvalidWrite)?;
entry += SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH + width;
}
Ok(candidate)
}
fn validate_model<C: ConfigModel>() -> Result<(), SnapshotModelError> {
pub(crate) fn validate_model<C: ConfigModel>() -> Result<(), SnapshotModelError> {
let count = C::field_count();
for index in 0..count {
let descriptor =
@@ -318,17 +315,25 @@ fn validate_model<C: ConfigModel>() -> Result<(), SnapshotModelError> {
fn validate_entries<C: ConfigModel>(
input: &[u8],
count: u16,
) -> Result<(), ScalarSnapshotDecodeError> {
) -> Result<usize, ScalarSnapshotDecodeError> {
let mut entry = ENTRIES_OFFSET;
for index in 0..count {
let entry = entry_offset(index);
require_bytes(input, entry + SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH)?;
let raw_id = read_u16(&input[entry..entry + 2]);
let id =
FieldId::try_new(raw_id).ok_or(ScalarSnapshotDecodeError::InvalidFieldId(raw_id))?;
let descriptor =
descriptor_by_id::<C>(id).ok_or(ScalarSnapshotDecodeError::UnknownField(id))?;
let descriptor = C::descriptor(index).ok_or(ScalarSnapshotDecodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(index),
))?;
if id != descriptor.id {
return Err(ScalarSnapshotDecodeError::UnexpectedField {
expected: descriptor.id,
actual: id,
});
}
let raw_kind = input[entry + 2];
let kind =
decode_kind(raw_kind).ok_or(ScalarSnapshotDecodeError::InvalidValueKind(raw_kind))?;
let kind = ValueKind::try_from_code(raw_kind)
.ok_or(ScalarSnapshotDecodeError::InvalidValueKind(raw_kind))?;
if kind != descriptor.kind {
return Err(ScalarSnapshotDecodeError::WrongValueKind {
field: id,
@@ -336,78 +341,41 @@ fn validate_entries<C: ConfigModel>(
actual: kind,
});
}
let bits = read_u64(&input[entry + 3..entry + SCALAR_SNAPSHOT_ENTRY_LENGTH]);
let _ = decode_value(id, kind, bits)?;
for previous in 0..index {
let previous_entry = entry_offset(previous);
if read_u16(&input[previous_entry..previous_entry + 2]) == raw_id {
return Err(ScalarSnapshotDecodeError::DuplicateField(id));
}
}
let width = kind.width();
let next = entry + SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH + width;
require_bytes(input, next)?;
let _ = ConfigValue::from_le_bytes(kind, &input[entry + 3..next])
.ok_or(ScalarSnapshotDecodeError::InvalidValueBits { field: id, kind })?;
entry = next;
}
for descriptor_index in 0..count {
let descriptor =
C::descriptor(descriptor_index).ok_or(ScalarSnapshotDecodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(descriptor_index),
))?;
let mut present = false;
for entry_index in 0..count {
let entry = entry_offset(entry_index);
present |= read_u16(&input[entry..entry + 2]) == descriptor.id.get();
}
if !present {
return Err(ScalarSnapshotDecodeError::MissingField(descriptor.id));
}
}
Ok(())
Ok(entry)
}
fn descriptor_by_id<C: ConfigModel>(id: FieldId) -> Option<&'static crate::FieldDescriptor> {
fn require_bytes(input: &[u8], required: usize) -> Result<(), ScalarSnapshotDecodeError> {
if input.len() < required {
Err(ScalarSnapshotDecodeError::Truncated {
required,
available: input.len(),
})
} else {
Ok(())
}
}
fn model_snapshot_length<C: ConfigModel>() -> Result<usize, SnapshotModelError> {
let mut value_bytes = 0;
for index in 0..C::field_count() {
let descriptor = C::descriptor(index)?;
if descriptor.id == id {
return Some(descriptor);
}
let descriptor =
C::descriptor(index).ok_or(SnapshotModelError::MissingDescriptor(index))?;
value_bytes += descriptor.kind.width();
}
None
}
const fn entry_offset(index: u16) -> usize {
ENTRIES_OFFSET + index as usize * SCALAR_SNAPSHOT_ENTRY_LENGTH
}
const fn decode_kind(value: u8) -> Option<ValueKind> {
match value {
1 => Some(ValueKind::Bool),
2 => Some(ValueKind::I32),
3 => Some(ValueKind::U32),
4 => Some(ValueKind::U64),
5 => Some(ValueKind::F32),
6 => Some(ValueKind::F64),
_ => None,
}
}
fn decode_value(
field: FieldId,
kind: ValueKind,
bits: u64,
) -> Result<ConfigValue, ScalarSnapshotDecodeError> {
let invalid = || ScalarSnapshotDecodeError::InvalidValueBits { field, kind };
ConfigValue::try_from_raw_bits(kind, bits).ok_or_else(invalid)
Ok(scalar_snapshot_length(C::field_count(), value_bytes))
}
fn read_u16(bytes: &[u8]) -> u16 {
u16::from_le_bytes([bytes[0], bytes[1]])
}
fn read_u64(bytes: &[u8]) -> u64 {
u64::from_le_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
])
}
#[cfg(test)]
mod tests {
use super::*;
@@ -419,14 +387,20 @@ mod tests {
const U64: FieldId = FieldId::new(4);
const F32: FieldId = FieldId::new(5);
const F64: FieldId = FieldId::new(6);
const U8: FieldId = FieldId::new(7);
const U16: FieldId = FieldId::new(8);
const SCHEMA: [u8; 16] = *b"SCALAR-SNAPSHOT1";
const DESCRIPTORS: [FieldDescriptor; 6] = [
const SNAPSHOT_VALUE_BYTES: usize = 1 + 4 + 4 + 8 + 4 + 8 + 1 + 2;
const SNAPSHOT_LENGTH: usize = scalar_snapshot_length(8, SNAPSHOT_VALUE_BYTES);
const DESCRIPTORS: [FieldDescriptor; 8] = [
descriptor(BOOL, ValueKind::Bool),
descriptor(I32, ValueKind::I32),
descriptor(U32, ValueKind::U32),
descriptor(U64, ValueKind::U64),
descriptor(F32, ValueKind::F32),
descriptor(F64, ValueKind::F64),
descriptor(U8, ValueKind::U8),
descriptor(U16, ValueKind::U16),
];
const fn descriptor(id: FieldId, kind: ValueKind) -> FieldDescriptor {
@@ -449,18 +423,20 @@ mod tests {
wide: u64,
float: f32,
double: f64,
byte: u8,
short: u16,
}
impl Default for Candidate {
fn default() -> Self {
Self {
boolean: false,
signed: -1,
unsigned: 1,
wide: 1,
float: 1.0,
double: 1.0,
}
fn factory() -> Candidate {
Candidate {
boolean: false,
signed: -1,
unsigned: 1,
wide: 1,
float: 1.0,
double: 1.0,
byte: 1,
short: 1,
}
}
@@ -486,6 +462,8 @@ mod tests {
U64 => Some(ConfigValue::U64(self.wide)),
F32 => Some(ConfigValue::F32(self.float)),
F64 => Some(ConfigValue::F64(self.double)),
U8 => Some(ConfigValue::U8(self.byte)),
U16 => Some(ConfigValue::U16(self.short)),
_ => None,
}
}
@@ -502,6 +480,8 @@ mod tests {
(U64, ConfigValue::U64(value)) => self.wide = value,
(F32, ConfigValue::F32(value)) if value.is_finite() => self.float = value,
(F64, ConfigValue::F64(value)) if value.is_finite() => self.double = value,
(U8, ConfigValue::U8(value)) => self.byte = value,
(U16, ConfigValue::U16(value)) => self.short = value,
(U32, ConfigValue::U32(_)) => {
return Err(FieldWriteError::Violation(ConfigViolation::field(
U32,
@@ -527,8 +507,8 @@ mod tests {
}
}
fn encoded(candidate: &Candidate) -> ([u8; scalar_snapshot_length(6)], usize) {
let mut bytes = [0xa5; scalar_snapshot_length(6)];
fn encoded(candidate: &Candidate) -> ([u8; SNAPSHOT_LENGTH], usize) {
let mut bytes = [0xa5; SNAPSHOT_LENGTH];
let length = encode_scalar_snapshot(candidate, &mut bytes)
.expect("candidate encodes")
.len();
@@ -536,7 +516,11 @@ mod tests {
}
fn entry(index: u16) -> usize {
entry_offset(index)
let mut offset = ENTRIES_OFFSET;
for descriptor in &DESCRIPTORS[..usize::from(index)] {
offset += SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH + descriptor.kind.width();
}
offset
}
#[test]
@@ -548,26 +532,25 @@ mod tests {
wide: u64::MAX,
float: -0.0,
double: f64::MIN_POSITIVE,
byte: u8::MAX,
short: u16::MAX,
};
let mut storage = [0xa5; scalar_snapshot_length(6) + 7];
let mut storage = [0xa5; SNAPSHOT_LENGTH + 7];
let payload = encode_scalar_snapshot(&candidate, &mut storage).unwrap();
assert_eq!(payload.len(), scalar_snapshot_length(6));
assert_eq!(&payload[..4], b"KCFG");
assert_eq!(payload.len(), SNAPSHOT_LENGTH);
assert_eq!(&payload[..4], b"KCSP");
assert_eq!(payload[4], SCALAR_SNAPSHOT_VERSION);
assert_eq!(&payload[5..21], &SCHEMA);
assert_eq!(read_u16(&payload[21..23]), 6);
assert_eq!(
decode_scalar_snapshot(payload, &Candidate::default()),
Ok(candidate)
);
assert_eq!(read_u16(&payload[21..23]), 8);
assert_eq!(decode_scalar_snapshot(payload, &factory()), Ok(candidate));
}
#[test]
fn encoder_rejects_a_short_destination_without_writing_it() {
let required = scalar_snapshot_length(6);
let mut output = [0xa5; scalar_snapshot_length(6) - 1];
let required = SNAPSHOT_LENGTH;
let mut output = [0xa5; SNAPSHOT_LENGTH - 1];
assert_eq!(
encode_scalar_snapshot(&Candidate::default(), &mut output),
encode_scalar_snapshot(&factory(), &mut output),
Err(ScalarSnapshotEncodeError::BufferTooSmall {
required,
available: required - 1,
@@ -578,14 +561,14 @@ mod tests {
#[test]
fn decoder_rejects_every_truncation_and_trailing_data() {
let (bytes, length) = encoded(&Candidate::default());
let (bytes, length) = encoded(&factory());
for end in 0..length {
assert!(decode_scalar_snapshot(&bytes[..end], &Candidate::default()).is_err());
assert!(decode_scalar_snapshot(&bytes[..end], &factory()).is_err());
}
let mut trailing = [0_u8; scalar_snapshot_length(6) + 1];
let mut trailing = [0_u8; SNAPSHOT_LENGTH + 1];
trailing[..length].copy_from_slice(&bytes);
assert_eq!(
decode_scalar_snapshot(&trailing, &Candidate::default()),
decode_scalar_snapshot(&trailing, &factory()),
Err(ScalarSnapshotDecodeError::TrailingData {
expected: length,
actual: length + 1,
@@ -595,20 +578,20 @@ mod tests {
#[test]
fn decoder_rejects_wrong_magic_version_schema_and_count() {
let (source, _) = encoded(&Candidate::default());
let (source, _) = encoded(&factory());
let mut bytes = source;
bytes[0] ^= 1;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::InvalidMagic)
);
let mut bytes = source;
bytes[VERSION_OFFSET] += 1;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::UnsupportedVersion(2))
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::UnsupportedVersion(3))
);
let mut bytes = source;
@@ -616,7 +599,7 @@ mod tests {
let mut actual = SCHEMA;
actual[0] ^= 1;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::WrongSchema {
expected: SCHEMA,
actual,
@@ -626,60 +609,62 @@ mod tests {
let mut bytes = source;
bytes[FIELD_COUNT_OFFSET..ENTRIES_OFFSET].copy_from_slice(&5_u16.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::WrongFieldCount {
expected: 6,
expected: 8,
actual: 5,
})
);
}
#[test]
fn decoder_rejects_invalid_unknown_duplicate_and_missing_ids() {
let (source, _) = encoded(&Candidate::default());
fn decoder_rejects_invalid_and_noncanonical_field_ids() {
let (source, _) = encoded(&factory());
let mut bytes = source;
bytes[entry(0)..entry(0) + 2].copy_from_slice(&0_u16.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::InvalidFieldId(0))
);
let mut bytes = source;
bytes[entry(0)..entry(0) + 2].copy_from_slice(&100_u16.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::UnknownField(FieldId::new(100)))
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::UnexpectedField {
expected: BOOL,
actual: FieldId::new(100),
})
);
let mut bytes = source;
let first_id = bytes[entry(0)..entry(0) + 2].to_vec();
bytes[entry(1)..entry(1) + 2].copy_from_slice(&first_id);
bytes[entry(1) + 2] = ValueKind::Bool as u8;
bytes[entry(1) + 3..entry(1) + 11].copy_from_slice(&0_u64.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::DuplicateField(BOOL))
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::UnexpectedField {
expected: I32,
actual: BOOL,
})
);
// With an exact count and only known IDs, a duplicate necessarily also
// omits a schema field. Rejecting it proves incomplete sets cannot pass.
}
#[test]
fn decoder_rejects_invalid_and_wrong_kinds_and_noncanonical_bits() {
let (source, _) = encoded(&Candidate::default());
let (source, _) = encoded(&factory());
let mut bytes = source;
bytes[entry(0) + 2] = 0xff;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::InvalidValueKind(0xff))
);
let mut bytes = source;
bytes[entry(0) + 2] = ValueKind::U32 as u8;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::WrongValueKind {
field: BOOL,
expected: ValueKind::Bool,
@@ -688,29 +673,21 @@ mod tests {
);
let mut bytes = source;
bytes[entry(0) + 3..entry(0) + 11].copy_from_slice(&2_u64.to_le_bytes());
bytes[entry(0) + 3] = 2;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::InvalidValueBits {
field: BOOL,
kind: ValueKind::Bool,
})
);
let mut bytes = source;
bytes[entry(1) + 3..entry(1) + 11]
.copy_from_slice(&(u64::from(u32::MAX) + 1).to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::InvalidValueBits {
field: I32,
kind: ValueKind::I32,
})
);
// Every numeric kind now occupies its native width, so unlike the old
// widened u64 representation it cannot carry out-of-width high bits.
}
#[test]
fn decoder_accepts_reordered_entries_because_ids_are_authoritative() {
fn decoder_rejects_reordered_entries_as_noncanonical() {
let candidate = Candidate {
boolean: true,
signed: -72,
@@ -718,23 +695,28 @@ mod tests {
wide: 987_654_321,
float: 0.25,
double: -19.5,
byte: 72,
short: 4861,
};
let (mut bytes, _) = encoded(&candidate);
for offset in 0..SCALAR_SNAPSHOT_ENTRY_LENGTH {
bytes.swap(entry(0) + offset, entry(5) + offset);
for offset in 0..SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH + 4 {
bytes.swap(entry(1) + offset, entry(2) + offset);
}
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Ok(candidate)
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::UnexpectedField {
expected: I32,
actual: U32,
})
);
}
#[test]
fn decoder_routes_values_through_local_model_validation() {
let (mut bytes, _) = encoded(&Candidate::default());
bytes[entry(2) + 3..entry(2) + 11].copy_from_slice(&101_u64.to_le_bytes());
let (mut bytes, _) = encoded(&factory());
bytes[entry(2) + 3..entry(2) + 7].copy_from_slice(&101_u32.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::InvalidWrite(
FieldWriteError::Violation(ConfigViolation::field(
U32,
@@ -784,7 +766,7 @@ mod tests {
#[test]
fn encoder_and_decoder_reject_a_broken_model_registry() {
let mut output = [0_u8; scalar_snapshot_length(1)];
let mut output = [0_u8; scalar_snapshot_length(1, 8)];
assert_eq!(
encode_scalar_snapshot(&MissingDescriptor, &mut output),
Err(ScalarSnapshotEncodeError::InvalidModel(
@@ -792,7 +774,7 @@ mod tests {
))
);
let (bytes, _) = encoded(&Candidate::default());
let (bytes, _) = encoded(&factory());
assert!(matches!(
decode_scalar_snapshot(&bytes, &MissingDescriptor),
Err(ScalarSnapshotDecodeError::InvalidModel(
+5 -3
View File
@@ -12,18 +12,20 @@ pub trait ConfigStore<C> {
type Error;
/// Loads the newest valid committed candidate, if one exists.
fn load(&mut self) -> Result<Option<C>, Self::Error>;
fn load(&mut self, factory: &C) -> Result<Option<C>, Self::Error>;
/// Loads the newest structurally valid candidate accepted by `compatible`.
///
/// Single-record stores inherit this implementation. Journals should
/// override it so an incompatible newest record can fall back to an older
/// compatible slot without weakening structural integrity checks.
fn load_matching<F>(&mut self, mut compatible: F) -> Result<Option<C>, Self::Error>
fn load_matching<F>(&mut self, factory: &C, mut compatible: F) -> Result<Option<C>, Self::Error>
where
F: FnMut(&C) -> bool,
{
Ok(self.load()?.filter(|candidate| compatible(candidate)))
Ok(self
.load(factory)?
.filter(|candidate| compatible(candidate)))
}
/// Persists one complete already-validated candidate atomically.
@@ -11,6 +11,9 @@ pub trait Config {
/// Number of scalar fields in the flattened tree.
const FIELD_COUNT: usize;
/// Exact value bytes in the flattened private snapshot.
const SNAPSHOT_VALUE_BYTES: usize;
/// Fingerprint generated from the complete declared schema.
const SCHEMA_ID: [u8; 16];
@@ -79,13 +82,13 @@ pub fn __unique_display_keys<C: Config>() -> bool {
#[doc(hidden)]
pub fn __valid_enum_options(descriptors: &[FieldDescriptor]) -> bool {
for descriptor in descriptors {
if descriptor.enum_options.len() > crate::MAX_ENUM_OPTIONS_PER_FIELD {
if descriptor.enum_options.len() > crate::field::MAX_ENUM_OPTIONS_PER_FIELD {
return false;
}
for (index, option) in descriptor.enum_options.iter().enumerate() {
if option.1.is_empty()
|| option.1.len() > crate::MAX_ENUM_OPTION_LABEL_BYTES
|| descriptor.kind != crate::ValueKind::U32
|| option.1.len() > crate::field::MAX_ENUM_OPTION_LABEL_BYTES
|| descriptor.kind != crate::ValueKind::U8
{
return false;
}