refactor(firmware): centralize configuration and diagnostics

This commit is contained in:
Onur Özkan committed 2026-08-22 18:37:18 +03:00
1 parent 7048ed89d0
commit 387fdaad23
140 files changed
+6891 -4541

No files matched your search

+4 -2
View File
@@ -2,7 +2,7 @@
use crate::FieldId;
/// The invariant violated by a configuration field or complete profile.
/// The invariant violated by a configuration field or complete candidate.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u16)]
pub enum ValidationRule {
@@ -20,6 +20,8 @@ pub enum ValidationRule {
UnsupportedCapability = 6,
/// The encoded snapshot format cannot be used by this firmware.
UnsupportedFormat = 7,
/// A scalar does not name a declared variant of its typed enum field.
InvalidEnumValue = 8,
}
impl ValidationRule {
@@ -59,7 +61,7 @@ impl ConfigViolation {
}
}
/// Constructs a profile-level violation with no scalar identity.
/// Constructs a configuration-wide violation with no scalar identity.
pub const fn global(rule: ValidationRule) -> Self {
Self {
primary: None,
+115 -18
View File
@@ -3,9 +3,18 @@
use crate::{ConfigViolation, ValidationRule};
use kaptan_log::KaptanError;
/// Maximum choices representable by one enum-backed field.
///
/// 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;
/// Maximum UTF-8 bytes advertised for one enum variant name.
pub const MAX_ENUM_OPTION_LABEL_BYTES: usize = 32;
/// A stable, schema-local identity for one editable configuration field.
///
/// IDs are never reused within a schema generation. They are compact wire
/// IDs are never reused within a schema version. They are compact wire
/// identities; display strings belong to metadata and diagnostics only.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)]
#[repr(transparent)]
@@ -56,6 +65,88 @@ 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)]
pub enum ScalarValueError {
/// The generic scalar kind does not match the native field type.
WrongKind(ValueKind),
/// The representation has the correct scalar kind but no native value.
OutOfDomain,
}
/// 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.
#[doc(hidden)]
pub trait ConfigScalar: Copy {
/// Generic scalar kind used at schema and transport boundaries.
const KIND: ValueKind;
/// Projects the native value into the generic scalar boundary.
fn into_config_value(self) -> ConfigValue;
/// Validates and projects one generic scalar into the native type.
fn try_from_config_value(value: ConfigValue) -> Result<Self, ScalarValueError>;
}
macro_rules! direct_config_scalar {
($type:ty, $kind:ident, $variant:ident) => {
impl ConfigScalar for $type {
const KIND: ValueKind = ValueKind::$kind;
fn into_config_value(self) -> ConfigValue {
ConfigValue::$variant(self)
}
fn try_from_config_value(value: ConfigValue) -> Result<Self, ScalarValueError> {
match value {
ConfigValue::$variant(value) => Ok(value),
value => Err(ScalarValueError::WrongKind(value.kind())),
}
}
}
};
}
direct_config_scalar!(bool, Bool, Bool);
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 {
@@ -80,6 +171,26 @@ impl ConfigValue {
Self::F64(value) => value.to_bits(),
}
}
/// 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> {
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::U64 => Some(Self::U64(bits)),
ValueKind::F32 if bits <= u32::MAX as u64 => {
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.
@@ -100,18 +211,6 @@ pub enum ValueKind {
F64 = 6,
}
/// When a committed field may affect the active runtime.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum ApplyPolicy {
/// Reserved for individually proven live-safe changes.
Live = 1,
/// Reserved for controlled output-inhibited reconfiguration.
DisarmedReset = 2,
/// P1 policy: the field takes effect only at the next boot.
RestartRequired = 3,
}
/// Static metadata for one configuration field.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FieldDescriptor {
@@ -125,12 +224,10 @@ pub struct FieldDescriptor {
pub minimum: Option<ConfigValue>,
/// Inclusive upper bound when the value is ordered.
pub maximum: Option<ConfigValue>,
/// Firmware-build default.
pub default: ConfigValue,
/// Named choices for an enum-backed field, otherwise empty.
pub enum_options: &'static [EnumOption],
/// Human-readable unit for schema metadata.
pub unit: &'static str,
/// Activation policy advertised to a control station.
pub apply_policy: ApplyPolicy,
}
/// Failure while changing one transaction field.
@@ -168,7 +265,7 @@ impl FieldWriteError {
/// Vehicle-specific editable candidate model.
///
/// Implementations expose every operator-editable scalar through the static
/// Implementations expose every user-editable scalar through the static
/// descriptor registry. A field write performs only local validation; complete
/// relational and capability validation happens through [`Self::validate`].
pub trait ConfigModel: Clone {
+524
View File
@@ -0,0 +1,524 @@
//! Power-loss-safe two-slot journal over an erase-before-write NOR device.
//!
//! The journal is board- and schema-agnostic. A platform supplies exactly two
//! independently erasable slots; the generic scalar snapshot codec supplies
//! 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};
const MAGIC: u32 = u32::from_le_bytes(*b"KJNL");
const RECORD_VERSION: u16 = 1;
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");
/// Exactly two independently erasable NOR slots used by [`DualSlotJournal`].
pub trait DualSlotNor {
/// Device-specific read, erase, or programming failure.
type Error;
/// Bytes available in each of the two slots.
fn slot_size(&self) -> usize;
/// Copies bytes from one slot into caller-owned RAM.
fn read(&mut self, slot: usize, offset: usize, output: &mut [u8]) -> Result<(), Self::Error>;
/// Erases one whole slot without affecting the other slot.
fn erase_slot(&mut self, slot: usize) -> Result<(), Self::Error>;
/// Programs one aligned erased NOR word. Implementations must reject any
/// attempted zero-to-one bit transition.
fn program_word(&mut self, slot: usize, offset: usize, word: u32) -> Result<(), Self::Error>;
}
/// Two-slot journal failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum JournalError<E> {
/// The platform slot cannot contain the bounded snapshot record.
InvalidGeometry,
/// The compiled model could not produce a canonical bounded snapshot.
InvalidModel,
/// The private durable sequence domain is exhausted.
SequenceExhausted,
/// The NOR device rejected a read, erase, or program operation.
Storage(E),
/// Read-back did not match the bytes just programmed.
Verification,
}
/// Generic persistent store backed by two independently erasable NOR slots.
pub struct DualSlotJournal<C, F, const MAX_PAYLOAD: usize> {
flash: F,
factory: C,
_payload: PhantomData<[u8; MAX_PAYLOAD]>,
}
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 {
Self {
flash,
factory,
_payload: PhantomData,
}
}
/// Releases the underlying platform flash owner.
pub fn into_flash(self) -> F {
self.flash
}
}
#[derive(Clone)]
struct Record<C> {
slot: usize,
sequence: u64,
candidate: C,
}
impl<C, F, const MAX_PAYLOAD: usize> DualSlotJournal<C, F, MAX_PAYLOAD>
where
C: ConfigModel,
F: DualSlotNor,
{
fn geometry(&self) -> Result<(), JournalError<F::Error>> {
let padded_payload = MAX_PAYLOAD
.checked_add(3)
.ok_or(JournalError::InvalidGeometry)?
& !3;
if MAX_PAYLOAD == 0 || self.flash.slot_size() < HEADER_LENGTH + padded_payload {
return Err(JournalError::InvalidGeometry);
}
Ok(())
}
fn record(&mut self, slot: usize) -> Result<Option<Record<C>>, JournalError<F::Error>> {
let mut header = [0_u8; HEADER_LENGTH];
self.flash
.read(slot, 0, &mut header)
.map_err(JournalError::Storage)?;
if header[..4] == [0xff; 4] {
return Ok(None);
}
if read_u32(&header[0..4]) != MAGIC
|| read_u16(&header[4..6]) != RECORD_VERSION
|| usize::from(read_u16(&header[6..8])) != HEADER_LENGTH
|| read_u32(&header[COMMIT_OFFSET..COMMIT_OFFSET + 4]) != COMMIT_MARKER
{
return Ok(None);
}
let sequence = read_u64(&header[8..16]);
if sequence == 0 {
return Ok(None);
}
let payload_length = read_u32(&header[16..20]) as usize;
if payload_length == 0
|| payload_length > MAX_PAYLOAD
|| HEADER_LENGTH + payload_length > self.flash.slot_size()
{
return Ok(None);
}
let mut payload = [0_u8; MAX_PAYLOAD];
self.flash
.read(slot, 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 {
return Ok(None);
};
Ok(Some(Record {
slot,
sequence,
candidate,
}))
}
fn newest(&mut self) -> Result<Option<Record<C>>, JournalError<F::Error>> {
let left = self.record(0)?;
let right = self.record(1)?;
Ok(match (left, right) {
(Some(left), Some(right)) if right.sequence > left.sequence => Some(right),
(Some(left), Some(_)) | (Some(left), None) => Some(left),
(None, Some(right)) => Some(right),
(None, None) => None,
})
}
fn program_bytes(
&mut self,
slot: usize,
offset: usize,
bytes: &[u8],
) -> Result<(), JournalError<F::Error>> {
if !offset.is_multiple_of(4) {
return Err(JournalError::InvalidGeometry);
}
for (index, chunk) in bytes.chunks(4).enumerate() {
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))
.map_err(JournalError::Storage)?;
}
Ok(())
}
}
impl<C, F, const MAX_PAYLOAD: usize> ConfigStore<C> for DualSlotJournal<C, F, MAX_PAYLOAD>
where
C: ConfigModel,
F: DualSlotNor,
{
type Error = JournalError<F::Error>;
fn load(&mut self) -> Result<Option<C>, Self::Error> {
self.geometry()?;
Ok(self.newest()?.map(|record| record.candidate))
}
fn load_matching<P>(&mut self, 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 = left.filter(|record| compatible(&record.candidate));
let right = right.filter(|record| compatible(&record.candidate));
let selected = match (left, right) {
(Some(left), Some(right)) if right.sequence > left.sequence => Some(right),
(Some(left), Some(_)) | (Some(left), None) => Some(left),
(None, Some(right)) => Some(right),
(None, None) => None,
};
Ok(selected.map(|record| record.candidate))
}
fn persist(&mut self, candidate: &C) -> Result<(), Self::Error> {
self.geometry()?;
let current = self.newest()?;
let sequence = current
.as_ref()
.map_or(1, |record| record.sequence.saturating_add(1));
if sequence == 0
|| current
.as_ref()
.is_some_and(|record| sequence == record.sequence)
{
return Err(JournalError::SequenceExhausted);
}
let target_slot = current.as_ref().map_or(0, |record| 1 - record.slot);
let mut payload = [0_u8; MAX_PAYLOAD];
let payload = encode_scalar_snapshot(candidate, &mut payload)
.map_err(|_| JournalError::InvalidModel)?;
let mut header = [0xff; HEADER_LENGTH];
header[0..4].copy_from_slice(&MAGIC.to_le_bytes());
header[4..6].copy_from_slice(&RECORD_VERSION.to_le_bytes());
header[6..8].copy_from_slice(&(HEADER_LENGTH as u16).to_le_bytes());
header[8..16].copy_from_slice(&sequence.to_le_bytes());
header[16..20].copy_from_slice(&(payload.len() as u32).to_le_bytes());
header[20..24].fill(0);
let crc = record_crc(&header, payload);
header[CRC_OFFSET..COMMIT_OFFSET].copy_from_slice(&crc.to_le_bytes());
self.flash
.erase_slot(target_slot)
.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)
.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 {
return Err(JournalError::Verification);
}
self.flash
.program_word(target_slot, COMMIT_OFFSET, COMMIT_MARKER)
.map_err(JournalError::Storage)?;
let Some(record) = self.record(target_slot)? else {
return Err(JournalError::Verification);
};
if record.sequence != sequence {
return Err(JournalError::Verification);
}
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
}
fn read_u16(bytes: &[u8]) -> u16 {
u16::from_le_bytes([bytes[0], bytes[1]])
}
fn read_u32(bytes: &[u8]) -> u32 {
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
}
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::*;
use crate::{
ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, ValueKind,
};
const VALUE: FieldId = FieldId::new(401);
const DESCRIPTOR: FieldDescriptor = FieldDescriptor {
id: VALUE,
display_key: "unseen.value",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(100)),
enum_options: &[],
unit: "ticks",
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Candidate(u32);
impl ConfigModel for Candidate {
type Active = Self;
type Context = ();
const SCHEMA_ID: [u8; 16] = *b"JOURNAL-UNKNOWN1";
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
(index == 0).then_some(&DESCRIPTOR)
}
fn field_count() -> u16 {
1
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
(id == VALUE).then_some(ConfigValue::U32(self.0))
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
active.field_value(id)
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
if id != VALUE {
return Err(FieldWriteError::UnknownField(id));
}
let ConfigValue::U32(value) = value else {
return Err(FieldWriteError::WrongType {
field: id,
expected: ValueKind::U32,
actual: value.kind(),
});
};
if !(1..=100).contains(&value) {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
crate::ValidationRule::AboveMaximum,
)));
}
self.0 = value;
Ok(())
}
fn validate(&self, _: &Self::Context) -> Result<Self::Active, ConfigViolation> {
Ok(*self)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FakeError {
PowerLoss,
Bounds,
ZeroToOne,
}
#[derive(Clone)]
struct FakeNor {
slots: [[u8; 256]; 2],
operations: usize,
fail_at: Option<usize>,
}
impl FakeNor {
fn erased() -> Self {
Self {
slots: [[0xff; 256]; 2],
operations: 0,
fail_at: None,
}
}
fn before_mutation(&mut self) -> Result<(), FakeError> {
if self.fail_at == Some(self.operations) {
return Err(FakeError::PowerLoss);
}
self.operations += 1;
Ok(())
}
}
impl DualSlotNor for FakeNor {
type Error = FakeError;
fn slot_size(&self) -> usize {
self.slots[0].len()
}
fn read(
&mut self,
slot: usize,
offset: usize,
output: &mut [u8],
) -> Result<(), Self::Error> {
let source = self
.slots
.get(slot)
.and_then(|slot| slot.get(offset..offset + output.len()))
.ok_or(FakeError::Bounds)?;
output.copy_from_slice(source);
Ok(())
}
fn erase_slot(&mut self, slot: usize) -> Result<(), Self::Error> {
self.before_mutation()?;
self.slots
.get_mut(slot)
.ok_or(FakeError::Bounds)?
.fill(0xff);
Ok(())
}
fn program_word(
&mut self,
slot: usize,
offset: usize,
word: u32,
) -> Result<(), Self::Error> {
self.before_mutation()?;
let destination = self
.slots
.get_mut(slot)
.and_then(|slot| slot.get_mut(offset..offset + 4))
.ok_or(FakeError::Bounds)?;
let bytes = word.to_le_bytes();
if destination
.iter()
.zip(bytes)
.any(|(old, new)| old & new != new)
{
return Err(FakeError::ZeroToOne);
}
destination.copy_from_slice(&bytes);
Ok(())
}
}
type Journal = DualSlotJournal<Candidate, FakeNor, 64>;
#[test]
fn alternates_slots_and_recovers_the_newest_record() {
let mut journal = Journal::new(FakeNor::erased(), Candidate(20));
assert_eq!(journal.load().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)));
}
#[test]
fn every_interrupted_erase_or_word_program_preserves_the_old_record() {
let mut initial = Journal::new(FakeNor::erased(), Candidate(20));
initial.persist(&Candidate(30)).unwrap();
let baseline = initial.into_flash();
let mut successful = Journal::new(baseline.clone(), Candidate(20));
let start = successful.flash.operations;
successful.persist(&Candidate(40)).unwrap();
let operation_count = successful.flash.operations - start;
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));
assert!(matches!(
interrupted.persist(&Candidate(40)),
Err(JournalError::Storage(FakeError::PowerLoss))
));
let flash = interrupted.into_flash();
let mut recovered = Journal::new(flash, Candidate(20));
assert_eq!(
recovered.load().unwrap(),
Some(Candidate(30)),
"mutation {interrupted_at} exposed a partial replacement"
);
}
}
#[test]
fn corrupt_newest_record_falls_back_to_the_previous_slot() {
let mut journal = Journal::new(FakeNor::erased(), Candidate(20));
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)));
}
#[test]
fn compatibility_filter_can_select_the_older_structurally_valid_slot() {
let mut journal = Journal::new(FakeNor::erased(), Candidate(20));
journal.persist(&Candidate(30)).unwrap();
journal.persist(&Candidate(40)).unwrap();
assert_eq!(
journal
.load_matching(|candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30))
);
assert_eq!(
journal.load_matching(|candidate| candidate.0 < 10).unwrap(),
None
);
}
}
+16 -5
View File
@@ -3,7 +3,9 @@
//! This crate has no knowledge of MAVLink, filesystems, board routing, motors,
//! or a particular airframe. It owns the staged-versus-active lifecycle and
//! parameter metadata; vehicle crates provide the typed schema and complete
//! validation. CBOR storage and MAVLink transport are separate adapters.
//! validation. Persistence framing and transports remain separate adapters;
//! this crate provides only the bounded scalar snapshot payload shared by
//! storage backends.
#![no_std]
@@ -11,15 +13,24 @@ extern crate self as kaptan_config;
pub mod error;
pub mod field;
pub mod journal;
pub mod lifecycle;
pub mod snapshot;
pub mod storage;
pub mod validation;
pub use error::{ConfigViolation, ValidationRule};
pub use field::{
ApplyPolicy, ConfigModel, ConfigValue, FieldDescriptor, FieldId, FieldWriteError, ValueKind,
ConfigModel, ConfigScalar, ConfigValue, EnumOption, FieldDescriptor, FieldId, FieldWriteError,
MAX_ENUM_OPTION_LABEL_BYTES, MAX_ENUM_OPTIONS_PER_FIELD, ScalarValueError, ValueKind,
};
pub use kaptan_config_derive::ConfigValidate;
pub use journal::{DualSlotJournal, DualSlotNor, JournalError};
pub use kaptan_config_derive::{Config, ConfigEnum};
pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError};
pub use storage::{Generation, ProfileStore};
pub use validation::{ConfigAggregate, ConfigSection, ValidateFields};
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,
};
pub use storage::ConfigStore;
pub use validation::Config;
+83 -67
View File
@@ -1,8 +1,8 @@
//! Transactional committed-versus-active configuration lifecycle.
use crate::{
ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
Generation, ProfileStore,
ConfigModel, ConfigStore, ConfigValue, ConfigViolation, FieldDescriptor, FieldId,
FieldWriteError,
};
use kaptan_log::KaptanError;
@@ -13,7 +13,7 @@ pub enum ConfigState {
Active,
/// One isolated RAM transaction is open.
Editing,
/// A newer committed snapshot will take effect after reboot.
/// A newer committed snapshot will take effect after restart.
RestartRequired,
}
@@ -53,7 +53,7 @@ pub enum CommitError<E> {
Store(E),
}
/// Owns the immutable boot-active profile, the latest durable candidate, and
/// Owns the immutable boot-active configuration, the latest durable candidate, and
/// exactly one RAM edit transaction.
///
/// P1 deliberately has no activation operation: [`Self::active`] never
@@ -61,11 +61,10 @@ pub enum CommitError<E> {
/// candidate, then reports [`ConfigState::RestartRequired`].
pub struct ConfigManager<C: ConfigModel, S> {
active: C::Active,
factory: C,
committed: C,
transaction: Option<C>,
context: C::Context,
active_generation: Option<Generation>,
committed_generation: Option<Generation>,
store: S,
restart_required: bool,
}
@@ -73,7 +72,7 @@ pub struct ConfigManager<C: ConfigModel, S> {
impl<C, S> ConfigManager<C, S>
where
C: ConfigModel,
S: ProfileStore<C>,
S: ConfigStore<C>,
{
/// Starts from a validated factory candidate when no persistent record is
/// available. Flight callers separately decide whether setup/calibration
@@ -82,11 +81,10 @@ where
let active = factory.validate(&context)?;
Ok(Self {
active,
factory: factory.clone(),
committed: factory,
transaction: None,
context,
active_generation: None,
committed_generation: None,
store,
restart_required: false,
})
@@ -99,7 +97,7 @@ where
context: C::Context,
mut store: S,
) -> Result<Self, StartupError<S::Error>> {
let Some((generation, committed)) = store.load().map_err(StartupError::Store)? else {
let Some(committed) = store.load().map_err(StartupError::Store)? else {
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
};
let active = committed
@@ -107,11 +105,41 @@ where
.map_err(StartupError::InvalidPersisted)?;
Ok(Self {
active,
factory,
committed,
transaction: None,
context,
store,
restart_required: false,
})
}
/// Recovers the newest candidate compatible with the current context,
/// falling back to the supplied factory when no compatible record exists.
///
/// This is an explicit recovery policy for output-inhibited setup images.
/// Safety-critical compositions should use [`Self::recover`] if an
/// incompatible durable record must prevent initialization.
pub fn recover_compatible_or_factory(
factory: C,
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
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
Ok(Self {
active,
factory,
committed,
transaction: None,
context,
active_generation: Some(generation),
committed_generation: Some(generation),
store,
restart_required: false,
})
@@ -127,6 +155,11 @@ where
self.committed.field_value(id)
}
/// 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 a value from the immutable boot-active snapshot.
pub fn active_value(&self, id: FieldId) -> Option<ConfigValue> {
C::active_field_value(&self.active, id)
@@ -158,23 +191,6 @@ where
}
}
/// Returns the boot-active snapshot generation.
pub const fn active_generation(&self) -> Option<Generation> {
self.active_generation
}
/// Returns the most recently recovered or committed generation.
pub const fn committed_generation(&self) -> Option<Generation> {
self.committed_generation
}
/// Returns the most recently recovered or committed generation.
///
/// Retained as a concise alias for optimistic commit callers.
pub const fn generation(&self) -> Option<Generation> {
self.committed_generation
}
/// Opens the only transaction slot from the last durably committed state.
pub fn begin(&mut self) -> Result<(), BeginError> {
if self.transaction.is_some() {
@@ -209,25 +225,8 @@ where
.map_err(CommitError::InvalidCandidate)
}
/// Replaces the open RAM transaction with one complete imported candidate.
///
/// Unlike scalar edits, imports are accepted only after complete
/// relational and capability validation. Neither the boot-active nor the
/// committed snapshot is changed here.
pub fn replace_transaction(&mut self, candidate: C) -> Result<(), CommitError<S::Error>> {
candidate
.validate(&self.context)
.map_err(CommitError::InvalidCandidate)?;
let transaction = self
.transaction
.as_mut()
.ok_or(CommitError::NoTransaction)?;
*transaction = candidate;
Ok(())
}
/// Validates and durably commits the complete open transaction.
pub fn commit(&mut self) -> Result<Generation, CommitError<S::Error>> {
pub fn commit(&mut self) -> Result<(), CommitError<S::Error>> {
let candidate = self
.transaction
.as_ref()
@@ -235,29 +234,23 @@ where
candidate
.validate(&self.context)
.map_err(CommitError::InvalidCandidate)?;
let generation = self.store.persist(candidate).map_err(CommitError::Store)?;
self.store.persist(candidate).map_err(CommitError::Store)?;
self.committed = candidate.clone();
self.transaction = None;
self.committed_generation = Some(generation);
self.restart_required = true;
Ok(generation)
Ok(())
}
/// Drops the RAM transaction without changing active or committed state.
pub fn abort(&mut self) -> bool {
self.transaction.take().is_some()
}
/// Releases the owned store when the configuration owner shuts down.
pub fn into_store(self) -> S {
self.store
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ApplyPolicy, ConfigValue, ValidationRule, ValueKind};
use crate::{ConfigValue, ValidationRule, ValueKind};
const VALUE: FieldId = FieldId::new(1);
const OTHER: FieldId = FieldId::new(2);
@@ -268,9 +261,8 @@ mod tests {
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
default: ConfigValue::U32(2),
enum_options: &[],
unit: "count",
apply_policy: ApplyPolicy::RestartRequired,
},
FieldDescriptor {
id: OTHER,
@@ -278,9 +270,8 @@ mod tests {
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
default: ConfigValue::U32(4),
enum_options: &[],
unit: "count",
apply_policy: ApplyPolicy::RestartRequired,
},
];
@@ -364,15 +355,29 @@ mod tests {
struct MemoryStore;
impl ProfileStore<Candidate> for MemoryStore {
impl ConfigStore<Candidate> for MemoryStore {
type Error = ();
fn load(&mut self) -> Result<Option<(Generation, Candidate)>, Self::Error> {
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
Ok(None)
}
fn persist(&mut self, _: &Candidate) -> Result<Generation, Self::Error> {
Ok(Generation::new(7))
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
Ok(())
}
}
struct IncompatibleStore;
impl ConfigStore<Candidate> for IncompatibleStore {
type Error = ();
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
Ok(Some(Candidate { value: 9, other: 1 }))
}
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
Ok(())
}
}
@@ -405,11 +410,9 @@ mod tests {
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(3)).unwrap();
assert_eq!(manager.commit(), Ok(Generation::new(7)));
assert_eq!(manager.commit(), Ok(()));
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.active_generation(), None);
assert_eq!(manager.committed_generation(), Some(Generation::new(7)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(4)).unwrap();
@@ -417,4 +420,17 @@ mod tests {
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
}
#[test]
fn explicit_recovery_policy_uses_factory_when_no_record_matches_context() {
let factory = Candidate { value: 2, other: 4 };
assert!(matches!(
ConfigManager::recover(factory.clone(), (), IncompatibleStore),
Err(StartupError::InvalidPersisted(_))
));
let manager =
ConfigManager::recover_compatible_or_factory(factory, (), IncompatibleStore).unwrap();
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
}
}
+781
View File
@@ -0,0 +1,781 @@
//! Allocation-free scalar snapshot payload codec.
//!
//! A snapshot is a canonical storage payload, not a flash journal record. A
//! 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:
//!
//! ```text
//! magic[4] | version:u8 | schema_id[16] | field_count:u16
//! repeated field_count times:
//! field_id:u16 | value_kind:u8 | value_bits:u64
//! ```
//!
//! 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.
use kaptan_log::KaptanError;
use crate::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValueKind};
const MAGIC: [u8; 4] = *b"KCFG";
const MAGIC_OFFSET: usize = 0;
const VERSION_OFFSET: usize = 4;
const SCHEMA_OFFSET: usize = 5;
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;
/// Bytes preceding the first scalar entry.
pub const SCALAR_SNAPSHOT_HEADER_LENGTH: usize = ENTRIES_OFFSET;
/// Fixed bytes occupied by one scalar entry.
pub const SCALAR_SNAPSHOT_ENTRY_LENGTH: usize = 11;
/// Returns the exact encoded length for a declared number of scalar fields.
///
/// 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
}
/// A broken [`ConfigModel`] registry detected before encoding or decoding.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum SnapshotModelError {
/// `field_count` includes an index with no descriptor.
MissingDescriptor(u16),
/// A descriptor exists at the first index after `field_count`.
ExtraDescriptor(u16),
/// Two descriptor indexes expose the same stable field identity.
DuplicateDescriptor(FieldId),
/// A descriptor has no readable value in the supplied candidate.
MissingValue(FieldId),
/// A candidate returned a scalar type different from its descriptor.
WrongValueKind {
/// Descriptor identity.
field: FieldId,
/// Type declared by the descriptor.
expected: ValueKind,
/// Type returned by the candidate.
actual: ValueKind,
},
}
/// Failure while encoding a candidate into caller-owned storage.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum ScalarSnapshotEncodeError {
/// The destination cannot hold the complete canonical payload.
BufferTooSmall {
/// Exact number of bytes required.
required: usize,
/// Number of bytes supplied by the caller.
available: usize,
},
/// The model's descriptor/value contract is internally inconsistent.
InvalidModel(SnapshotModelError),
}
/// Failure while decoding an untrusted scalar snapshot.
#[derive(Clone, Copy, Debug, PartialEq, KaptanError)]
pub enum ScalarSnapshotDecodeError {
/// The input ends before the complete header or declared field set.
Truncated {
/// Exact number of bytes required once it is known.
required: usize,
/// Number of bytes supplied by the caller.
available: usize,
},
/// Bytes remain after the exact declared field set.
TrailingData {
/// Exact number of bytes required.
expected: usize,
/// Number of bytes supplied by the caller.
actual: usize,
},
/// The payload does not carry the scalar-snapshot magic.
InvalidMagic,
/// The payload uses an unsupported scalar-snapshot format.
UnsupportedVersion(u8),
/// The payload belongs to another configuration schema.
WrongSchema {
/// Schema compiled into the target model.
expected: [u8; 16],
/// Schema carried by the payload.
actual: [u8; 16],
},
/// The payload is not a complete snapshot for this schema.
WrongFieldCount {
/// Exact number of fields compiled into the target model.
expected: u16,
/// Number declared by the payload.
actual: u16,
},
/// The target model's descriptor registry is internally inconsistent.
InvalidModel(SnapshotModelError),
/// An entry used a reserved stable field identity.
InvalidFieldId(u16),
/// An entry names no field in the target schema.
UnknownField(FieldId),
/// The complete payload identifies one field more than once.
DuplicateField(FieldId),
/// The complete payload does not identify a required schema field.
MissingField(FieldId),
/// An entry contains no supported scalar-kind discriminator.
InvalidValueKind(u8),
/// An entry's scalar kind differs from the target descriptor.
WrongValueKind {
/// Entry identity.
field: FieldId,
/// Type declared by the target descriptor.
expected: ValueKind,
/// Type carried by the payload.
actual: ValueKind,
},
/// Scalar bits are not canonical for the entry's declared kind.
InvalidValueBits {
/// Entry identity.
field: FieldId,
/// Scalar kind whose representation was invalid.
kind: ValueKind,
},
/// The model rejected an otherwise structurally valid scalar write.
InvalidWrite(FieldWriteError),
}
/// Encodes a complete [`ConfigModel`] candidate into a caller-owned buffer.
///
/// The returned slice covers only the initialized payload; a larger caller
/// buffer is permitted. No partial payload is returned on failure.
pub fn encode_scalar_snapshot<'a, C: ConfigModel>(
candidate: &C,
output: &'a mut [u8],
) -> Result<&'a [u8], ScalarSnapshotEncodeError> {
validate_model::<C>().map_err(ScalarSnapshotEncodeError::InvalidModel)?;
let field_count = C::field_count();
let required = scalar_snapshot_length(field_count);
if output.len() < required {
return Err(ScalarSnapshotEncodeError::BufferTooSmall {
required,
available: output.len(),
});
}
output[MAGIC_OFFSET..VERSION_OFFSET].copy_from_slice(&MAGIC);
output[VERSION_OFFSET] = SCALAR_SNAPSHOT_VERSION;
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());
for index in 0..field_count {
let descriptor = C::descriptor(index).ok_or(ScalarSnapshotEncodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(index),
))?;
let value =
candidate
.field_value(descriptor.id)
.ok_or(ScalarSnapshotEncodeError::InvalidModel(
SnapshotModelError::MissingValue(descriptor.id),
))?;
if value.kind() != descriptor.kind {
return Err(ScalarSnapshotEncodeError::InvalidModel(
SnapshotModelError::WrongValueKind {
field: descriptor.id,
expected: descriptor.kind,
actual: value.kind(),
},
));
}
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());
}
Ok(&output[..required])
}
/// Decodes a complete scalar snapshot into a clone of `factory`.
///
/// Structural validation completes before any field setter runs. Each write
/// then passes through [`ConfigModel::set_field`], preserving the model's local
/// validation. Whole-candidate capability validation remains the composition
/// owner's responsibility because its context is deliberately not part of a
/// storage codec.
pub fn decode_scalar_snapshot<C: ConfigModel>(
input: &[u8],
factory: &C,
) -> Result<C, ScalarSnapshotDecodeError> {
validate_model::<C>().map_err(ScalarSnapshotDecodeError::InvalidModel)?;
if input.len() < SCALAR_SNAPSHOT_HEADER_LENGTH {
return Err(ScalarSnapshotDecodeError::Truncated {
required: SCALAR_SNAPSHOT_HEADER_LENGTH,
available: input.len(),
});
}
if input[MAGIC_OFFSET..VERSION_OFFSET] != MAGIC {
return Err(ScalarSnapshotDecodeError::InvalidMagic);
}
if input[VERSION_OFFSET] != SCALAR_SNAPSHOT_VERSION {
return Err(ScalarSnapshotDecodeError::UnsupportedVersion(
input[VERSION_OFFSET],
));
}
let mut actual_schema = [0_u8; 16];
actual_schema.copy_from_slice(&input[SCHEMA_OFFSET..FIELD_COUNT_OFFSET]);
if actual_schema != C::SCHEMA_ID {
return Err(ScalarSnapshotDecodeError::WrongSchema {
expected: C::SCHEMA_ID,
actual: actual_schema,
});
}
let actual_count = read_u16(&input[FIELD_COUNT_OFFSET..ENTRIES_OFFSET]);
let expected_count = C::field_count();
if actual_count != expected_count {
return Err(ScalarSnapshotDecodeError::WrongFieldCount {
expected: expected_count,
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(),
});
}
if input.len() > expected_length {
return Err(ScalarSnapshotDecodeError::TrailingData {
expected: expected_length,
actual: input.len(),
});
}
validate_entries::<C>(input, actual_count)?;
let mut candidate = factory.clone();
for index in 0..actual_count {
let entry = entry_offset(index);
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)?;
candidate
.set_field(id, value)
.map_err(ScalarSnapshotDecodeError::InvalidWrite)?;
}
Ok(candidate)
}
fn validate_model<C: ConfigModel>() -> Result<(), SnapshotModelError> {
let count = C::field_count();
for index in 0..count {
let descriptor =
C::descriptor(index).ok_or(SnapshotModelError::MissingDescriptor(index))?;
for previous in 0..index {
let previous =
C::descriptor(previous).ok_or(SnapshotModelError::MissingDescriptor(previous))?;
if previous.id == descriptor.id {
return Err(SnapshotModelError::DuplicateDescriptor(descriptor.id));
}
}
}
if C::descriptor(count).is_some() {
return Err(SnapshotModelError::ExtraDescriptor(count));
}
Ok(())
}
fn validate_entries<C: ConfigModel>(
input: &[u8],
count: u16,
) -> Result<(), ScalarSnapshotDecodeError> {
for index in 0..count {
let entry = entry_offset(index);
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 raw_kind = input[entry + 2];
let kind =
decode_kind(raw_kind).ok_or(ScalarSnapshotDecodeError::InvalidValueKind(raw_kind))?;
if kind != descriptor.kind {
return Err(ScalarSnapshotDecodeError::WrongValueKind {
field: id,
expected: descriptor.kind,
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));
}
}
}
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(())
}
fn descriptor_by_id<C: ConfigModel>(id: FieldId) -> Option<&'static crate::FieldDescriptor> {
for index in 0..C::field_count() {
let descriptor = C::descriptor(index)?;
if descriptor.id == id {
return Some(descriptor);
}
}
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)
}
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::*;
use crate::{ConfigViolation, FieldDescriptor, ValidationRule};
const BOOL: FieldId = FieldId::new(1);
const I32: FieldId = FieldId::new(2);
const U32: FieldId = FieldId::new(3);
const U64: FieldId = FieldId::new(4);
const F32: FieldId = FieldId::new(5);
const F64: FieldId = FieldId::new(6);
const SCHEMA: [u8; 16] = *b"SCALAR-SNAPSHOT1";
const DESCRIPTORS: [FieldDescriptor; 6] = [
descriptor(BOOL, ValueKind::Bool),
descriptor(I32, ValueKind::I32),
descriptor(U32, ValueKind::U32),
descriptor(U64, ValueKind::U64),
descriptor(F32, ValueKind::F32),
descriptor(F64, ValueKind::F64),
];
const fn descriptor(id: FieldId, kind: ValueKind) -> FieldDescriptor {
FieldDescriptor {
id,
display_key: "test",
kind,
minimum: None,
maximum: None,
enum_options: &[],
unit: "",
}
}
#[derive(Clone, Debug, PartialEq)]
struct Candidate {
boolean: bool,
signed: i32,
unsigned: u32,
wide: u64,
float: f32,
double: f64,
}
impl Default for Candidate {
fn default() -> Self {
Self {
boolean: false,
signed: -1,
unsigned: 1,
wide: 1,
float: 1.0,
double: 1.0,
}
}
}
impl ConfigModel for Candidate {
type Active = Self;
type Context = ();
const SCHEMA_ID: [u8; 16] = SCHEMA;
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
DESCRIPTORS.get(usize::from(index))
}
fn field_count() -> u16 {
DESCRIPTORS.len() as u16
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
match id {
BOOL => Some(ConfigValue::Bool(self.boolean)),
I32 => Some(ConfigValue::I32(self.signed)),
U32 => Some(ConfigValue::U32(self.unsigned)),
U64 => Some(ConfigValue::U64(self.wide)),
F32 => Some(ConfigValue::F32(self.float)),
F64 => Some(ConfigValue::F64(self.double)),
_ => None,
}
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
active.field_value(id)
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
match (id, value) {
(BOOL, ConfigValue::Bool(value)) => self.boolean = value,
(I32, ConfigValue::I32(value)) => self.signed = value,
(U32, ConfigValue::U32(value)) if value <= 100 => self.unsigned = value,
(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,
(U32, ConfigValue::U32(_)) => {
return Err(FieldWriteError::Violation(ConfigViolation::field(
U32,
ValidationRule::AboveMaximum,
)));
}
(F32, ConfigValue::F32(_)) => return Err(FieldWriteError::non_finite(F32)),
(F64, ConfigValue::F64(_)) => return Err(FieldWriteError::non_finite(F64)),
(_, value) if self.field_value(id).is_some() => {
return Err(FieldWriteError::WrongType {
field: id,
expected: self.field_value(id).expect("known field").kind(),
actual: value.kind(),
});
}
_ => return Err(FieldWriteError::UnknownField(id)),
}
Ok(())
}
fn validate(&self, _: &Self::Context) -> Result<Self::Active, ConfigViolation> {
Ok(self.clone())
}
}
fn encoded(candidate: &Candidate) -> ([u8; scalar_snapshot_length(6)], usize) {
let mut bytes = [0xa5; scalar_snapshot_length(6)];
let length = encode_scalar_snapshot(candidate, &mut bytes)
.expect("candidate encodes")
.len();
(bytes, length)
}
fn entry(index: u16) -> usize {
entry_offset(index)
}
#[test]
fn round_trips_every_scalar_kind_and_uses_exact_length() {
let candidate = Candidate {
boolean: true,
signed: i32::MIN,
unsigned: 99,
wide: u64::MAX,
float: -0.0,
double: f64::MIN_POSITIVE,
};
let mut storage = [0xa5; scalar_snapshot_length(6) + 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[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)
);
}
#[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];
assert_eq!(
encode_scalar_snapshot(&Candidate::default(), &mut output),
Err(ScalarSnapshotEncodeError::BufferTooSmall {
required,
available: required - 1,
})
);
assert!(output.iter().all(|byte| *byte == 0xa5));
}
#[test]
fn decoder_rejects_every_truncation_and_trailing_data() {
let (bytes, length) = encoded(&Candidate::default());
for end in 0..length {
assert!(decode_scalar_snapshot(&bytes[..end], &Candidate::default()).is_err());
}
let mut trailing = [0_u8; scalar_snapshot_length(6) + 1];
trailing[..length].copy_from_slice(&bytes);
assert_eq!(
decode_scalar_snapshot(&trailing, &Candidate::default()),
Err(ScalarSnapshotDecodeError::TrailingData {
expected: length,
actual: length + 1,
})
);
}
#[test]
fn decoder_rejects_wrong_magic_version_schema_and_count() {
let (source, _) = encoded(&Candidate::default());
let mut bytes = source;
bytes[0] ^= 1;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::InvalidMagic)
);
let mut bytes = source;
bytes[VERSION_OFFSET] += 1;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::UnsupportedVersion(2))
);
let mut bytes = source;
bytes[SCHEMA_OFFSET] ^= 1;
let mut actual = SCHEMA;
actual[0] ^= 1;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::WrongSchema {
expected: SCHEMA,
actual,
})
);
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()),
Err(ScalarSnapshotDecodeError::WrongFieldCount {
expected: 6,
actual: 5,
})
);
}
#[test]
fn decoder_rejects_invalid_unknown_duplicate_and_missing_ids() {
let (source, _) = encoded(&Candidate::default());
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()),
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)))
);
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))
);
// 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 mut bytes = source;
bytes[entry(0) + 2] = 0xff;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::InvalidValueKind(0xff))
);
let mut bytes = source;
bytes[entry(0) + 2] = ValueKind::U32 as u8;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::WrongValueKind {
field: BOOL,
expected: ValueKind::Bool,
actual: ValueKind::U32,
})
);
let mut bytes = source;
bytes[entry(0) + 3..entry(0) + 11].copy_from_slice(&2_u64.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
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,
})
);
}
#[test]
fn decoder_accepts_reordered_entries_because_ids_are_authoritative() {
let candidate = Candidate {
boolean: true,
signed: -72,
unsigned: 50,
wide: 987_654_321,
float: 0.25,
double: -19.5,
};
let (mut bytes, _) = encoded(&candidate);
for offset in 0..SCALAR_SNAPSHOT_ENTRY_LENGTH {
bytes.swap(entry(0) + offset, entry(5) + offset);
}
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Ok(candidate)
);
}
#[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());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::InvalidWrite(
FieldWriteError::Violation(ConfigViolation::field(
U32,
ValidationRule::AboveMaximum,
))
))
);
}
struct MissingDescriptor;
impl Clone for MissingDescriptor {
fn clone(&self) -> Self {
Self
}
}
impl ConfigModel for MissingDescriptor {
type Active = ();
type Context = ();
const SCHEMA_ID: [u8; 16] = SCHEMA;
fn descriptor(_: u16) -> Option<&'static FieldDescriptor> {
None
}
fn field_count() -> u16 {
1
}
fn field_value(&self, _: FieldId) -> Option<ConfigValue> {
None
}
fn active_field_value(_: &Self::Active, _: FieldId) -> Option<ConfigValue> {
None
}
fn set_field(&mut self, id: FieldId, _: ConfigValue) -> Result<(), FieldWriteError> {
Err(FieldWriteError::UnknownField(id))
}
fn validate(&self, _: &Self::Context) -> Result<Self::Active, ConfigViolation> {
Ok(())
}
}
#[test]
fn encoder_and_decoder_reject_a_broken_model_registry() {
let mut output = [0_u8; scalar_snapshot_length(1)];
assert_eq!(
encode_scalar_snapshot(&MissingDescriptor, &mut output),
Err(ScalarSnapshotEncodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(0)
))
);
let (bytes, _) = encoded(&Candidate::default());
assert!(matches!(
decode_scalar_snapshot(&bytes, &MissingDescriptor),
Err(ScalarSnapshotDecodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(0)
))
));
}
}
+16 -33
View File
@@ -1,48 +1,31 @@
//! Persistence interface for complete validated candidate snapshots.
use core::num::NonZeroU64;
/// Storage-owned monotonically increasing committed snapshot generation.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Generation(NonZeroU64);
impl Generation {
/// Creates a generation supplied only by a persistence backend.
pub const fn new(value: u64) -> Self {
match NonZeroU64::new(value) {
Some(value) => Self(value),
None => panic!("configuration generation must be nonzero"),
}
}
/// Parses an on-disk or wire generation, rejecting the factory sentinel.
pub const fn try_new(value: u64) -> Option<Self> {
match NonZeroU64::new(value) {
Some(value) => Some(Self(value)),
None => None,
}
}
/// Returns the committed generation value.
pub const fn get(self) -> u64 {
self.0.get()
}
}
/// Durable storage of complete configuration snapshots.
///
/// Flight implementations use a two-slot journal: a record contains a header,
/// bounded CBOR payload, integrity check, and a commit marker written last.
/// bounded payload, integrity check, and a commit marker written last.
/// A backend must retain its previous valid record until the replacement is
/// fully verified. Host file stores may implement the same interface without
/// claiming flash power-loss semantics.
pub trait ProfileStore<C> {
pub trait ConfigStore<C> {
/// Backend-specific I/O or integrity failure.
type Error;
/// Loads the newest valid committed candidate, if one exists.
fn load(&mut self) -> Result<Option<(Generation, C)>, Self::Error>;
fn load(&mut self) -> 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>
where
F: FnMut(&C) -> bool,
{
Ok(self.load()?.filter(|candidate| compatible(candidate)))
}
/// Persists one complete already-validated candidate atomically.
fn persist(&mut self, candidate: &C) -> Result<Generation, Self::Error>;
fn persist(&mut self, candidate: &C) -> Result<(), Self::Error>;
}
+184 -48
View File
@@ -1,63 +1,199 @@
//! Local scalar configuration sections implemented by the derive macro.
//! Generated configuration trees.
use crate::{ConfigValue, ConfigViolation, FieldId, FieldWriteError};
use crate::{ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError};
/// Validates local scalar invariants of one record.
/// One recursively composable configuration tree.
///
/// The derive macro handles finite, nonzero, and inclusive range checks.
/// Cross-field, capability, calibration, and lifecycle invariants belong in a
/// handwritten post-validation hook on the owning vehicle schema.
pub trait ValidateFields {
/// Rejects the first violated scalar or declared post-validation rule.
fn validate_fields(&self) -> Result<(), ConfigViolation>;
}
/// A complete scalar configuration section generated from `#[config(...)]`
/// field attributes.
///
/// Its metadata, default construction, reads, local writes, and scalar
/// validation are all produced from the same field declarations. A section
/// setter never commits a locally invalid value. Cross-section and capability
/// invariants deliberately remain the aggregate candidate's responsibility.
pub trait ConfigSection: ValidateFields {
/// Number of scalar descriptors generated for this section.
/// `#[derive(kaptan_config::Config)]` implements this contract for both scalar
/// records and records composed from other configuration trees. Validation,
/// metadata, field routing, and schema identity therefore come from one source.
pub trait Config {
/// Number of scalar fields in the flattened tree.
const FIELD_COUNT: usize;
/// Builds the section from derive-generated defaults after validating it.
fn default_section() -> Result<Self, ConfigViolation>
where
Self: Sized;
/// Fingerprint generated from the complete declared schema.
const SCHEMA_ID: [u8; 16];
/// Returns a descriptor from the flattened declaration order.
fn descriptor(index: usize) -> Option<&'static FieldDescriptor>;
/// Returns one scalar by stable field identity.
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
/// Applies one scalar only when this section owns the ID and its local
/// generated validation accepts the new value.
/// Applies one scalar only when its owning record accepts the local value.
/// Whole-tree relational validation is intentionally deferred until the
/// candidate is validated or committed.
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
/// Validates only generated scalar invariants. This intentionally omits
/// a derive `post` hook so staged multi-field edits can remain temporarily
/// cross-field-invalid until aggregate validation/commit.
fn validate_local_fields(&self) -> Result<(), ConfigViolation>;
/// Validates every generated rule and declared post-validation hook.
fn validate(&self) -> Result<(), ConfigViolation>;
}
/// An ordered aggregate of generated configuration sections.
/// Detects duplicate flattened field identities in a generated tree.
#[doc(hidden)]
pub fn __unique_field_ids<C: Config>() -> bool {
let mut index = 0;
while index < C::FIELD_COUNT {
let Some(descriptor) = C::descriptor(index) else {
return false;
};
let mut other = index + 1;
while other < C::FIELD_COUNT {
let Some(other_descriptor) = C::descriptor(other) else {
return false;
};
if descriptor.id == other_descriptor.id {
return false;
}
other += 1;
}
index += 1;
}
C::descriptor(C::FIELD_COUNT).is_none()
}
/// Checks display-key uniqueness across one recursively composed schema.
#[doc(hidden)]
pub fn __unique_display_keys<C: Config>() -> bool {
let mut index = 0;
while index < C::FIELD_COUNT {
let Some(descriptor) = C::descriptor(index) else {
return false;
};
let mut other = index + 1;
while other < C::FIELD_COUNT {
let Some(other_descriptor) = C::descriptor(other) else {
return false;
};
if descriptor.display_key == other_descriptor.display_key {
return false;
}
other += 1;
}
index += 1;
}
true
}
/// Validates the bounded, self-describing enum portion of generated metadata.
#[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 {
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
{
return false;
}
if descriptor.enum_options[..index]
.iter()
.any(|previous| previous.0 == option.0)
{
return false;
}
}
}
true
}
const FINGERPRINT_LEFT_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FINGERPRINT_RIGHT_OFFSET: u64 = 0x6c62_272e_07bb_0142;
const FINGERPRINT_LEFT_PRIME: u64 = 0x0000_0100_0000_01b3;
const FINGERPRINT_RIGHT_PRIME: u64 = 0x9e37_79b1_85eb_ca87;
const fn hash_byte(mut state: [u64; 2], byte: u8) -> [u64; 2] {
state[0] = (state[0] ^ byte as u64).wrapping_mul(FINGERPRINT_LEFT_PRIME);
state[1] = (state[1] ^ byte as u64).wrapping_mul(FINGERPRINT_RIGHT_PRIME);
state
}
const fn hash_u64(mut state: [u64; 2], value: u64) -> [u64; 2] {
let bytes = value.to_le_bytes();
let mut index = 0;
while index < bytes.len() {
state = hash_byte(state, bytes[index]);
index += 1;
}
state
}
const fn hash_str(mut state: [u64; 2], value: &str) -> [u64; 2] {
state = hash_u64(state, value.len() as u64);
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
state = hash_byte(state, bytes[index]);
index += 1;
}
state
}
const fn hash_value(mut state: [u64; 2], value: Option<ConfigValue>) -> [u64; 2] {
let Some(value) = value else {
return hash_byte(state, 0);
};
state = hash_byte(state, value.kind() as u8);
hash_u64(state, value.raw_bits())
}
const fn finish_fingerprint(state: [u64; 2]) -> [u8; 16] {
let left = state[0].to_le_bytes();
let right = state[1].to_le_bytes();
[
left[0], left[1], left[2], left[3], left[4], left[5], left[6], left[7], right[0], right[1],
right[2], right[3], right[4], right[5], right[6], right[7],
]
}
/// Produces the stable schema fingerprint for one scalar record.
#[doc(hidden)]
pub const fn __schema_id(descriptors: &[FieldDescriptor]) -> [u8; 16] {
let mut state = [FINGERPRINT_LEFT_OFFSET, FINGERPRINT_RIGHT_OFFSET];
state = hash_u64(state, descriptors.len() as u64);
let mut index = 0;
while index < descriptors.len() {
let descriptor = descriptors[index];
state = hash_u64(state, descriptor.id.get() as u64);
state = hash_str(state, descriptor.display_key);
state = hash_byte(state, descriptor.kind as u8);
state = hash_value(state, descriptor.minimum);
state = hash_value(state, descriptor.maximum);
state = hash_u64(state, descriptor.enum_options.len() as u64);
let mut option = 0;
while option < descriptor.enum_options.len() {
state = hash_u64(state, descriptor.enum_options[option].0 as u64);
state = hash_str(state, descriptor.enum_options[option].1);
option += 1;
}
state = hash_str(state, descriptor.unit);
index += 1;
}
finish_fingerprint(state)
}
/// Produces a parent fingerprint from ordered child fingerprints.
///
/// The derive owns routing/default construction so adding a field to an
/// existing section cannot silently bypass local validation. The aggregate
/// owner still defines capability checks and immutable active construction.
pub trait ConfigAggregate: ValidateFields {
/// Builds and fully validates all nested sections from their derived
/// defaults.
fn default_aggregate() -> Result<Self, ConfigViolation>
where
Self: Sized;
/// Returns one nested scalar by stable field identity.
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
/// Routes one scalar write to its owning generated section. The setter
/// applies local validation only, so a transaction may temporarily hold a
/// cross-field-invalid multi-edit candidate.
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
/// A one-child wrapper is deliberately transparent, so adding or removing a
/// redundant structural wrapper does not invalidate persisted values.
#[doc(hidden)]
pub const fn __combine_schema_ids(children: &[[u8; 16]]) -> [u8; 16] {
if children.len() == 1 {
return children[0];
}
let mut state = [FINGERPRINT_LEFT_OFFSET, FINGERPRINT_RIGHT_OFFSET];
state = hash_u64(state, children.len() as u64);
let mut child = 0;
while child < children.len() {
let mut byte = 0;
while byte < children[child].len() {
state = hash_byte(state, children[child][byte]);
byte += 1;
}
child += 1;
}
finish_fingerprint(state)
}