refactor(firmware): centralize configuration and diagnostics
This commit is contained in:
1 parent
7048ed89d0
commit
387fdaad23
140 files changed
+6891
-4541
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user