feat(firmware): validate configuration and migrate sensor-driven flight APIs

Validate configuration through its owning schemas and keep board
declarations separate from shared F722 resource ownership. Bound USB
transitions, isolate transport failures and retain restart-only activation.

Migrate navigation and flight consumers with the sensor and schema
contracts. Require explicit installation data and synchronous output
authority; board motor outputs remain inhibited.

Validation: assembled-prefix host tests, strict Clippy, platform/USB tests
and both ARM release builds. Final-source coverage and scoped Miri/Kani
evidence are retained separately from hardware qualification.
This commit is contained in:
Onur Özkan committed 2026-11-07 18:12:11 +03:00
1 parent fb1b8af2d1
commit f9a61a086f
211 files changed
+45657 -12740

No files matched your search

+270 -96
View File
@@ -2,7 +2,9 @@
//!
//! The artifact is independent of any transport and of the private flash
//! journal. It contains the generated field metadata, one complete committed
//! value set, and the board-selected factory values required by a generic UI.
//! value set, the board-selected factory values, and exact expected-base values
//! for guarded full replacement. Each field carries current, factory, then
//! expected-base scalars at native width before bounds and text metadata.
use core::str;
@@ -24,10 +26,16 @@ const CHECKSUM_LENGTH: usize = 4;
const FIELD_PREFIX_LENGTH: usize = 7;
const FIELD_HAS_MINIMUM: u8 = 1 << 0;
const FIELD_HAS_MAXIMUM: u8 = 1 << 1;
const FIELD_FLAGS: u8 = FIELD_HAS_MINIMUM | FIELD_HAS_MAXIMUM;
const FIELD_FINITE: u8 = 1 << 2;
const FIELD_NONZERO: u8 = 1 << 3;
const FIELD_FLAGS: u8 = FIELD_HAS_MINIMUM | FIELD_HAS_MAXIMUM | FIELD_FINITE | FIELD_NONZERO;
/// Caller-owned bitmap bytes required by dynamic KCFG decoding.
/// Hosted callers can initialize this directly on the heap with a byte vector.
pub const KCFG_FIELD_ID_SCRATCH_BYTES: usize = 8192;
/// Current canonical KCFG artifact format.
const KCFG_VERSION: u8 = 1;
const KCFG_VERSION: u8 = 3;
/// KCFG header bytes preceding the first field record.
const KCFG_HEADER_LENGTH: usize = FIELDS_OFFSET;
/// KCFG trailer bytes containing a big-endian CRC-32C.
@@ -208,7 +216,7 @@ pub enum KcfgEncodeError {
}
/// Failure while decoding untrusted KCFG bytes.
#[derive(Debug)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum KcfgDecodeError {
/// Input ends before a required fixed or variable-length component.
Truncated,
@@ -309,12 +317,15 @@ pub struct KcfgField<'a> {
kind: ValueKind,
current: ConfigValue,
factory: ConfigValue,
expected_base: ConfigValue,
minimum: Option<ConfigValue>,
maximum: Option<ConfigValue>,
key: &'a str,
unit: &'a str,
options: KcfgEnumOptions<'a>,
current_offset: usize,
finite: bool,
nonzero: bool,
}
impl<'a> KcfgField<'a> {
@@ -338,6 +349,12 @@ impl<'a> KcfgField<'a> {
self.factory
}
/// Exact committed scalar observed when this draft was first exported.
/// Editing requested values preserves this optimistic concurrency guard.
pub const fn expected_base_value(self) -> ConfigValue {
self.expected_base
}
/// Inclusive generated lower bound.
pub const fn minimum(self) -> Option<ConfigValue> {
self.minimum
@@ -362,6 +379,16 @@ impl<'a> KcfgField<'a> {
pub const fn enum_options(self) -> KcfgEnumOptions<'a> {
self.options
}
/// Whether NaN and infinities are forbidden.
pub const fn finite(self) -> bool {
self.finite
}
/// Whether numeric zero is forbidden.
pub const fn nonzero(self) -> bool {
self.nonzero
}
}
/// Iterator over validated field records in canonical artifact order.
@@ -428,7 +455,15 @@ impl<'a> KcfgArtifact<'a> {
}
/// Validates and borrows one board-agnostic KCFG artifact.
pub fn decode_kcfg(input: &[u8]) -> Result<KcfgArtifact<'_>, KcfgDecodeError> {
///
/// Clears the caller's 8-KiB bitmap and visits each field once. Work is
/// O(input bytes + 8192), including checksum, text and enum validation, even
/// on a late duplicate. Per-field enum scratch is 32 bytes. The returned view
/// borrows only the input, so the scratch can be reused immediately.
pub fn decode_kcfg<'a>(
input: &'a [u8],
field_ids: &mut [u8; KCFG_FIELD_ID_SCRATCH_BYTES],
) -> Result<KcfgArtifact<'a>, KcfgDecodeError> {
if input.len() < KCFG_HEADER_LENGTH + KCFG_CHECKSUM_LENGTH {
return Err(KcfgDecodeError::Truncated);
}
@@ -449,9 +484,13 @@ pub fn decode_kcfg(input: &[u8]) -> Result<KcfgArtifact<'_>, KcfgDecodeError> {
}
let field_count = read_u16(&input[FIELD_COUNT_OFFSET..FIELDS_OFFSET]);
field_ids.fill(0);
let mut cursor = FIELDS_OFFSET;
for _ in 0..field_count {
let (_, next) = parse_field(input, cursor, checksum_offset)?;
let (field, next) = parse_field(input, cursor, checksum_offset)?;
if mark_seen(field_ids, usize::from(field.id.get())) {
return Err(KcfgDecodeError::DuplicateField(field.id));
}
cursor = next;
}
if cursor != checksum_offset {
@@ -466,7 +505,6 @@ pub fn decode_kcfg(input: &[u8]) -> Result<KcfgArtifact<'_>, KcfgDecodeError> {
field_count,
restart_required: artifact_flags & KCFG_RESTART_REQUIRED != 0,
};
reject_duplicate_fields(&artifact)?;
Ok(artifact)
}
@@ -482,7 +520,7 @@ pub fn kcfg_length<C: ConfigModel>() -> Result<usize, KcfgEncodeError> {
))?;
validate_descriptor(descriptor)?;
let width = descriptor.kind.width();
let values = 2
let values = 3
+ usize::from(descriptor.minimum.is_some())
+ usize::from(descriptor.maximum.is_some());
let mut field_length = FIELD_PREFIX_LENGTH
@@ -507,11 +545,7 @@ pub fn kcfg_length<C: ConfigModel>() -> Result<usize, KcfgEncodeError> {
Ok(length)
}
/// Prepared immutable metadata for one canonical generated artifact.
///
/// A file endpoint can retain this small value for the lifetime of an open
/// read and reconstruct range exporters without recomputing the file length or
/// either checksum for every transport chunk.
/// Cached length and checksums for an immutable export.
#[derive(Clone, Copy)]
pub struct KcfgExportPlan {
length: usize,
@@ -520,12 +554,8 @@ pub struct KcfgExportPlan {
restart_required: bool,
}
/// Allocation-free view of one canonical generated artifact.
///
/// The exporter can fill an arbitrary transport range directly from the typed
/// configuration. Firmware therefore does not need a buffer sized for the
/// complete public file. Construction also computes both the embedded KCFG
/// checksum and the checksum of the complete file bytes.
/// Exports arbitrary byte ranges directly from typed configuration.
/// Construction computes both the KCFG checksum and the complete-file checksum.
pub struct KcfgExporter<'a, C: ConfigModel> {
current: &'a C,
factory: &'a C,
@@ -611,19 +641,31 @@ impl<'a, C: ConfigModel> KcfgExporter<'a, C> {
}
}
/// Complete requested values and the exact committed base observed by an editor.
#[derive(Clone, Debug, PartialEq)]
pub struct KcfgUpdate<C> {
/// Requested replacement, subject to complete validation before persistence.
pub candidate: C,
/// Expected committed values, compared before no-op detection or persistence.
pub expected_base: C,
}
/// Bounded sequential decoder for a typed KCFG replacement.
///
/// Only the candidate plus eight scalar bytes are retained. Generated
/// metadata and factory bytes are checked as they arrive, and a field setter
/// Only two typed candidates plus fixed scalar/parser state are retained.
/// Generated metadata and factory bytes are checked as they arrive, and a field setter
/// is called only after its complete canonical record has been received.
/// Discarding the importer discards every partially imported candidate.
pub struct KcfgImporter<C: ConfigModel> {
candidate: C,
expected_base: C,
error: Option<KcfgDecodeError>,
phase: ImportPhase,
header_offset: usize,
field_index: u16,
field_offset: usize,
current_bytes: [u8; 8],
expected_base_bytes: [u8; 8],
checksum_bytes: [u8; CHECKSUM_LENGTH],
checksum_offset: usize,
crc: Crc32c,
@@ -646,31 +688,48 @@ impl<C: ConfigModel> KcfgImporter<C> {
emit_body(factory, factory, false, |_| true)?;
Ok(Self {
candidate: factory.clone(),
expected_base: factory.clone(),
error: None,
phase: ImportPhase::Header,
header_offset: 0,
field_index: 0,
field_offset: 0,
current_bytes: [0; 8],
expected_base_bytes: [0; 8],
checksum_bytes: [0; CHECKSUM_LENGTH],
checksum_offset: 0,
crc: Crc32c::new(),
})
}
/// Consumes the next contiguous file bytes.
/// Consumes the next contiguous file bytes. Any parser error is terminal:
/// subsequent pushes and finish return the original error.
pub fn push(&mut self, factory: &C, input: &[u8]) -> Result<(), KcfgDecodeError> {
if let Some(error) = self.error {
return Err(error);
}
for byte in input {
self.push_byte(factory, *byte)?;
if let Err(error) = self.push_byte(factory, *byte) {
self.error = Some(error);
return Err(error);
}
}
Ok(())
}
/// Returns the complete candidate after checksum and schema validation.
pub fn finish(self) -> Result<C, KcfgDecodeError> {
/// Returns requested and expected-base candidates after checksum and schema
/// validation. Relational validation belongs to the manager at commit time.
pub fn finish(self) -> Result<KcfgUpdate<C>, KcfgDecodeError> {
if let Some(error) = self.error {
return Err(error);
}
if self.phase != ImportPhase::Done {
return Err(KcfgDecodeError::Truncated);
}
Ok(self.candidate)
Ok(KcfgUpdate {
candidate: self.candidate,
expected_base: self.expected_base,
})
}
fn push_byte(&mut self, factory: &C, byte: u8) -> Result<(), KcfgDecodeError> {
@@ -743,6 +802,7 @@ impl<C: ConfigModel> KcfgImporter<C> {
}
match expected_field_byte(descriptor, factory, self.field_offset) {
Some(ExpectedFieldByte::Current(index)) => self.current_bytes[index] = byte,
Some(ExpectedFieldByte::ExpectedBase(index)) => self.expected_base_bytes[index] = byte,
Some(ExpectedFieldByte::Factory(expected)) if byte != expected => {
return Err(KcfgDecodeError::FactoryMismatch(descriptor.id));
}
@@ -762,7 +822,16 @@ impl<C: ConfigModel> KcfgImporter<C> {
self.candidate
.set_field(descriptor.id, value)
.map_err(KcfgDecodeError::InvalidWrite)?;
let base = value_from_bytes(
descriptor.id,
descriptor.kind,
&self.expected_base_bytes[..width],
)?;
self.expected_base
.set_field(descriptor.id, base)
.map_err(KcfgDecodeError::InvalidWrite)?;
self.current_bytes = [0; 8];
self.expected_base_bytes = [0; 8];
self.field_offset = 0;
self.field_index += 1;
if self.field_index == C::field_count() {
@@ -809,8 +878,8 @@ fn encode_kcfg<'a, C: ConfigModel>(
///
/// A board-agnostic host can retain the received artifact bytes, present the
/// borrowed metadata to its UI, and provide edited values by stable field ID.
/// The generated metadata, schema, factory values, and enum choices are copied
/// unchanged and the checksum is regenerated here.
/// The expected-base scalars, metadata, schema, factory values, and enum choices
/// are copied unchanged and the checksum is regenerated here.
pub fn encode_edited_kcfg<'a, F>(
source: &KcfgArtifact<'_>,
restart_required: bool,
@@ -848,31 +917,9 @@ where
#[cfg(test)]
fn decode_kcfg_for<C: ConfigModel>(input: &[u8], factory: &C) -> Result<C, KcfgDecodeError> {
validate_model::<C>().map_err(KcfgDecodeError::InvalidModel)?;
let artifact = decode_kcfg(input)?;
if artifact.schema_id != C::SCHEMA_ID {
return Err(KcfgDecodeError::WrongSchema);
}
if artifact.field_count != C::field_count() {
return Err(KcfgDecodeError::WrongFieldCount);
}
let mut candidate = factory.clone();
for (index, field) in artifact.fields().enumerate() {
let descriptor = C::descriptor(index as u16).ok_or(KcfgDecodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(index as u16),
))?;
if !same_descriptor(&field, descriptor) {
return Err(KcfgDecodeError::DescriptorMismatch(field.id));
}
if factory.field_value(field.id) != Some(field.factory) {
return Err(KcfgDecodeError::FactoryMismatch(field.id));
}
candidate
.set_field(field.id, field.current)
.map_err(KcfgDecodeError::InvalidWrite)?;
}
Ok(candidate)
let mut importer = KcfgImporter::new(factory).expect("valid test model");
importer.push(factory, input)?;
Ok(importer.finish()?.candidate)
}
fn emit_body<C, F>(
@@ -940,6 +987,10 @@ where
if !emit_value(factory, &mut emit) {
return Ok(());
}
// A fresh export establishes the exact base for subsequent host edits.
if !emit_value(current, &mut emit) {
return Ok(());
}
if let Some(minimum) = descriptor.minimum
&& !emit_value(minimum, &mut emit)
{
@@ -973,16 +1024,17 @@ fn validate_descriptor(descriptor: &crate::FieldDescriptor) -> Result<(), KcfgEn
|| descriptor.unit.contains('\0')
|| descriptor.enum_options.len() > u8::MAX as usize
|| (!descriptor.enum_options.is_empty() && descriptor.kind != ValueKind::U8)
|| (descriptor.finite && !matches!(descriptor.kind, ValueKind::F32 | ValueKind::F64))
|| (descriptor.nonzero && descriptor.kind == ValueKind::Bool)
{
return Err(KcfgEncodeError::InvalidMetadata(descriptor.id));
}
for (index, (value, label)) in descriptor.enum_options.iter().enumerate() {
let mut seen = [0_u8; 32];
for (value, label) in descriptor.enum_options {
if label.is_empty()
|| label.len() > u8::MAX as usize
|| label.contains('\0')
|| descriptor.enum_options[..index]
.iter()
.any(|previous| previous.0 == *value)
|| mark_seen(&mut seen, usize::from(*value))
{
return Err(KcfgEncodeError::InvalidMetadata(descriptor.id));
}
@@ -995,6 +1047,8 @@ fn parse_field(
start: usize,
limit: usize,
) -> Result<(KcfgField<'_>, usize), KcfgDecodeError> {
#[cfg(test)]
tests::FIELD_PARSES.with(|count| count.set(count.get() + 1));
let prefix_end = start
.checked_add(FIELD_PREFIX_LENGTH)
.ok_or(KcfgDecodeError::Truncated)?;
@@ -1007,7 +1061,10 @@ fn parse_field(
let kind =
ValueKind::try_from_code(raw_kind).ok_or(KcfgDecodeError::InvalidValueKind(raw_kind))?;
let flags = bytes[start + 3];
if flags & !FIELD_FLAGS != 0 {
if flags & !FIELD_FLAGS != 0
|| (flags & FIELD_FINITE != 0 && !matches!(kind, ValueKind::F32 | ValueKind::F64))
|| (flags & FIELD_NONZERO != 0 && kind == ValueKind::Bool)
{
return Err(KcfgDecodeError::InvalidFieldFlags(id));
}
let key_length = usize::from(bytes[start + 4]);
@@ -1021,6 +1078,7 @@ fn parse_field(
let current_offset = cursor;
let current = parse_value(bytes, &mut cursor, limit, id, kind)?;
let factory = parse_value(bytes, &mut cursor, limit, id, kind)?;
let expected_base = parse_value(bytes, &mut cursor, limit, id, kind)?;
let minimum = if flags & FIELD_HAS_MINIMUM != 0 {
Some(parse_value(bytes, &mut cursor, limit, id, kind)?)
} else {
@@ -1047,7 +1105,8 @@ fn parse_field(
canonical_text(&bytes[key_end..unit_end], true).ok_or(KcfgDecodeError::InvalidText(id))?;
cursor = unit_end;
let options_start = cursor;
for option_index in 0..enum_count {
let mut seen = [0_u8; 32];
for _ in 0..enum_count {
if cursor.checked_add(2).is_none_or(|end| end > limit) {
return Err(KcfgDecodeError::Truncated);
}
@@ -1062,11 +1121,7 @@ fn parse_field(
if canonical_text(&bytes[cursor + 2..label_end], false).is_none() {
return Err(KcfgDecodeError::InvalidText(id));
}
let mut previous = KcfgEnumOptions {
bytes: &bytes[options_start..cursor],
remaining: option_index,
};
if previous.any(|option| option.value == value) {
if mark_seen(&mut seen, usize::from(value)) {
return Err(KcfgDecodeError::DuplicateEnumValue(id));
}
cursor = label_end;
@@ -1078,6 +1133,7 @@ fn parse_field(
kind,
current,
factory,
expected_base,
minimum,
maximum,
key,
@@ -1087,22 +1143,21 @@ fn parse_field(
remaining: enum_count,
},
current_offset,
finite: flags & FIELD_FINITE != 0,
nonzero: flags & FIELD_NONZERO != 0,
},
cursor,
))
}
fn reject_duplicate_fields(artifact: &KcfgArtifact<'_>) -> Result<(), KcfgDecodeError> {
for (index, field) in artifact.fields().enumerate() {
if artifact
.fields()
.take(index)
.any(|previous| previous.id == field.id)
{
return Err(KcfgDecodeError::DuplicateField(field.id));
}
}
Ok(())
fn mark_seen(bitmap: &mut [u8], value: usize) -> bool {
#[cfg(test)]
tests::BITMAP_PROBES.with(|count| count.set(count.get() + 1));
let byte = &mut bitmap[value / 8];
let bit = 1 << (value % 8);
let duplicate = *byte & bit != 0;
*byte |= bit;
duplicate
}
fn parse_value(
@@ -1132,21 +1187,6 @@ fn value_from_bytes(
ConfigValue::from_le_bytes(kind, bytes).ok_or(KcfgDecodeError::InvalidValue(id))
}
#[cfg(test)]
fn same_descriptor(field: &KcfgField<'_>, descriptor: &crate::FieldDescriptor) -> bool {
field.id == descriptor.id
&& field.kind == descriptor.kind
&& field.minimum == descriptor.minimum
&& field.maximum == descriptor.maximum
&& field.key == descriptor.display_key
&& field.unit == descriptor.unit
&& field
.options
.zip(descriptor.enum_options.iter().copied())
.all(|(actual, expected)| (actual.value, actual.label) == expected)
&& field.options.len() == descriptor.enum_options.len()
}
fn canonical_text(bytes: &[u8], allow_empty: bool) -> Option<&str> {
let text = str::from_utf8(bytes).ok()?;
if (!allow_empty && text.is_empty()) || text.contains('\0') {
@@ -1166,6 +1206,7 @@ fn read_u32_be(bytes: &[u8]) -> u32 {
enum ExpectedFieldByte {
Current(usize),
ExpectedBase(usize),
Factory(u8),
Descriptor(u8),
}
@@ -1202,6 +1243,10 @@ fn expected_field_byte(
)));
}
cursor += width;
if offset < cursor + width {
return Some(ExpectedFieldByte::ExpectedBase(offset - cursor));
}
cursor += width;
if let Some(minimum) = descriptor.minimum {
if offset < cursor + width {
return Some(ExpectedFieldByte::Descriptor(value_byte(
@@ -1254,7 +1299,7 @@ fn expected_field_byte(
fn field_record_length(descriptor: &crate::FieldDescriptor) -> usize {
let width = descriptor.kind.width();
let value_count =
2 + usize::from(descriptor.minimum.is_some()) + usize::from(descriptor.maximum.is_some());
3 + usize::from(descriptor.minimum.is_some()) + usize::from(descriptor.maximum.is_some());
FIELD_PREFIX_LENGTH
+ width * value_count
+ descriptor.display_key.len()
@@ -1269,6 +1314,8 @@ fn field_record_length(descriptor: &crate::FieldDescriptor) -> usize {
fn field_flags(descriptor: &crate::FieldDescriptor) -> u8 {
(u8::from(descriptor.minimum.is_some()) * FIELD_HAS_MINIMUM)
| (u8::from(descriptor.maximum.is_some()) * FIELD_HAS_MAXIMUM)
| (u8::from(descriptor.finite) * FIELD_FINITE)
| (u8::from(descriptor.nonzero) * FIELD_NONZERO)
}
fn value_byte(value: ConfigValue, offset: usize) -> u8 {
@@ -1321,8 +1368,78 @@ impl<'a> RangeSink<'a> {
#[cfg(test)]
mod tests {
extern crate std;
use super::*;
use crate::{ConfigViolation, FieldDescriptor, ValidationRule};
use std::{vec, vec::Vec};
std::thread_local! {
pub(super) static FIELD_PARSES: core::cell::Cell<usize> = const { core::cell::Cell::new(0) };
pub(super) static BITMAP_PROBES: core::cell::Cell<usize> = const { core::cell::Cell::new(0) };
}
fn raw_artifact(count: u16, last_id: u16, options: &[u8]) -> Vec<u8> {
let mut bytes = vec![0; KCFG_HEADER_LENGTH];
bytes[..4].copy_from_slice(&MAGIC);
bytes[VERSION_OFFSET] = KCFG_VERSION;
bytes[FIELD_COUNT_OFFSET..FIELDS_OFFSET].copy_from_slice(&count.to_le_bytes());
for index in 0..count {
let last = index == count - 1;
let id = if last { last_id } else { index + 1 };
bytes.extend_from_slice(&id.to_le_bytes());
bytes.extend_from_slice(&[
ValueKind::U8 as u8,
0,
1,
0,
if last { options.len() as u8 } else { 0 },
]);
bytes.extend_from_slice(&[1, 1, 1, b'k']);
if last {
for value in options {
bytes.extend_from_slice(&[*value, 1, b'x']);
}
}
}
bytes.extend_from_slice(&crc32c(&bytes).to_be_bytes());
bytes
}
#[test]
fn maximum_counts_and_late_duplicates_have_one_parse_and_bitmap_probe_per_item() {
let mut scratch = vec![0xff; KCFG_FIELD_ID_SCRATCH_BYTES];
let scratch: &mut [u8; KCFG_FIELD_ID_SCRATCH_BYTES] =
scratch.as_mut_slice().try_into().unwrap();
let mut options: Vec<u8> = (0..u8::MAX).collect();
let valid = raw_artifact(u16::MAX - 1, u16::MAX - 1, &options);
FIELD_PARSES.set(0);
BITMAP_PROBES.set(0);
assert!(decode_kcfg(&valid, scratch).is_ok());
assert_eq!(FIELD_PARSES.get(), 65_534);
assert_eq!(BITMAP_PROBES.get(), 65_534 + 255);
let duplicate = raw_artifact(u16::MAX, 1, &options);
FIELD_PARSES.set(0);
BITMAP_PROBES.set(0);
assert!(
matches!(decode_kcfg(&duplicate, scratch), Err(KcfgDecodeError::DuplicateField(id)) if id == FieldId::new(1))
);
assert_eq!(FIELD_PARSES.get(), 65_535);
assert_eq!(BITMAP_PROBES.get(), 65_535 + 255);
options[254] = 0;
let duplicate = raw_artifact(u16::MAX - 1, u16::MAX - 1, &options);
FIELD_PARSES.set(0);
BITMAP_PROBES.set(0);
assert!(
matches!(decode_kcfg(&duplicate, scratch), Err(KcfgDecodeError::DuplicateEnumValue(id)) if id == FieldId::new(65_534))
);
assert_eq!(FIELD_PARSES.get(), 65_534);
assert_eq!(BITMAP_PROBES.get(), 65_533 + 255);
// A failed parse cannot poison subsequent calls using the same scratch.
assert!(decode_kcfg(&valid, scratch).is_ok());
}
const RATE: FieldId = FieldId::new(1);
const PORT: FieldId = FieldId::new(2);
@@ -1337,6 +1454,8 @@ mod tests {
maximum: Some(ConfigValue::U8(100)),
enum_options: &[],
unit: "Hz",
finite: false,
nonzero: false,
},
FieldDescriptor {
id: PORT,
@@ -1346,6 +1465,8 @@ mod tests {
maximum: Some(ConfigValue::U16(u16::MAX)),
enum_options: &[],
unit: "id",
finite: false,
nonzero: false,
},
FieldDescriptor {
id: MODE,
@@ -1355,6 +1476,8 @@ mod tests {
maximum: None,
enum_options: MODE_OPTIONS,
unit: "level",
finite: false,
nonzero: false,
},
];
@@ -1472,7 +1595,8 @@ mod tests {
};
let mut bytes = [0; 256];
let encoded = encode_kcfg(&current, &factory, true, &mut bytes).unwrap();
let artifact = decode_kcfg(encoded).unwrap();
let artifact = decode_kcfg(encoded, &mut [0; KCFG_FIELD_ID_SCRATCH_BYTES]).unwrap();
assert_eq!(encoded[VERSION_OFFSET], 3);
assert_eq!(artifact.schema_id(), Candidate::SCHEMA_ID);
assert!(artifact.restart_required());
let mut fields = artifact.fields();
@@ -1489,6 +1613,20 @@ mod tests {
}
})
.unwrap();
let edited_artifact = decode_kcfg(edited, &mut [0; KCFG_FIELD_ID_SCRATCH_BYTES]).unwrap();
for field in edited_artifact.fields() {
assert_eq!(
field.expected_base_value(),
current.field_value(field.id()).unwrap()
);
}
let mut importer = KcfgImporter::new(&factory).unwrap();
for byte in edited {
importer
.push(&factory, core::slice::from_ref(byte))
.unwrap();
}
assert_eq!(importer.finish().unwrap().expected_base, current);
assert_eq!(
decode_kcfg_for(edited, &factory).unwrap(),
Candidate {
@@ -1553,7 +1691,9 @@ mod tests {
for chunk in contiguous[..exporter.length()].chunks(chunk_length) {
importer.push(&factory, chunk).unwrap();
}
assert_eq!(importer.finish().unwrap(), current);
let update = importer.finish().unwrap();
assert_eq!(update.candidate, current);
assert_eq!(update.expected_base, current);
}
for truncated_length in 0..exporter.length() {
@@ -1597,11 +1737,11 @@ mod tests {
.unwrap()
.len();
storage[FIELDS_OFFSET + FIELD_PREFIX_LENGTH + 4] ^= 1;
storage[FIELDS_OFFSET + FIELD_PREFIX_LENGTH + 5] ^= 1;
let checksum_offset = length - CHECKSUM_LENGTH;
let checksum = crc32c(&storage[..checksum_offset]);
storage[checksum_offset..length].copy_from_slice(&checksum.to_be_bytes());
assert!(decode_kcfg(&storage[..length]).is_ok());
assert!(decode_kcfg(&storage[..length], &mut [0; KCFG_FIELD_ID_SCRATCH_BYTES]).is_ok());
assert!(matches!(
decode_kcfg_for(&storage[..length], &factory),
Err(KcfgDecodeError::DescriptorMismatch(RATE))
@@ -1609,8 +1749,42 @@ mod tests {
storage[checksum_offset] ^= 1;
assert!(matches!(
decode_kcfg(&storage[..length]),
decode_kcfg(&storage[..length], &mut [0; KCFG_FIELD_ID_SCRATCH_BYTES]),
Err(KcfgDecodeError::ChecksumMismatch)
));
}
#[test]
fn parser_errors_are_terminal_including_checksum_and_trailing_data() {
let factory = factory();
let mut bytes = [0; 256];
let length = encode_kcfg(&factory, &factory, false, &mut bytes)
.unwrap()
.len();
// Header/version, metadata, scalar validation, and checksum errors must
// never allow a later push or finish to revive a partial update.
for (offset, bad_byte) in [
(0, b'X'),
(VERSION_OFFSET, 1),
(FIELDS_OFFSET, 42),
(FIELDS_OFFSET + FIELD_PREFIX_LENGTH, 0),
(FIELDS_OFFSET + FIELD_PREFIX_LENGTH + 2, 0),
(length - 1, bytes[length - 1] ^ 1),
] {
let mut malformed = bytes;
malformed[offset] = bad_byte;
let mut importer = KcfgImporter::new(&factory).unwrap();
let error = importer.push(&factory, &malformed[..length]).unwrap_err();
assert_eq!(importer.push(&factory, &bytes[..length]), Err(error));
assert_eq!(importer.push(&factory, &[]), Err(error));
assert_eq!(importer.finish(), Err(error));
}
let mut importer = KcfgImporter::new(&factory).unwrap();
importer.push(&factory, &bytes[..length]).unwrap();
assert_eq!(
importer.push(&factory, &[0]),
Err(KcfgDecodeError::TrailingData)
);
assert_eq!(importer.finish(), Err(KcfgDecodeError::TrailingData));
}
}
+3
View File
@@ -22,6 +22,8 @@ pub enum ValidationRule {
UnsupportedFormat = 7,
/// A scalar does not name a declared variant of its typed enum field.
InvalidEnumValue = 8,
/// An ordered range cannot contain NaN.
Unordered = 9,
}
impl ValidationRule {
@@ -41,6 +43,7 @@ impl ValidationRule {
6 => Self::UnsupportedCapability,
7 => Self::UnsupportedFormat,
8 => Self::InvalidEnumValue,
9 => Self::Unordered,
_ => return None,
};
Some(rule)
+19 -13
View File
@@ -3,11 +3,7 @@
use crate::{ConfigViolation, ValidationRule};
use kaptan_err_reportable::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.
/// Maximum choice count representable by indexed `u8` discovery.
pub(crate) const MAX_ENUM_OPTIONS_PER_FIELD: usize = u8::MAX as usize;
/// Maximum UTF-8 bytes advertised for one enum variant name.
pub(crate) const MAX_ENUM_OPTION_LABEL_BYTES: usize = 32;
@@ -47,9 +43,9 @@ impl FieldId {
/// The scalar representation of a configuration value.
///
/// The variants deliberately preserve native widths: durations, for example,
/// remain `u64` microseconds instead of being lossy display-unit conversions.
#[derive(Clone, Copy, Debug, PartialEq)]
/// Preserves native widths. Equality compares kind and raw bits, including
/// signed zero and NaN payloads.
#[derive(Clone, Copy, Debug)]
pub enum ConfigValue {
/// Boolean value.
Bool(bool),
@@ -69,6 +65,16 @@ pub enum ConfigValue {
F64(f64),
}
/// Configuration identity is native kind plus exact scalar bits. Signed zeros
/// differ and identical NaN payloads compare equal, including in optional values.
impl PartialEq for ConfigValue {
fn eq(&self, other: &Self) -> bool {
self.kind() == other.kind() && self.raw_bits() == other.raw_bits()
}
}
impl Eq for ConfigValue {}
/// Failure to project a generic configuration scalar into its native type.
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -79,11 +85,7 @@ pub enum ScalarValueError {
OutOfDomain,
}
/// Native representation used by generated configuration fields.
///
/// Generic artifacts and private snapshots retain the native scalar kind while
/// board and runtime structures keep the narrow or enum type that models the
/// value.
/// Conversion between native field types and configuration scalars.
#[doc(hidden)]
pub trait ConfigScalar: Copy {
/// Generic scalar kind used at schema and transport boundaries.
@@ -246,6 +248,10 @@ pub struct FieldDescriptor {
pub enum_options: &'static [(u8, &'static str)],
/// Human-readable unit for schema metadata.
pub unit: &'static str,
/// Reject NaN and infinities. Only meaningful for floating-point fields.
pub finite: bool,
/// Reject numeric zero, including either floating-point signed zero.
pub nonzero: bool,
}
/// Failure while changing one candidate field.
+127 -82
View File
@@ -4,9 +4,12 @@
//! 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.
//! A persistence error can occur after that marker is durable, so it does not
//! establish which candidate recovery will select.
use crate::{
ConfigModel, ConfigStore, checksum::Crc32c, decode_scalar_snapshot, encode_scalar_snapshot,
ConfigModel, ConfigStore, StoreRecovery, checksum::Crc32c, decode_scalar_snapshot,
encode_scalar_snapshot,
};
const MAGIC: u32 = u32::from_le_bytes(*b"KJNL");
@@ -213,23 +216,25 @@ where
Ok(selected.map(|record| record.candidate))
}
fn load_matching<P>(&mut self, factory: &C, mut compatible: P) -> Result<Option<C>, Self::Error>
where
P: FnMut(&C) -> bool,
{
self.geometry()?;
let left = self.record(Slot::Left, factory)?;
let right = self.record(Slot::Right, factory)?;
let left = left.filter(|record| compatible(&record.candidate));
let right = right.filter(|record| compatible(&record.candidate));
let selected = match (left, right) {
(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,
};
self.protected_slot = selected.as_ref().map(|record| record.slot);
Ok(selected.map(|record| record.candidate))
fn load_recovery(&mut self, factory: &C) -> Result<StoreRecovery<C>, Self::Error> {
if let Some(candidate) = self.load(factory)? {
return Ok(StoreRecovery::Committed(candidate));
}
// Only the no-readable-record case needs an erased-byte scan. Do not
// infer factory state from erased headers with occupied payload bytes.
let mut bytes = [0; VERIFY_CHUNK_LENGTH];
for slot in 0..2 {
for offset in (0..self.flash.slot_size()).step_by(bytes.len()) {
let length = bytes.len().min(self.flash.slot_size() - offset);
self.flash
.read(slot, offset, &mut bytes[..length])
.map_err(JournalError::Storage)?;
if bytes[..length].iter().any(|&byte| byte != 0xff) {
return Ok(StoreRecovery::Unreadable);
}
}
}
Ok(StoreRecovery::Empty)
}
fn persist(&mut self, candidate: &C) -> Result<(), Self::Error> {
@@ -294,6 +299,8 @@ where
self.flash
.program_word(target_slot.index(), COMMIT_OFFSET, COMMIT_MARKER)
.map_err(JournalError::Storage)?;
// The candidate is now durable even if this final read fails. Callers
// must recover from storage rather than assume an error rolled it back.
let Some(record) = self.record(target_slot, candidate)? else {
return Err(JournalError::Verification);
};
@@ -330,7 +337,8 @@ fn read_u64(bytes: &[u8]) -> u64 {
mod tests {
use super::*;
use crate::{
ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, ValueKind,
ConfigManager, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
ReplaceError, ValueKind,
};
const VALUE: FieldId = FieldId::new(401);
@@ -342,6 +350,8 @@ mod tests {
maximum: Some(ConfigValue::U32(100)),
enum_options: &[],
unit: "ticks",
finite: false,
nonzero: false,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -398,15 +408,26 @@ mod tests {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FakeError {
PowerLoss,
Read,
Program,
Bounds,
ZeroToOne,
}
#[derive(Clone, Copy, Debug)]
enum CommitFailure {
Program,
Read(usize),
CorruptRead(usize),
}
#[derive(Clone)]
struct FakeNor {
slots: [[u8; 256]; 2],
operations: usize,
fail_at: Option<usize>,
commit_failure: Option<CommitFailure>,
commit_written: bool,
}
impl FakeNor {
@@ -415,6 +436,8 @@ mod tests {
slots: [[0xff; 256]; 2],
operations: 0,
fail_at: None,
commit_failure: None,
commit_written: false,
}
}
@@ -446,6 +469,13 @@ mod tests {
.and_then(|slot| slot.get(offset..offset + output.len()))
.ok_or(FakeError::Bounds)?;
output.copy_from_slice(source);
if self.commit_written {
match self.commit_failure {
Some(CommitFailure::Read(at)) if at == offset => return Err(FakeError::Read),
Some(CommitFailure::CorruptRead(at)) if at == offset => output[0] ^= 1,
_ => {}
}
}
Ok(())
}
@@ -479,6 +509,12 @@ mod tests {
return Err(FakeError::ZeroToOne);
}
destination.copy_from_slice(&bytes);
if offset == COMMIT_OFFSET && word == COMMIT_MARKER {
self.commit_written = self.commit_failure.is_some();
if matches!(self.commit_failure, Some(CommitFailure::Program)) {
return Err(FakeError::Program);
}
}
Ok(())
}
}
@@ -486,6 +522,78 @@ mod tests {
type Journal = DualSlotJournal<FakeNor, 64>;
const FACTORY: Candidate = Candidate(20);
// Borrowing lets a simulated reboot recover the same flash after dropping
// the manager, without adding a production store-extraction API.
impl ConfigStore<Candidate> for &mut Journal {
type Error = JournalError<FakeError>;
fn load(&mut self, factory: &Candidate) -> Result<Option<Candidate>, Self::Error> {
(**self).load(factory)
}
fn persist(&mut self, candidate: &Candidate) -> Result<(), Self::Error> {
(**self).persist(candidate)
}
}
#[test]
fn errors_after_durable_marker_make_committed_unknown_until_reboot() {
let a = Candidate(30);
let b = Candidate(40);
let mut initial = Journal::new(FakeNor::erased());
initial.persist(&a).unwrap();
let baseline = initial.into_flash();
for failure in [
CommitFailure::Program,
CommitFailure::Read(0),
CommitFailure::Read(HEADER_LENGTH),
CommitFailure::CorruptRead(0),
CommitFailure::CorruptRead(HEADER_LENGTH),
] {
let mut flash = baseline.clone();
flash.commit_failure = Some(failure);
let mut journal = Journal::new(flash);
{
let mut manager = ConfigManager::recover(FACTORY, (), &mut journal).unwrap();
assert_eq!(manager.active(), &a);
assert_eq!(manager.committed(), Some(&a));
let expected = match failure {
CommitFailure::Program => JournalError::Storage(FakeError::Program),
CommitFailure::Read(_) => JournalError::Storage(FakeError::Read),
CommitFailure::CorruptRead(_) => JournalError::Verification,
};
assert_eq!(manager.replace(b), Err(ReplaceError::Store(expected)));
assert_eq!(manager.active(), &a);
assert_eq!(manager.factory(), &FACTORY);
assert_eq!(manager.committed(), None);
assert!(manager.restart_required());
// Restoring the pre-error values must not falsely succeed as a
// no-op, and retrying B cannot silently clear the unknown state.
for candidate in [a, b, Candidate(50)] {
assert_eq!(
manager.replace(candidate),
Err(ReplaceError::DurableStateUnknown)
);
assert_eq!(manager.active(), &a);
assert_eq!(manager.committed(), None);
}
}
let mut flash = journal.into_flash();
assert_eq!(
read_u32(&flash.slots[1][COMMIT_OFFSET..COMMIT_OFFSET + 4]),
COMMIT_MARKER
);
flash.commit_failure = None;
let rebooted = ConfigManager::recover(FACTORY, (), Journal::new(flash)).unwrap();
assert_eq!(rebooted.active(), &b, "failure {failure:?}");
assert_eq!(rebooted.committed(), Some(&b));
assert!(!rebooted.restart_required());
}
}
#[test]
fn alternates_slots_and_recovers_the_newest_record() {
let mut journal = Journal::new(FakeNor::erased());
@@ -538,67 +646,4 @@ mod tests {
let mut recovered = Journal::new(flash);
assert_eq!(recovered.load(&FACTORY).unwrap(), Some(Candidate(30)));
}
#[test]
fn compatibility_filter_can_select_the_older_structurally_valid_slot() {
let mut journal = Journal::new(FakeNor::erased());
journal.persist(&Candidate(30)).unwrap();
journal.persist(&Candidate(40)).unwrap();
assert_eq!(
journal
.load_matching(&FACTORY, |candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30))
);
assert_eq!(
journal
.load_matching(&FACTORY, |candidate| candidate.0 < 10)
.unwrap(),
None
);
}
#[test]
fn replacing_an_older_compatible_record_preserves_it_across_every_interruption() {
let mut initial = Journal::new(FakeNor::erased());
initial.persist(&Candidate(30)).unwrap();
initial.persist(&Candidate(40)).unwrap();
let baseline = initial.into_flash();
let mut successful = Journal::new(baseline.clone());
assert_eq!(
successful
.load_matching(&FACTORY, |candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30))
);
let start = successful.flash.operations;
successful.persist(&Candidate(35)).unwrap();
let operation_count = successful.flash.operations - start;
for interrupted_at in 0..operation_count {
let mut journal = Journal::new(baseline.clone());
assert_eq!(
journal
.load_matching(&FACTORY, |candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30))
);
journal.flash.fail_at = Some(journal.flash.operations + interrupted_at);
assert!(matches!(
journal.persist(&Candidate(35)),
Err(JournalError::Storage(FakeError::PowerLoss))
));
let mut recovered = Journal::new(journal.into_flash());
assert_eq!(
recovered
.load_matching(&FACTORY, |candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30)),
"mutation {interrupted_at} erased the selected compatible record"
);
}
}
}
+13 -11
View File
@@ -1,11 +1,11 @@
//! Runtime-configuration primitives shared by vehicle-specific schemas.
//!
//! This crate has no knowledge of MAVLink, filesystems, board routing, motors,
//! or a particular airframe. It owns the committed-versus-active lifecycle and
//! parameter metadata; vehicle crates provide the typed schema and complete
//! validation. It also owns the canonical public KCFG artifact and the compact
//! private scalar snapshot. Transport and storage framing remain separate
//! adapters.
//! Provides configuration lifecycle, field metadata, KCFG artifacts and storage
//! snapshots. Vehicle crates supply the schema and validation.
//!
//! A persistence error makes the manager's committed view unknown and blocks
//! ordinary saves until reboot recovery or explicit factory reset. The active
//! and factory views remain available; an errored save may become active at boot.
#![no_std]
@@ -22,9 +22,9 @@ pub mod storage;
pub mod validation;
pub use artifact::{
KcfgArtifact, KcfgDecodeError, KcfgEncodeError, KcfgEnumOption, KcfgEnumOptions,
KcfgExportPlan, KcfgExporter, KcfgField, KcfgFields, KcfgImporter, KcfgResultDetail,
decode_kcfg, encode_edited_kcfg, kcfg_length,
KCFG_FIELD_ID_SCRATCH_BYTES, KcfgArtifact, KcfgDecodeError, KcfgEncodeError, KcfgEnumOption,
KcfgEnumOptions, KcfgExportPlan, KcfgExporter, KcfgField, KcfgFields, KcfgImporter,
KcfgResultDetail, KcfgUpdate, decode_kcfg, encode_edited_kcfg, kcfg_length,
};
pub use error::{ConfigViolation, ValidationRule};
pub use field::{
@@ -33,10 +33,12 @@ pub use field::{
};
pub use journal::{DualSlotJournal, DualSlotNor, JournalError};
pub use kaptan_config_derive::{Config, ConfigEnum};
pub use lifecycle::{ConfigManager, ReplaceError, ReplaceOutcome, StartupError};
pub use lifecycle::{
ConfigManager, RecoveryDisposition, ReplaceError, ReplaceOutcome, StartupError,
};
pub use snapshot::{
ScalarSnapshotDecodeError, ScalarSnapshotEncodeError, SnapshotModelError,
decode_scalar_snapshot, encode_scalar_snapshot, scalar_snapshot_length,
};
pub use storage::ConfigStore;
pub use storage::{ConfigStore, StoreRecovery};
pub use validation::Config;
+298 -62
View File
@@ -2,7 +2,7 @@
use kaptan_err_reportable::KaptanError;
use crate::{ConfigModel, ConfigStore, ConfigViolation};
use crate::{ConfigModel, ConfigStore, ConfigViolation, StoreRecovery};
/// Failure while recovering a boot configuration from a store.
#[derive(Debug, KaptanError)]
@@ -16,6 +16,23 @@ pub enum StartupError<E> {
/// The board's compiled factory candidate violates its immutable context.
#[kaptan_error(id = 1103)]
InvalidFactory(ConfigViolation),
/// Occupied storage contains no readable candidate for this schema.
#[kaptan_error(id = 1104)]
UnreadablePersisted,
/// The explicit recovery policy supplied an invalid fallback candidate.
#[kaptan_error(id = 1105)]
InvalidRecovery(ConfigViolation),
}
/// Immutable diagnostic describing the configuration selected at boot.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum RecoveryDisposition {
/// The selected boot policy accepted a candidate without a recovery diagnostic.
Healthy,
/// The readable committed candidate was retained, but could not be activated.
InvalidPersisted(ConfigViolation),
/// Storage could not establish a readable committed candidate.
StorageUnknown,
}
/// Result of replacing the complete committed configuration.
@@ -31,9 +48,15 @@ pub enum ReplaceError<E> {
/// The complete candidate violates a relational or capability invariant.
#[kaptan_error(id = 1402)]
InvalidCandidate(ConfigViolation),
/// The storage backend could not commit the validated candidate.
/// The storage backend failed; the candidate may already be durable.
#[kaptan_error(id = 1403)]
Store(E),
/// Durability is unknown; ordinary saving requires recovery or factory reset.
#[kaptan_error(id = 1404)]
DurableStateUnknown,
/// The editor's expected base differs from the current committed values.
#[kaptan_error(id = 1405)]
Conflict,
}
/// Owns the immutable boot-active configuration and latest durable candidate.
@@ -41,12 +64,15 @@ pub enum ReplaceError<E> {
/// There is deliberately no in-place activation operation: [`Self::active`]
/// never changes after boot. A file endpoint owns its in-progress candidate;
/// this manager receives only complete candidates for validation and commit.
/// Any persistence error invalidates the committed view and blocks further
/// ordinary replacements until reboot recovery or an explicit factory reset.
pub struct ConfigManager<C: ConfigModel, S> {
active: C::Active,
factory: C,
committed: C,
committed: Option<C>,
context: C::Context,
store: S,
diagnostic: RecoveryDisposition,
}
impl<C, S> ConfigManager<C, S>
@@ -55,58 +81,69 @@ where
S: ConfigStore<C>,
{
/// Recovers the newest valid committed snapshot or uses the supplied
/// factory candidate when the store is empty.
/// factory candidate when the store is empty. Invalid or unreadable
/// persisted state fails startup rather than selecting factory values.
pub fn recover(
factory: C,
context: C::Context,
mut store: S,
store: S,
) -> Result<Self, StartupError<S::Error>> {
let factory_active = factory
.validate(&context)
.map_err(StartupError::InvalidFactory)?;
let (active, committed) = match store.load(&factory).map_err(StartupError::Store)? {
Some(committed) => {
let active = committed
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
(active, committed)
}
None => (factory_active, factory.clone()),
};
Ok(Self {
active,
factory,
committed,
context,
store,
})
Self::recover_with(factory, context, store, |_, _, _| None)
}
/// 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(
/// Explicit setup recovery policy. On invalid or unreadable storage the
/// callback may supply a fallback candidate, which this manager validates
/// before activation. Returning `None` preserves strict startup failure.
/// Readable invalid committed values remain available for repair. Neither
/// fallback activation nor inspection writes anything to storage.
/// The fallback supplies only the active view. It never replaces the
/// committed candidate or turns unknown durable state into known state.
pub fn recover_with(
factory: C,
context: C::Context,
mut store: S,
recovery: impl FnOnce(&C, &C::Context, RecoveryDisposition) -> Option<C>,
) -> Result<Self, StartupError<S::Error>> {
let factory_active = factory
.validate(&context)
.map_err(StartupError::InvalidFactory)?;
let committed = store
.load_matching(&factory, |candidate| candidate.validate(&context).is_ok())
.map_err(StartupError::Store)?;
let (active, committed) = match committed {
Some(committed) => {
let active = committed
let (committed, active, diagnostic) = match store.load_recovery(&factory) {
Ok(StoreRecovery::Committed(committed)) => {
let active = committed.validate(&context);
let diagnostic = match active {
Ok(_) => RecoveryDisposition::Healthy,
Err(issue) => RecoveryDisposition::InvalidPersisted(issue),
};
(
Some(committed),
active.map_err(StartupError::InvalidPersisted),
diagnostic,
)
}
Ok(StoreRecovery::Empty) => (
Some(factory.clone()),
Ok(factory_active),
RecoveryDisposition::Healthy,
),
Ok(StoreRecovery::Unreadable) => (
None,
Err(StartupError::UnreadablePersisted),
RecoveryDisposition::StorageUnknown,
),
Err(error) => (
None,
Err(StartupError::Store(error)),
RecoveryDisposition::StorageUnknown,
),
};
let active = match active {
Ok(active) => active,
Err(error) => {
let fallback = recovery(&factory, &context, diagnostic).ok_or(error)?;
fallback
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
(active, committed)
.map_err(StartupError::InvalidRecovery)?
}
None => (factory_active, factory.clone()),
};
Ok(Self {
active,
@@ -114,6 +151,7 @@ where
committed,
context,
store,
diagnostic,
})
}
@@ -122,9 +160,12 @@ where
&self.active
}
/// Returns the complete last durably committed candidate.
pub const fn committed(&self) -> &C {
&self.committed
/// Returns the readable committed candidate (possibly invalid), or the
/// empty-store factory selected at boot. Returns `None` when boot could not
/// establish durable state or after any persistence error, since the
/// attempted replacement may already be durable.
pub const fn committed(&self) -> Option<&C> {
self.committed.as_ref()
}
/// Returns the complete board/image factory candidate.
@@ -132,28 +173,76 @@ where
&self.factory
}
/// Whether committed values differ from the immutable boot-active view.
/// Returns the boot recovery disposition; saving does not change boot state.
pub const fn diagnostic(&self) -> RecoveryDisposition {
self.diagnostic
}
/// Whether every candidate scalar exactly matches the factory, including
/// native floating-point bits (so positive and negative zero differ).
pub fn is_factory(&self, candidate: &C) -> bool {
same_values(candidate, &self.factory)
}
/// Whether committed values differ from the immutable boot-active view,
/// or reboot recovery is required because the durable state is unknown.
pub fn restart_required(&self) -> bool {
!active_matches_candidate::<C>(&self.active, &self.committed)
self.committed
.as_ref()
.is_none_or(|committed| !active_matches_candidate::<C>(&self.active, committed))
}
/// Validates and atomically persists one complete replacement candidate.
///
/// An unchanged candidate does not consume a flash erase/write cycle. The
/// boot-active view remains immutable until a physical restart.
/// After any persistence error, all replacements (including unchanged
/// candidates) fail with [`ReplaceError::DurableStateUnknown`] until reset
/// or reboot recovery.
pub fn replace(&mut self, candidate: C) -> Result<ReplaceOutcome, ReplaceError<S::Error>> {
let committed = self
.committed
.as_ref()
.ok_or(ReplaceError::DurableStateUnknown)?;
candidate
.validate(&self.context)
.map_err(ReplaceError::InvalidCandidate)?;
if same_values(&candidate, &self.committed) {
if same_values(&candidate, committed) {
return Ok(ReplaceOutcome {
restart_required: self.restart_required(),
});
}
self.store
.persist(&candidate)
.map_err(ReplaceError::Store)?;
self.committed = candidate;
self.persist(candidate)
}
/// Guarded full replacement. The expected base is compared bit-for-bit
/// before validation, no-op detection, or any persistence attempt.
pub fn replace_if_current(
&mut self,
candidate: C,
expected_base: &C,
) -> Result<ReplaceOutcome, ReplaceError<S::Error>> {
let committed = self.committed().ok_or(ReplaceError::DurableStateUnknown)?;
if !same_values(expected_base, committed) {
return Err(ReplaceError::Conflict);
}
self.replace(candidate)
}
/// Explicit recovery action: always persists the validated factory, even
/// when durability is unknown or values appear unchanged. Active remains
/// immutable. A failed reset leaves durability unknown; success reopens
/// ordinary guarded saves.
pub fn reset_to_factory(&mut self) -> Result<ReplaceOutcome, ReplaceError<S::Error>> {
self.persist(self.factory.clone())
}
fn persist(&mut self, candidate: C) -> Result<ReplaceOutcome, ReplaceError<S::Error>> {
if let Err(error) = self.store.persist(&candidate) {
self.committed = None;
return Err(ReplaceError::Store(error));
}
self.committed = Some(candidate);
let restart_required = self.restart_required();
if restart_required {
kaptan_log::info!("configuration saved; restart required");
@@ -175,7 +264,7 @@ fn same_values<C: ConfigModel>(left: &C, right: &C) -> bool {
) else {
return false;
};
if left.kind() != right.kind() || left.raw_bits() != right.raw_bits() {
if left != right {
return false;
}
}
@@ -193,7 +282,7 @@ fn active_matches_candidate<C: ConfigModel>(active: &C::Active, candidate: &C) -
) else {
return false;
};
if active.kind() != candidate.kind() || active.raw_bits() != candidate.raw_bits() {
if active != candidate {
return false;
}
}
@@ -220,6 +309,8 @@ mod tests {
maximum: Some(ConfigValue::U8(10)),
enum_options: &[],
unit: "count",
finite: false,
nonzero: false,
},
FieldDescriptor {
id: OTHER,
@@ -229,6 +320,8 @@ mod tests {
maximum: Some(ConfigValue::U8(10)),
enum_options: &[],
unit: "count",
finite: false,
nonzero: false,
},
];
@@ -319,9 +412,24 @@ mod tests {
}
}
struct IncompatibleStore;
struct FailingStore<'a>(&'a Cell<usize>);
impl ConfigStore<Candidate> for IncompatibleStore {
impl ConfigStore<Candidate> for FailingStore<'_> {
type Error = ();
fn load(&mut self, _: &Candidate) -> Result<Option<Candidate>, Self::Error> {
Ok(None)
}
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
self.0.set(self.0.get() + 1);
Err(())
}
}
struct IncompatibleStore<'a>(&'a Cell<usize>);
impl ConfigStore<Candidate> for IncompatibleStore<'_> {
type Error = ();
fn load(&mut self, _: &Candidate) -> Result<Option<Candidate>, Self::Error> {
@@ -329,6 +437,7 @@ mod tests {
}
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
self.0.set(self.0.get() + 1);
Ok(())
}
}
@@ -362,7 +471,7 @@ mod tests {
ValidationRule::Inconsistent,
)))
);
assert_eq!(manager.committed(), &factory);
assert_eq!(manager.committed(), Some(&factory));
assert_eq!(writes.get(), 0);
assert_eq!(
@@ -396,22 +505,147 @@ mod tests {
restart_required: false,
})
);
assert_eq!(manager.committed(), &factory);
assert_eq!(manager.committed(), Some(&factory));
assert_eq!(manager.factory(), &factory);
assert_eq!(writes.get(), 2);
}
#[test]
fn explicit_recovery_policy_uses_factory_when_no_record_matches_context() {
fn any_persistence_error_blocks_all_replacements_until_recovery() {
use kaptan_err_reportable::ReportableError;
let attempts = Cell::new(0);
let factory = Candidate { value: 2, other: 4 };
let mut manager =
ConfigManager::recover(factory.clone(), (), FailingStore(&attempts)).unwrap();
assert_eq!(manager.committed(), Some(&factory));
assert!(!manager.restart_required());
assert_eq!(
manager.replace(Candidate { value: 3, other: 4 }),
Err(ReplaceError::Store(()))
);
assert_eq!(manager.committed(), None);
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.factory(), &factory);
assert!(manager.restart_required());
for candidate in [
factory,
Candidate { value: 3, other: 4 },
Candidate { value: 4, other: 4 },
Candidate { value: 5, other: 4 },
] {
assert_eq!(
manager.replace(candidate),
Err(ReplaceError::DurableStateUnknown)
);
assert_eq!(manager.committed(), None);
}
assert_eq!(attempts.get(), 1);
assert_eq!(
ReplaceError::<()>::DurableStateUnknown.error_id().get(),
1404
);
}
#[test]
fn explicit_fallback_retains_invalid_committed_values_and_validates_recovery() {
let factory = Candidate { value: 2, other: 4 };
let invalid = Candidate { value: 9, other: 1 };
let writes = Cell::new(0);
assert!(matches!(
ConfigManager::recover(factory.clone(), (), IncompatibleStore),
ConfigManager::recover(factory.clone(), (), IncompatibleStore(&writes)),
Err(StartupError::InvalidPersisted(_))
));
let manager =
ConfigManager::recover_compatible_or_factory(factory, (), IncompatibleStore).unwrap();
let mut manager = ConfigManager::recover_with(
factory.clone(),
(),
IncompatibleStore(&writes),
|factory, _, disposition| {
assert!(matches!(
disposition,
RecoveryDisposition::InvalidPersisted(_)
));
Some(factory.clone())
},
)
.unwrap();
assert_eq!(manager.committed(), Some(&invalid));
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(writes.get(), 0);
assert_eq!(
manager.replace_if_current(factory.clone(), &factory),
Err(ReplaceError::Conflict)
);
assert_eq!(writes.get(), 0);
manager
.replace_if_current(factory.clone(), &invalid)
.unwrap();
assert_eq!(writes.get(), 1, "factory repair must persist, not no-op");
assert_eq!(manager.committed(), Some(&factory));
assert!(matches!(
manager.diagnostic(),
RecoveryDisposition::InvalidPersisted(_)
));
assert!(matches!(
ConfigManager::recover_with(factory, (), IncompatibleStore(&writes), |_, _, _| {
Some(invalid)
}),
Err(StartupError::InvalidRecovery(_))
));
}
#[test]
fn factory_reset_is_explicit_forced_and_preserves_active_and_uncertainty_on_error() {
struct Store<'a> {
attempts: &'a Cell<usize>,
fail: &'a Cell<bool>,
}
impl ConfigStore<Candidate> for Store<'_> {
type Error = ();
fn load(&mut self, _: &Candidate) -> Result<Option<Candidate>, ()> {
Ok(Some(Candidate { value: 3, other: 4 }))
}
fn persist(&mut self, _: &Candidate) -> Result<(), ()> {
self.attempts.set(self.attempts.get() + 1);
if self.fail.get() { Err(()) } else { Ok(()) }
}
}
let attempts = Cell::new(0);
let fail = Cell::new(true);
let factory = Candidate { value: 2, other: 4 };
let mut manager = ConfigManager::recover(
factory.clone(),
(),
Store {
attempts: &attempts,
fail: &fail,
},
)
.unwrap();
assert_eq!(
manager.replace(factory.clone()),
Err(ReplaceError::Store(()))
);
assert_eq!(
manager.replace_if_current(factory.clone(), &factory),
Err(ReplaceError::DurableStateUnknown)
);
assert_eq!(attempts.get(), 1);
assert_eq!(manager.reset_to_factory(), Err(ReplaceError::Store(())));
assert_eq!(manager.committed(), None);
fail.set(false);
assert!(manager.reset_to_factory().unwrap().restart_required);
assert_eq!(attempts.get(), 3);
assert_eq!(manager.committed(), Some(&factory));
assert_eq!(manager.active(), &Active { value: 3, other: 4 });
manager
.replace_if_current(factory.clone(), &factory)
.unwrap();
assert_eq!(attempts.get(), 3, "same-base no-op must skip flash");
manager.reset_to_factory().unwrap();
assert_eq!(attempts.get(), 4, "explicit reset must always persist");
}
#[test]
@@ -422,7 +656,9 @@ mod tests {
Err(StartupError::InvalidFactory(_))
));
assert!(matches!(
ConfigManager::recover_compatible_or_factory(invalid_factory, (), ValidStore),
ConfigManager::recover_with(invalid_factory, (), ValidStore, |_, _, _| {
panic!("invalid factory must fail before the recovery callback")
}),
Err(StartupError::InvalidFactory(_))
));
}
+25 -15
View File
@@ -12,9 +12,7 @@
//! field_id:u16 | value_kind:u8 | value:[value_kind.width]
//! ```
//!
//! Field entries are emitted and decoded in descriptor order. The schema
//! fingerprint already fixes that order, so accepting permutations would add
//! work without adding a compatible persistence state.
//! Field entries follow descriptor order, as fixed by the schema fingerprint.
use kaptan_err_reportable::KaptanError;
@@ -38,12 +36,15 @@ pub(crate) const SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH: usize = 3;
/// Returns the exact encoded length for a schema's field and value widths.
///
/// The `u16` field-count domain makes this arithmetic safe on all targets
/// supported by this crate, including 32-bit microcontrollers.
/// `value_bytes` must sum the native widths of all fields, each 1 through 8
/// bytes. Panics if that total is outside the range allowed by `field_count`.
pub const fn scalar_snapshot_length(field_count: u16, value_bytes: usize) -> usize {
SCALAR_SNAPSHOT_HEADER_LENGTH
+ field_count as usize * SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH
+ value_bytes
let count = field_count as usize;
assert!(
value_bytes >= count && value_bytes <= count * ValueKind::F64.width(),
"snapshot value bytes do not match the field-count domain"
);
SCALAR_SNAPSHOT_HEADER_LENGTH + count * SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH + value_bytes
}
/// A broken [`ConfigModel`] registry detected before encoding or decoding.
@@ -225,11 +226,8 @@ pub fn encode_scalar_snapshot<'a, C: ConfigModel>(
/// 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.
/// Checks structure before applying writes through [`ConfigModel::set_field`].
/// The caller must validate whole-candidate relations and capabilities.
pub fn decode_scalar_snapshot<C: ConfigModel>(
input: &[u8],
factory: &C,
@@ -378,6 +376,8 @@ fn read_u16(bytes: &[u8]) -> u16 {
#[cfg(test)]
mod tests {
extern crate std;
use super::*;
use crate::{ConfigViolation, FieldDescriptor, ValidationRule};
@@ -403,6 +403,15 @@ mod tests {
descriptor(U16, ValueKind::U16),
];
#[test]
fn snapshot_length_bounds_raw_aggregate_widths() {
assert_eq!(scalar_snapshot_length(0, 0), 23);
assert_eq!(scalar_snapshot_length(u16::MAX, 524_280), 720_908);
for (count, bytes) in [(0, 1), (1, 0), (1, 9), (u16::MAX, usize::MAX)] {
assert!(std::panic::catch_unwind(|| scalar_snapshot_length(count, bytes)).is_err());
}
}
const fn descriptor(id: FieldId, kind: ValueKind) -> FieldDescriptor {
FieldDescriptor {
id,
@@ -412,6 +421,8 @@ mod tests {
maximum: None,
enum_options: &[],
unit: "",
finite: false,
nonzero: false,
}
}
@@ -682,8 +693,7 @@ mod tests {
})
);
// Every numeric kind now occupies its native width, so unlike the old
// widened u64 representation it cannot carry out-of-width high bits.
// Native-width encodings cannot carry out-of-width high bits.
}
#[test]
+23 -12
View File
@@ -1,5 +1,15 @@
//! Persistence interface for complete validated candidate snapshots.
/// Storage recovery without conflating erased storage with unreadable records.
pub enum StoreRecovery<C> {
/// No configuration has been stored.
Empty,
/// The newest readable candidate; relational validation is the caller's job.
Committed(C),
/// Storage is occupied, but no candidate can be decoded by this schema.
Unreadable,
}
/// Durable storage of complete configuration snapshots.
///
/// Flight implementations use a two-slot journal: a record contains a header,
@@ -14,20 +24,21 @@ pub trait ConfigStore<C> {
/// Loads the newest valid committed candidate, if one exists.
fn load(&mut self, factory: &C) -> Result<Option<C>, Self::Error>;
/// Loads the newest structurally valid candidate accepted by `compatible`.
///
/// Single-record stores inherit this implementation. Journals should
/// override it so an incompatible newest record can fall back to an older
/// compatible slot without weakening structural integrity checks.
fn load_matching<F>(&mut self, factory: &C, mut compatible: F) -> Result<Option<C>, Self::Error>
where
F: FnMut(&C) -> bool,
{
Ok(self
.load(factory)?
.filter(|candidate| compatible(candidate)))
/// Recovers a candidate while distinguishing empty from undecodable storage.
/// Stores whose `load(None)` can mean occupied storage must override this.
fn load_recovery(&mut self, factory: &C) -> Result<StoreRecovery<C>, Self::Error> {
Ok(match self.load(factory)? {
Some(candidate) => StoreRecovery::Committed(candidate),
None => StoreRecovery::Empty,
})
}
/// Persists one complete already-validated candidate atomically.
///
/// Success confirms that the candidate is durable. An error does not imply
/// rollback: the commit marker may already be durable when a later write
/// acknowledgement or verification read fails. Callers must treat durable
/// state as unknown after any error until recovery reloads it from storage
/// or an explicit factory reset successfully establishes a new record.
fn persist(&mut self, candidate: &C) -> Result<(), Self::Error>;
}
+10 -6
View File
@@ -14,7 +14,8 @@ pub trait Config {
/// Exact value bytes in the flattened private snapshot.
const SNAPSHOT_VALUE_BYTES: usize;
/// Fingerprint generated from the complete declared schema.
/// Fingerprint of declared scalar metadata and ordered child fingerprints.
/// Post-validator code and runtime capability context are not hashed.
const SCHEMA_ID: [u8; 16];
/// Returns a descriptor from the flattened declaration order.
@@ -85,19 +86,20 @@ pub fn __valid_enum_options(descriptors: &[FieldDescriptor]) -> bool {
if descriptor.enum_options.len() > crate::field::MAX_ENUM_OPTIONS_PER_FIELD {
return false;
}
for (index, option) in descriptor.enum_options.iter().enumerate() {
let mut seen = [0_u8; 32];
for option in descriptor.enum_options {
if option.1.is_empty()
|| option.1.len() > crate::field::MAX_ENUM_OPTION_LABEL_BYTES
|| descriptor.kind != crate::ValueKind::U8
{
return false;
}
if descriptor.enum_options[..index]
.iter()
.any(|previous| previous.0 == option.0)
{
let byte = usize::from(option.0 / 8);
let bit = 1 << (option.0 % 8);
if seen[byte] & bit != 0 {
return false;
}
seen[byte] |= bit;
}
}
true
@@ -173,6 +175,8 @@ pub const fn __schema_id(descriptors: &[FieldDescriptor]) -> [u8; 16] {
option += 1;
}
state = hash_str(state, descriptor.unit);
state = hash_byte(state, descriptor.finite as u8);
state = hash_byte(state, descriptor.nonzero as u8);
index += 1;
}
finish_fingerprint(state)
@@ -0,0 +1,318 @@
use core::cell::Cell;
use kaptan_config::{
ConfigManager, ConfigModel, ConfigStore, ConfigValue, ConfigViolation, FieldDescriptor,
FieldId, FieldWriteError, KcfgDecodeError, KcfgExporter, KcfgImporter, KcfgUpdate,
ReplaceError, ValidationRule, decode_kcfg, encode_edited_kcfg,
};
#[derive(Clone, Debug, PartialEq, kaptan_config::Config)]
struct Scalars {
#[config(id = 1, key = "test.u8", unit = "")]
u8_value: u8,
#[config(id = 2, key = "test.u16", unit = "")]
u16_value: u16,
#[config(id = 3, key = "test.u32", unit = "")]
u32_value: u32,
#[config(id = 4, key = "test.u64", unit = "")]
u64_value: u64,
#[config(id = 7, key = "test.i32", unit = "")]
i32_value: i32,
#[config(id = 9, key = "test.f32", unit = "")]
f32_value: f32,
#[config(id = 10, key = "test.f64", unit = "")]
f64_value: f64,
#[config(id = 11, key = "test.bool", unit = "")]
bool_value: bool,
#[config(id = 12, key = "test.finite", unit = "", finite)]
finite: f32,
#[config(id = 13, key = "test.nonzero", unit = "", nonzero)]
nonzero: f64,
}
impl ConfigModel for Scalars {
type Active = Self;
type Context = ();
const SCHEMA_ID: [u8; 16] = <Self as kaptan_config::Config>::SCHEMA_ID;
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
<Self as kaptan_config::Config>::descriptor(usize::from(index))
}
fn field_count() -> u16 {
<Self as kaptan_config::Config>::FIELD_COUNT as u16
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
<Self as kaptan_config::Config>::field_value(self, id)
}
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> {
<Self as kaptan_config::Config>::set_field(self, id, value)
}
fn validate(&self, _: &()) -> Result<Self::Active, ConfigViolation> {
<Self as kaptan_config::Config>::validate(self)?;
Ok(self.clone())
}
}
fn factory() -> Scalars {
Scalars {
u8_value: u8::MAX,
u16_value: u16::MAX,
u32_value: u32::MAX,
u64_value: u64::MAX,
i32_value: i32::MIN,
f32_value: 0.0,
f64_value: 0.0,
bool_value: true,
finite: 1.0,
nonzero: 1.0,
}
}
struct Store<'a>(&'a Cell<usize>);
impl ConfigStore<Scalars> for Store<'_> {
type Error = ();
fn load(&mut self, _: &Scalars) -> Result<Option<Scalars>, ()> {
Ok(None)
}
fn persist(&mut self, _: &Scalars) -> Result<(), ()> {
self.0.set(self.0.get() + 1);
Ok(())
}
}
fn export(current: &Scalars, factory: &Scalars) -> Vec<u8> {
let exporter = KcfgExporter::new(current, factory, false).unwrap();
let mut bytes = vec![0; exporter.length()];
exporter.read(0, &mut bytes).unwrap();
bytes
}
fn edit(bytes: &[u8], candidate: &Scalars) -> Vec<u8> {
let source = decode_kcfg(bytes, &mut [0; kaptan_config::KCFG_FIELD_ID_SCRATCH_BYTES]).unwrap();
let mut edited = vec![0; bytes.len()];
encode_edited_kcfg(&source, false, &mut edited, |field| {
candidate.field_value(field.id()).unwrap()
})
.unwrap();
edited
}
fn import(bytes: &[u8], factory: &Scalars) -> KcfgUpdate<Scalars> {
let mut importer = KcfgImporter::new(factory).unwrap();
for byte in bytes {
importer.push(factory, core::slice::from_ref(byte)).unwrap();
}
importer.finish().unwrap()
}
fn assert_exact(actual: ConfigValue, expected: ConfigValue) {
match (actual, expected) {
(ConfigValue::F32(a), ConfigValue::F32(b)) => assert_eq!(a.to_bits(), b.to_bits()),
(ConfigValue::F64(a), ConfigValue::F64(b)) => assert_eq!(a.to_bits(), b.to_bits()),
_ => assert_eq!(actual, expected),
}
}
#[test]
fn independent_editors_compare_the_downloaded_base_before_even_noop_or_validation() {
let a = factory();
let writes = Cell::new(0);
let mut manager = ConfigManager::recover(a.clone(), (), Store(&writes)).unwrap();
let editor_a = export(manager.committed().unwrap(), &a);
let editor_b = export(manager.committed().unwrap(), &a);
let mut b = a.clone();
b.u64_value -= 1; // Above the exact-integer range of floating-point transports.
b.f32_value = -0.0;
b.f64_value = -0.0;
let update = import(&edit(&editor_b, &b), &a);
assert!(
manager
.replace_if_current(update.candidate, &update.expected_base)
.unwrap()
.restart_required
);
assert_eq!(writes.get(), 1);
// Editing an already-edited draft must not rebase it. Even a requested
// value set exactly equal to current B is stale when its base was A.
let draft = edit(&edit(&editor_a, &b), &b);
let update = import(&draft, &a);
assert_eq!(
manager.replace_if_current(update.candidate, &update.expected_base),
Err(ReplaceError::Conflict)
);
let update = import(&editor_a, &a);
assert_eq!(
manager.replace_if_current(update.candidate, &update.expected_base),
Err(ReplaceError::Conflict)
);
let mut invalid = b.clone();
invalid.finite = f32::NAN;
let violation = ConfigViolation::field(FieldId::new(12), ValidationRule::NonFinite);
assert_eq!(invalid.validate(&()).unwrap_err(), violation);
assert_eq!(
manager.replace_if_current(invalid.clone(), &a),
Err(ReplaceError::Conflict)
);
assert_eq!(
manager.replace_if_current(invalid, &b),
Err(ReplaceError::InvalidCandidate(violation))
);
assert_eq!(writes.get(), 1);
let fresh = import(&export(manager.committed().unwrap(), &a), &a);
manager
.replace_if_current(fresh.candidate, &fresh.expected_base)
.unwrap();
assert_eq!(writes.get(), 1, "same-base no-op does not consume flash");
assert_eq!(manager.active().f32_value.to_bits(), 0.0_f32.to_bits());
assert_eq!(
manager.committed().unwrap().f32_value.to_bits(),
(-0.0_f32).to_bits()
);
assert!(!manager.is_factory(&b));
assert!(!manager.replace_if_current(a, &b).unwrap().restart_required);
assert_eq!(writes.get(), 2);
}
#[test]
fn v3_preserves_all_native_widths_and_signed_zero_in_both_value_sets() {
let factory = factory();
let mut current = factory.clone();
current.f32_value = -0.0;
current.f64_value = -0.0;
let bytes = export(&current, &factory);
assert_eq!(bytes[4], 3);
let artifact =
decode_kcfg(&bytes, &mut [0; kaptan_config::KCFG_FIELD_ID_SCRATCH_BYTES]).unwrap();
assert_eq!(artifact.schema_id(), Scalars::SCHEMA_ID);
for field in artifact.fields() {
assert_eq!(field.finite(), field.id() == FieldId::new(12));
assert_eq!(field.nonzero(), field.id() == FieldId::new(13));
let expected = current.field_value(field.id()).unwrap();
assert_exact(field.value(), expected);
assert_exact(field.expected_base_value(), expected);
}
let update = import(&edit(&bytes, &factory), &factory);
for index in 0..Scalars::field_count() {
let id = Scalars::descriptor(index).unwrap().id;
assert_exact(
update.candidate.field_value(id).unwrap(),
factory.field_value(id).unwrap(),
);
assert_exact(
update.expected_base.field_value(id).unwrap(),
current.field_value(id).unwrap(),
);
}
// 24 header + 4 CRC + 70 prefix + 132 value + 87 key bytes.
assert_eq!(bytes.len(), 317);
let mut importer = KcfgImporter::new(&factory).unwrap();
assert_eq!(
importer.push(&factory, &export(&current, &current)),
Err(KcfgDecodeError::FactoryMismatch(FieldId::new(9)))
);
// Base checks, no-op checks, restart checks, and factory checks must all
// distinguish signed zero even when every other scalar matches.
for id in [FieldId::new(9), FieldId::new(10)] {
let writes = Cell::new(0);
let mut manager = ConfigManager::recover(factory.clone(), (), Store(&writes)).unwrap();
let mut negative_zero = factory.clone();
negative_zero
.set_field(id, current.field_value(id).unwrap())
.unwrap();
assert!(!manager.is_factory(&negative_zero));
assert_eq!(
manager.replace_if_current(factory.clone(), &negative_zero),
Err(ReplaceError::Conflict)
);
assert!(
manager
.replace_if_current(negative_zero.clone(), &factory)
.unwrap()
.restart_required
);
assert_eq!(writes.get(), 1);
assert_eq!(
manager.replace_if_current(negative_zero, &factory),
Err(ReplaceError::Conflict)
);
}
}
#[test]
fn nonfinite_payload_identity_survives_artifacts_cas_noops_and_factory_checks() {
for (single, double) in [
(0x7f80_0000, 0xfff0_0000_0000_0000), // infinities
(0x7fc0_0042, 0xfff8_0000_0000_0042), // quiet payloads
(0xff80_0042, 0x7ff0_0000_0000_0042), // signaling payloads
] {
let mut factory = factory();
factory.f32_value = f32::from_bits(single);
factory.f64_value = f64::from_bits(double);
let writes = Cell::new(0);
let mut manager = ConfigManager::recover(factory.clone(), (), Store(&writes)).unwrap();
let bytes = export(&factory, &factory);
let update = import(&bytes, &factory);
assert!(manager.is_factory(&update.candidate));
assert!(
!manager
.replace_if_current(update.candidate, &update.expected_base)
.unwrap()
.restart_required
);
assert_eq!(writes.get(), 0);
let mut other = factory.clone();
other.f64_value = f64::from_bits(double ^ 1);
assert!(!manager.is_factory(&other));
assert_eq!(
manager.replace_if_current(factory.clone(), &other),
Err(ReplaceError::Conflict)
);
assert!(
manager
.replace_if_current(other.clone(), &factory)
.unwrap()
.restart_required
);
assert_eq!(writes.get(), 1);
let update = import(&export(&other, &factory), &factory);
assert_eq!(
update.candidate.field_value(FieldId::new(10)),
Some(ConfigValue::F64(other.f64_value))
);
}
assert_ne!(ConfigValue::U32(0), ConfigValue::F32(0.0));
}
#[test]
fn streaming_import_checks_rule_flags_and_rejects_the_unshipped_old_format() {
let factory = factory();
let bytes = export(&factory, &factory);
let mut old = bytes.clone();
old[4] = 2;
let mut importer = KcfgImporter::new(&factory).unwrap();
assert_eq!(
importer.push(&factory, &old),
Err(KcfgDecodeError::UnsupportedVersion(2))
);
let mut offset = 24;
for index in 0..Scalars::field_count() {
let field = Scalars::descriptor(index).unwrap();
if field.finite || field.nonzero {
let mut edited = bytes.clone();
edited[offset + 3] ^= if field.finite { 4 } else { 8 };
let mut importer = KcfgImporter::new(&factory).unwrap();
assert_eq!(
importer.push(&factory, &edited),
Err(KcfgDecodeError::DescriptorMismatch(field.id))
);
}
offset += 7 + field.kind.width() * 3 + field.display_key.len() + field.unit.len();
}
}