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:
1 parent
fb1b8af2d1
commit
f9a61a086f
211 files changed
+45657
-12740
No files matched your search
@@ -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(¤t, &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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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(¤t, &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(¤t, ¤t)),
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user