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.
381 lines
10 KiB
Rust
381 lines
10 KiB
Rust
use kaptan_config::{
|
|
Config, ConfigEnum, ConfigValue, ConfigViolation, FieldId, FieldWriteError, ValidationRule,
|
|
};
|
|
|
|
#[derive(Config)]
|
|
struct LocalSection {
|
|
#[config(id = 1, key = "local.first", unit = "count", range(min = 1, max = 9))]
|
|
first: u32,
|
|
#[config(id = 2, key = "local.second", unit = "count", range(min = 1, max = 9))]
|
|
second: u32,
|
|
}
|
|
|
|
#[derive(Config)]
|
|
#[config(post = Self::validate_order)]
|
|
struct OrderedSection {
|
|
#[config(
|
|
id = 3,
|
|
key = "ordered.minimum",
|
|
unit = "count",
|
|
range(min = 0, max = 9)
|
|
)]
|
|
minimum: u32,
|
|
#[config(
|
|
id = 4,
|
|
key = "ordered.maximum",
|
|
unit = "count",
|
|
range(min = 0, max = 9)
|
|
)]
|
|
maximum: u32,
|
|
}
|
|
|
|
impl OrderedSection {
|
|
fn validate_order(&self) -> Result<(), ConfigViolation> {
|
|
if self.minimum > self.maximum {
|
|
return Err(ConfigViolation::related(
|
|
FieldId::new(3),
|
|
FieldId::new(4),
|
|
ValidationRule::Inconsistent,
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Config)]
|
|
struct Aggregate {
|
|
local: LocalSection,
|
|
ordered: OrderedSection,
|
|
}
|
|
|
|
#[derive(Config)]
|
|
struct BoardOwnedSection {
|
|
#[config(
|
|
id = 10,
|
|
key = "board.endpoint",
|
|
unit = "id",
|
|
range(min = 1, max = 255)
|
|
)]
|
|
endpoint: u32,
|
|
}
|
|
|
|
#[derive(Config)]
|
|
struct BoardOwnedAggregate {
|
|
endpoint: BoardOwnedSection,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, ConfigEnum)]
|
|
#[repr(u8)]
|
|
enum TestMode {
|
|
Quiet = 1,
|
|
Loud = 3,
|
|
}
|
|
|
|
#[derive(Config)]
|
|
struct CompactSection {
|
|
#[config(id = 20, key = "compact.endpoint", unit = "id", range(min = 1))]
|
|
endpoint: u8,
|
|
#[config(enum, id = 21, key = "compact.mode", unit = "mode")]
|
|
mode: TestMode,
|
|
#[config(id = 22, key = "compact.port", unit = "id", range(min = 1))]
|
|
port: u16,
|
|
}
|
|
|
|
#[test]
|
|
fn generated_section_owns_every_declared_scalar_path() {
|
|
let mut section = LocalSection {
|
|
first: 2,
|
|
second: 4,
|
|
};
|
|
assert_eq!(LocalSection::FIELD_COUNT, 2);
|
|
assert_eq!(
|
|
section.field_value(FieldId::new(2)),
|
|
Some(ConfigValue::U32(4))
|
|
);
|
|
section
|
|
.set_field(FieldId::new(2), ConfigValue::U32(8))
|
|
.unwrap();
|
|
assert_eq!(
|
|
section.field_value(FieldId::new(2)),
|
|
Some(ConfigValue::U32(8))
|
|
);
|
|
assert_eq!(
|
|
section.set_field(FieldId::new(2), ConfigValue::U32(0)),
|
|
Err(FieldWriteError::Violation(ConfigViolation::field(
|
|
FieldId::new(2),
|
|
ValidationRule::BelowMinimum,
|
|
)))
|
|
);
|
|
assert_eq!(
|
|
section.field_value(FieldId::new(2)),
|
|
Some(ConfigValue::U32(8))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn narrow_scalars_and_compact_enums_keep_native_types_and_generic_metadata() {
|
|
let mut section = CompactSection {
|
|
endpoint: 7,
|
|
mode: TestMode::Quiet,
|
|
port: 4861,
|
|
};
|
|
assert_eq!(CompactSection::SNAPSHOT_VALUE_BYTES, 1 + 1 + 2);
|
|
assert_eq!(
|
|
section.field_value(FieldId::new(20)),
|
|
Some(ConfigValue::U8(7))
|
|
);
|
|
assert_eq!(
|
|
CompactSection::DESCRIPTORS[0].minimum,
|
|
Some(ConfigValue::U8(1))
|
|
);
|
|
assert_eq!(CompactSection::DESCRIPTORS[0].maximum, None);
|
|
assert_eq!(
|
|
CompactSection::DESCRIPTORS[1].enum_options,
|
|
TestMode::CONFIG_ENUM_OPTIONS
|
|
);
|
|
assert_eq!(TestMode::Quiet.wire_value(), 1);
|
|
assert_eq!(TestMode::try_from_wire(3), Some(TestMode::Loud));
|
|
assert_eq!(TestMode::try_from_wire(2), None);
|
|
|
|
section
|
|
.set_field(FieldId::new(21), ConfigValue::U8(3))
|
|
.unwrap();
|
|
assert_eq!(section.mode, TestMode::Loud);
|
|
assert!(matches!(
|
|
section.set_field(FieldId::new(21), ConfigValue::U8(2)),
|
|
Err(FieldWriteError::Violation(issue))
|
|
if issue.rule() == ValidationRule::InvalidEnumValue
|
|
));
|
|
assert!(matches!(
|
|
section.set_field(FieldId::new(20), ConfigValue::U8(0)),
|
|
Err(FieldWriteError::Violation(issue))
|
|
if issue.rule() == ValidationRule::BelowMinimum
|
|
));
|
|
assert_eq!(
|
|
section.field_value(FieldId::new(22)),
|
|
Some(ConfigValue::U16(4861))
|
|
);
|
|
assert_eq!(
|
|
CompactSection::DESCRIPTORS[2].kind,
|
|
kaptan_config::ValueKind::U16
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
|
let mut aggregate = Aggregate {
|
|
local: LocalSection {
|
|
first: 2,
|
|
second: 4,
|
|
},
|
|
ordered: OrderedSection {
|
|
minimum: 1,
|
|
maximum: 2,
|
|
},
|
|
};
|
|
assert_eq!(Aggregate::FIELD_COUNT, 4);
|
|
assert_eq!(
|
|
aggregate.field_value(FieldId::new(2)),
|
|
Some(ConfigValue::U32(4))
|
|
);
|
|
aggregate
|
|
.set_field(FieldId::new(3), ConfigValue::U32(3))
|
|
.unwrap();
|
|
assert!(matches!(
|
|
aggregate.validate(),
|
|
Err(issue)
|
|
if issue.primary() == Some(FieldId::new(3))
|
|
&& issue.related_field() == Some(FieldId::new(4))
|
|
&& issue.rule() == ValidationRule::Inconsistent
|
|
));
|
|
aggregate
|
|
.set_field(FieldId::new(4), ConfigValue::U32(3))
|
|
.unwrap();
|
|
aggregate.validate().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_derives_its_flattened_descriptor_order() {
|
|
assert_eq!(Aggregate::SNAPSHOT_VALUE_BYTES, 4 * 4);
|
|
let descriptors: Vec<_> = (0..Aggregate::FIELD_COUNT)
|
|
.map(|index| Aggregate::descriptor(index).unwrap())
|
|
.collect();
|
|
assert_eq!(
|
|
descriptors
|
|
.iter()
|
|
.map(|descriptor| descriptor.id.get())
|
|
.collect::<Vec<_>>(),
|
|
[1, 2, 3, 4]
|
|
);
|
|
assert!(Aggregate::descriptor(Aggregate::FIELD_COUNT).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn factory_values_are_owned_outside_the_generated_schema() {
|
|
let mut aggregate = BoardOwnedAggregate {
|
|
endpoint: BoardOwnedSection { endpoint: 42 },
|
|
};
|
|
assert_eq!(BoardOwnedAggregate::FIELD_COUNT, 1);
|
|
assert_eq!(
|
|
aggregate.field_value(FieldId::new(10)),
|
|
Some(ConfigValue::U32(42))
|
|
);
|
|
aggregate
|
|
.set_field(FieldId::new(10), ConfigValue::U32(43))
|
|
.unwrap();
|
|
aggregate.validate().unwrap();
|
|
}
|
|
|
|
#[derive(Config)]
|
|
struct DuplicateChildren {
|
|
first: LocalSection,
|
|
second: LocalSection,
|
|
}
|
|
|
|
#[derive(Config)]
|
|
struct TransparentRoot {
|
|
local: LocalSection,
|
|
}
|
|
|
|
#[derive(Config)]
|
|
struct FirstKeyOwner {
|
|
#[config(id = 30, key = "shared.key", unit = "count")]
|
|
value: u32,
|
|
}
|
|
|
|
#[derive(Config)]
|
|
struct SecondKeyOwner {
|
|
#[config(id = 31, key = "shared.key", unit = "count")]
|
|
value: u32,
|
|
}
|
|
|
|
#[derive(Config)]
|
|
struct DuplicateKeysAcrossChildren {
|
|
first: FirstKeyOwner,
|
|
second: SecondKeyOwner,
|
|
}
|
|
|
|
#[test]
|
|
fn schema_fingerprint_is_generated_and_one_child_wrappers_are_transparent() {
|
|
assert_eq!(TransparentRoot::SCHEMA_ID, LocalSection::SCHEMA_ID);
|
|
assert_ne!(Aggregate::SCHEMA_ID, LocalSection::SCHEMA_ID);
|
|
}
|
|
|
|
#[test]
|
|
fn recursive_tree_rejects_duplicate_field_ids() {
|
|
let duplicate = DuplicateChildren {
|
|
first: LocalSection {
|
|
first: 2,
|
|
second: 4,
|
|
},
|
|
second: LocalSection {
|
|
first: 2,
|
|
second: 4,
|
|
},
|
|
};
|
|
assert!(matches!(
|
|
duplicate.validate(),
|
|
Err(issue) if issue.rule() == ValidationRule::Inconsistent
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn recursive_tree_rejects_duplicate_display_keys() {
|
|
let duplicate = DuplicateKeysAcrossChildren {
|
|
first: FirstKeyOwner { value: 1 },
|
|
second: SecondKeyOwner { value: 2 },
|
|
};
|
|
assert!(matches!(
|
|
duplicate.validate(),
|
|
Err(issue) if issue.rule() == ValidationRule::Inconsistent
|
|
));
|
|
}
|
|
|
|
#[derive(Config)]
|
|
struct FloatRules {
|
|
#[config(id = 1, key = "finite", unit = "", finite)]
|
|
finite: f32,
|
|
#[config(id = 2, key = "nonzero", unit = "", nonzero)]
|
|
nonzero: f64,
|
|
#[config(id = 3, key = "lower", unit = "", range(min = 0.0))]
|
|
lower: f32,
|
|
#[config(id = 4, key = "upper", unit = "", range(max = 1.0))]
|
|
upper: f64,
|
|
#[config(id = 5, key = "free", unit = "")]
|
|
free: f64,
|
|
#[config(id = 6, key = "nonzero32", unit = "", nonzero)]
|
|
nonzero32: f32,
|
|
}
|
|
|
|
#[test]
|
|
fn scalar_rules_preserve_local_repair_and_float_domains() {
|
|
let mut values = FloatRules {
|
|
finite: f32::NAN,
|
|
nonzero: 0.0,
|
|
lower: f32::NAN,
|
|
upper: f64::NAN,
|
|
free: f64::NAN,
|
|
nonzero32: 0.0,
|
|
};
|
|
// Every other field is invalid. A local repair must still succeed.
|
|
values
|
|
.set_field(FieldId::new(1), ConfigValue::F32(1.0))
|
|
.unwrap();
|
|
for (id, value, rule) in [
|
|
(
|
|
1,
|
|
ConfigValue::F32(f32::INFINITY),
|
|
ValidationRule::NonFinite,
|
|
),
|
|
(2, ConfigValue::F64(0.0), ValidationRule::Zero),
|
|
(2, ConfigValue::F64(-0.0), ValidationRule::Zero),
|
|
(3, ConfigValue::F32(f32::NAN), ValidationRule::Unordered),
|
|
(4, ConfigValue::F64(f64::NAN), ValidationRule::Unordered),
|
|
(6, ConfigValue::F32(0.0), ValidationRule::Zero),
|
|
(6, ConfigValue::F32(-0.0), ValidationRule::Zero),
|
|
] {
|
|
let id = FieldId::new(id);
|
|
let before = values.field_value(id);
|
|
assert_eq!(
|
|
values.set_field(id, value),
|
|
Err(FieldWriteError::Violation(ConfigViolation::field(id, rule)))
|
|
);
|
|
assert_eq!(values.field_value(id), before);
|
|
}
|
|
// Neither nonzero nor a one-sided range implies finite.
|
|
for (id, value) in [
|
|
(2, ConfigValue::F64(f64::from_bits(0xfff0_0000_0000_0042))),
|
|
(3, ConfigValue::F32(f32::INFINITY)),
|
|
(4, ConfigValue::F64(f64::NEG_INFINITY)),
|
|
(5, ConfigValue::F64(f64::from_bits(0x7ff8_0000_0000_1234))),
|
|
(6, ConfigValue::F32(f32::INFINITY)),
|
|
] {
|
|
values.set_field(FieldId::new(id), value).unwrap();
|
|
assert_eq!(values.field_value(FieldId::new(id)), Some(value));
|
|
}
|
|
values.validate().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn rule_metadata_changes_fingerprints() {
|
|
let descriptor = FloatRules::DESCRIPTORS[0];
|
|
assert!(descriptor.finite);
|
|
assert!(!descriptor.nonzero);
|
|
assert!(FloatRules::DESCRIPTORS[1].nonzero);
|
|
let original = kaptan_config::validation::__schema_id(&[descriptor]);
|
|
assert_ne!(
|
|
original,
|
|
kaptan_config::validation::__schema_id(&[kaptan_config::FieldDescriptor {
|
|
finite: false,
|
|
..descriptor
|
|
}])
|
|
);
|
|
assert_ne!(
|
|
original,
|
|
kaptan_config::validation::__schema_id(&[kaptan_config::FieldDescriptor {
|
|
nonzero: true,
|
|
..descriptor
|
|
}])
|
|
);
|
|
}
|