feat(mavlink): adopt the kaptan-native dialect
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user