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
@@ -0,0 +1,775 @@
|
||||
use kaptan_config::{
|
||||
ConfigManager, ConfigModel, ConfigStore, ConfigValue, DualSlotJournal, DualSlotNor, FieldId,
|
||||
KcfgExporter, KcfgImporter, KcfgResultDetail, ReplaceError, decode_kcfg, encode_edited_kcfg,
|
||||
};
|
||||
use kaptan_file_mavlink::{
|
||||
Content, ControlRequest, ControlResponse, DATA_BYTES, DataRequest, DispatchResult,
|
||||
FileOperation, FileProvider, FileRequest, FileResponse, FileService, FileStatus, IngressId,
|
||||
Principal, Target, TransferPeer, crc32c, wire,
|
||||
};
|
||||
use kaptan_ports::{PortId, Role};
|
||||
use platform_stm32f7::{F7Board, F7ImmutableConfig};
|
||||
extern crate std;
|
||||
use crate::f722::{config as target_config, geprc, kakute};
|
||||
use std::{cell::RefCell, rc::Rc, vec, vec::Vec};
|
||||
use target_config::{
|
||||
BootDiagnostic, F722ConfigFile, F722FlashError, UserConfigModel, recover_configuration,
|
||||
};
|
||||
|
||||
type Board = kakute::KakuteF7MiniV3;
|
||||
type C = <Board as F7Board>::UserConfig;
|
||||
type I = <Board as F7Board>::ImmutableConfig;
|
||||
const PORT_COUNT: usize = Board::IMMUTABLE_CONFIG.hardware.serial().len();
|
||||
type Model = UserConfigModel<C, I, PORT_COUNT>;
|
||||
const N: usize = platform_stm32f7::config_snapshot_length::<C>();
|
||||
type Store = DualSlotJournal<RamNor, N>;
|
||||
type Manager = ConfigManager<Model, Store>;
|
||||
type Provider = F722ConfigFile<C, I, PORT_COUNT, N, Store>;
|
||||
|
||||
struct FlashState {
|
||||
bytes: [[u8; 2048]; 2],
|
||||
operations: usize,
|
||||
bytes_read: usize,
|
||||
fail_after: Option<usize>,
|
||||
fail_after_commit: bool,
|
||||
fail_reads: bool,
|
||||
}
|
||||
impl Default for FlashState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bytes: [[0xff; 2048]; 2],
|
||||
operations: 0,
|
||||
bytes_read: 0,
|
||||
fail_after: None,
|
||||
fail_after_commit: false,
|
||||
fail_reads: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Default)]
|
||||
struct RamNor(Rc<RefCell<FlashState>>);
|
||||
impl RamNor {
|
||||
fn operation(state: &mut FlashState) -> Result<(), F722FlashError> {
|
||||
if state.fail_after == Some(state.operations) {
|
||||
return Err(F722FlashError::Controller);
|
||||
}
|
||||
state.operations += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl DualSlotNor for RamNor {
|
||||
type Error = F722FlashError;
|
||||
fn slot_size(&self) -> usize {
|
||||
2048
|
||||
}
|
||||
fn read(&mut self, slot: usize, offset: usize, output: &mut [u8]) -> Result<(), Self::Error> {
|
||||
let mut state = self.0.borrow_mut();
|
||||
if state.fail_reads {
|
||||
return Err(F722FlashError::Controller);
|
||||
}
|
||||
state.bytes_read += output.len();
|
||||
output.copy_from_slice(&state.bytes[slot][offset..offset + output.len()]);
|
||||
Ok(())
|
||||
}
|
||||
fn erase_slot(&mut self, slot: usize) -> Result<(), Self::Error> {
|
||||
let mut state = self.0.borrow_mut();
|
||||
Self::operation(&mut state)?;
|
||||
state.bytes[slot].fill(0xff);
|
||||
Ok(())
|
||||
}
|
||||
fn program_word(&mut self, slot: usize, offset: usize, word: u32) -> Result<(), Self::Error> {
|
||||
let mut state = self.0.borrow_mut();
|
||||
Self::operation(&mut state)?;
|
||||
for (byte, value) in state.bytes[slot][offset..offset + 4]
|
||||
.iter_mut()
|
||||
.zip(word.to_le_bytes())
|
||||
{
|
||||
assert_eq!(*byte & value, value);
|
||||
*byte = value;
|
||||
}
|
||||
// Fault after the durable marker, before journal verification.
|
||||
if state.fail_after_commit && word == u32::from_le_bytes(*b"DONE") {
|
||||
return Err(F722FlashError::Verification);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn factory() -> Model {
|
||||
Model::new(Board::FACTORY_USER_CONFIG)
|
||||
}
|
||||
fn recover(flash: &RamNor) -> Manager {
|
||||
recover_configuration(
|
||||
factory(),
|
||||
Board::IMMUTABLE_CONFIG,
|
||||
Store::new(flash.clone()),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn artifact(candidate: &Model) -> Vec<u8> {
|
||||
let factory = factory();
|
||||
let export = KcfgExporter::new(candidate, &factory, false).unwrap();
|
||||
let mut bytes = vec![0; export.length()];
|
||||
assert_eq!(export.read(0, &mut bytes).unwrap(), bytes.len());
|
||||
bytes
|
||||
}
|
||||
fn decode(bytes: &[u8]) -> Model {
|
||||
let factory = factory();
|
||||
let mut importer = KcfgImporter::new(&factory).unwrap();
|
||||
for chunk in bytes.chunks(37) {
|
||||
importer.push(&factory, chunk).unwrap();
|
||||
}
|
||||
importer.finish().unwrap().candidate
|
||||
}
|
||||
const TARGET: Target = Target::new(1, 1);
|
||||
const PEER: TransferPeer = TransferPeer {
|
||||
principal: Principal::new(42, 7),
|
||||
ingress: IngressId(0),
|
||||
};
|
||||
fn control(operation: FileOperation, position: u32, path: &[u8], bytes: &[u8]) -> FileRequest {
|
||||
FileRequest::Control(ControlRequest {
|
||||
operation,
|
||||
target: TARGET,
|
||||
position,
|
||||
file_size: bytes.len() as u32,
|
||||
checksum_crc32c: if bytes.is_empty() { 0 } else { crc32c(bytes) },
|
||||
transfer_id: 9,
|
||||
content: Content::try_new(path).unwrap(),
|
||||
})
|
||||
}
|
||||
fn exchange(
|
||||
service: &mut FileService,
|
||||
provider: &mut impl FileProvider,
|
||||
peer: TransferPeer,
|
||||
request: FileRequest,
|
||||
) -> DispatchResult {
|
||||
let request = wire::decode_request(&wire::encode_request(request)).unwrap();
|
||||
let result = service.dispatch(provider, peer, request).unwrap();
|
||||
assert_eq!(
|
||||
wire::decode_response(&wire::encode_response(result.response)).unwrap(),
|
||||
result.response
|
||||
);
|
||||
result
|
||||
}
|
||||
fn accepted(result: DispatchResult) -> Result<ControlResponse, FileStatus> {
|
||||
let FileResponse::Control(response) = result.response else {
|
||||
panic!("control response expected");
|
||||
};
|
||||
if response.status != FileStatus::Ok {
|
||||
return Err(response.status);
|
||||
}
|
||||
assert!(result.refresh_lease);
|
||||
Ok(response)
|
||||
}
|
||||
fn read_result(provider: &mut impl FileProvider, path: &str) -> Result<Vec<u8>, FileStatus> {
|
||||
let mut service = FileService::new(TARGET);
|
||||
let metadata = accepted(exchange(
|
||||
&mut service,
|
||||
provider,
|
||||
PEER,
|
||||
control(FileOperation::OpenRead, 0, path.as_bytes(), &[]),
|
||||
))?;
|
||||
let mut bytes = vec![0; metadata.file_size as usize];
|
||||
for (i, chunk) in bytes.chunks_mut(DATA_BYTES).enumerate() {
|
||||
let result = exchange(
|
||||
&mut service,
|
||||
provider,
|
||||
PEER,
|
||||
control(FileOperation::Read, (i * DATA_BYTES) as u32, &[], &[]),
|
||||
);
|
||||
assert!(result.refresh_lease);
|
||||
let FileResponse::Data(response) = result.response else {
|
||||
panic!("data response expected");
|
||||
};
|
||||
chunk.copy_from_slice(&response.data[..chunk.len()]);
|
||||
}
|
||||
assert_eq!(crc32c(&bytes), metadata.checksum_crc32c);
|
||||
accepted(exchange(
|
||||
&mut service,
|
||||
provider,
|
||||
PEER,
|
||||
control(FileOperation::Close, bytes.len() as u32, &[], &[]),
|
||||
))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
fn read(provider: &mut impl FileProvider, path: &str) -> Vec<u8> {
|
||||
read_result(provider, path).unwrap()
|
||||
}
|
||||
fn write_to(provider: &mut Provider, path: &[u8], candidate: &Model) -> Result<(), FileStatus> {
|
||||
let base = read_result(provider, "/config/user.kcfg")
|
||||
.or_else(|_| read_result(provider, "/config/active.kcfg"))?;
|
||||
upload(provider, path, &edit(&base, candidate)).map(|_| ())
|
||||
}
|
||||
fn edit(base: &[u8], candidate: &Model) -> Vec<u8> {
|
||||
let source = decode_kcfg(base, &mut [0; kaptan_config::KCFG_FIELD_ID_SCRATCH_BYTES]).unwrap();
|
||||
let mut edited = vec![0; base.len()];
|
||||
encode_edited_kcfg(&source, false, &mut edited, |field| {
|
||||
candidate.field_value(field.id()).unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
edited
|
||||
}
|
||||
fn upload(provider: &mut Provider, path: &[u8], bytes: &[u8]) -> Result<Content, FileStatus> {
|
||||
let mut service = FileService::new(TARGET);
|
||||
accepted(exchange(
|
||||
&mut service,
|
||||
provider,
|
||||
PEER,
|
||||
control(FileOperation::OpenWrite, 0, path, bytes),
|
||||
))?;
|
||||
for (i, chunk) in bytes.chunks(DATA_BYTES).enumerate() {
|
||||
let mut data = [0; DATA_BYTES];
|
||||
data[..chunk.len()].copy_from_slice(chunk);
|
||||
let request = FileRequest::Data(DataRequest {
|
||||
target: TARGET,
|
||||
transfer_id: 9,
|
||||
position: (i * DATA_BYTES) as u32,
|
||||
data,
|
||||
});
|
||||
for id in [1, 2, 4] {
|
||||
let foreign = exchange(
|
||||
&mut service,
|
||||
provider,
|
||||
TransferPeer {
|
||||
ingress: IngressId(id),
|
||||
..PEER
|
||||
},
|
||||
request,
|
||||
);
|
||||
assert!(!foreign.refresh_lease);
|
||||
assert_eq!(accepted(foreign), Err(FileStatus::InvalidTransfer));
|
||||
}
|
||||
let result = exchange(&mut service, provider, PEER, request);
|
||||
accepted(result)?;
|
||||
assert_eq!(
|
||||
exchange(&mut service, provider, PEER, request),
|
||||
result,
|
||||
"retry must not append twice"
|
||||
);
|
||||
}
|
||||
let result = exchange(
|
||||
&mut service,
|
||||
provider,
|
||||
PEER,
|
||||
control(FileOperation::Close, bytes.len() as u32, &[], &[]),
|
||||
);
|
||||
if let FileResponse::Control(response) = result.response
|
||||
&& response.status == FileStatus::RequestConflict
|
||||
{
|
||||
let (_, text) = KcfgResultDetail::decode(response.content.as_bytes()).unwrap();
|
||||
assert!(
|
||||
text.contains("reload"),
|
||||
"conflict must carry a readable KCFG prefix"
|
||||
);
|
||||
}
|
||||
accepted(result).map(|response| response.content)
|
||||
}
|
||||
fn write(provider: &mut Provider, candidate: &Model) -> Result<(), FileStatus> {
|
||||
write_to(provider, b"/config/user.kcfg", candidate)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_actual_providers_export_only_their_declared_ports() {
|
||||
fn check<B: F7Board, const PORTS: usize, const BYTES: usize>() {
|
||||
let factory = UserConfigModel::<B::UserConfig, B::ImmutableConfig, PORTS>::new(
|
||||
B::FACTORY_USER_CONFIG,
|
||||
);
|
||||
let manager = recover_configuration(
|
||||
factory.clone(),
|
||||
B::IMMUTABLE_CONFIG,
|
||||
DualSlotJournal::<_, BYTES>::new(RamNor::default()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(manager.active().ports.iter().len(), PORTS);
|
||||
let mut provider = F722ConfigFile::<_, _, PORTS, BYTES, _>::new(manager);
|
||||
for path in ["/config/user.kcfg", "/config/active.kcfg"] {
|
||||
let bytes = read(&mut provider, path);
|
||||
let mut importer = KcfgImporter::new(&factory).unwrap();
|
||||
importer.push(&factory, &bytes).unwrap();
|
||||
let exported = importer.finish().unwrap().candidate;
|
||||
for port in [
|
||||
platform_stm32f7::UART1,
|
||||
platform_stm32f7::UART2,
|
||||
platform_stm32f7::UART3,
|
||||
platform_stm32f7::UART4,
|
||||
platform_stm32f7::UART6,
|
||||
] {
|
||||
let available = B::IMMUTABLE_CONFIG
|
||||
.f722()
|
||||
.hardware
|
||||
.serial()
|
||||
.index(port.id)
|
||||
.is_some();
|
||||
assert_eq!(exported.field_value(port.role_field).is_some(), available);
|
||||
assert_eq!(exported.field_value(port.baud_field).is_some(), available);
|
||||
}
|
||||
}
|
||||
}
|
||||
check::<kakute::KakuteF7MiniV3, PORT_COUNT, N>();
|
||||
check::<
|
||||
geprc::GeprcF722V2,
|
||||
{ geprc::GeprcF722V2::IMMUTABLE_CONFIG.hardware.serial().len() },
|
||||
{
|
||||
platform_stm32f7::config_snapshot_length::<<geprc::GeprcF722V2 as F7Board>::UserConfig>(
|
||||
)
|
||||
},
|
||||
>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_board_kcfg_provider_round_trips_and_active_is_read_only_restart_only() {
|
||||
let flash = RamNor::default();
|
||||
let mut provider = Provider::new(recover(&flash));
|
||||
let _initial = read(&mut provider, "/config/active.kcfg");
|
||||
let mut next = decode(&read(&mut provider, "/config/user.kcfg"));
|
||||
next.set_field(FieldId::new(201), ConfigValue::U8(2))
|
||||
.unwrap();
|
||||
next.set_field(FieldId::new(207), ConfigValue::U8(1))
|
||||
.unwrap();
|
||||
write(&mut provider, &next).unwrap();
|
||||
let active = decode(&read(&mut provider, "/config/active.kcfg"));
|
||||
let committed = decode(&read(&mut provider, "/config/user.kcfg"));
|
||||
assert_eq!(
|
||||
active.field_value(FieldId::new(201)),
|
||||
Some(ConfigValue::U8(0))
|
||||
);
|
||||
assert_eq!(
|
||||
active.field_value(FieldId::new(207)),
|
||||
Some(ConfigValue::U8(0))
|
||||
);
|
||||
assert_eq!(
|
||||
committed.field_value(FieldId::new(201)),
|
||||
Some(ConfigValue::U8(2))
|
||||
);
|
||||
assert_eq!(
|
||||
committed.field_value(FieldId::new(207)),
|
||||
Some(ConfigValue::U8(1))
|
||||
);
|
||||
assert_eq!(
|
||||
write_to(&mut provider, b"/config/active.kcfg", &next),
|
||||
Err(FileStatus::NotFound)
|
||||
);
|
||||
let reboot = recover(&flash);
|
||||
assert_eq!(
|
||||
reboot.active().ports.get(PortId::new(4)).unwrap().role,
|
||||
Role::Mavlink
|
||||
);
|
||||
assert_eq!(reboot.diagnostic(), BootDiagnostic::Healthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_readable_invalid_candidate_is_retained_for_usb_repair_without_enabling_uarts() {
|
||||
let flash = RamNor::default();
|
||||
let mut store = Store::new(flash.clone());
|
||||
store.persist(&factory()).unwrap();
|
||||
let mut invalid = factory();
|
||||
invalid
|
||||
.set_field(FieldId::new(201), ConfigValue::U8(2))
|
||||
.unwrap();
|
||||
invalid
|
||||
.set_field(FieldId::new(207), ConfigValue::U8(2))
|
||||
.unwrap();
|
||||
let error = invalid.validate(&Board::IMMUTABLE_CONFIG).err().unwrap();
|
||||
assert_eq!(error.primary(), Some(FieldId::new(207)));
|
||||
assert_eq!(error.related_field(), Some(FieldId::new(201)));
|
||||
store.persist(&invalid).unwrap();
|
||||
let manager = recover(&flash);
|
||||
assert!(matches!(
|
||||
manager.diagnostic(),
|
||||
BootDiagnostic::InvalidPersisted(_)
|
||||
));
|
||||
assert!(
|
||||
manager
|
||||
.active()
|
||||
.ports
|
||||
.iter()
|
||||
.all(|(_, p)| p.role == Role::Disabled)
|
||||
);
|
||||
assert_eq!(
|
||||
manager.committed().unwrap().field_value(FieldId::new(201)),
|
||||
Some(ConfigValue::U8(2))
|
||||
);
|
||||
let before = flash.0.borrow().operations;
|
||||
let mut provider = Provider::new(manager);
|
||||
assert_eq!(
|
||||
decode(&read(&mut provider, "/config/user.kcfg")).field_value(FieldId::new(201)),
|
||||
Some(ConfigValue::U8(2))
|
||||
);
|
||||
assert_eq!(
|
||||
flash.0.borrow().operations,
|
||||
before,
|
||||
"boot and inspection never rewrite the candidate"
|
||||
);
|
||||
assert_eq!(
|
||||
write(&mut provider, &invalid),
|
||||
Err(FileStatus::ContentRejected)
|
||||
);
|
||||
assert_eq!(flash.0.borrow().operations, before);
|
||||
let mut repaired = factory();
|
||||
repaired
|
||||
.set_field(FieldId::new(201), ConfigValue::U8(1))
|
||||
.unwrap();
|
||||
write(&mut provider, &repaired).unwrap();
|
||||
let active = decode(&read(&mut provider, "/config/active.kcfg"));
|
||||
for id in [201, 207] {
|
||||
assert_eq!(
|
||||
active.field_value(FieldId::new(id)),
|
||||
Some(ConfigValue::U8(0))
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
recover(&flash)
|
||||
.active()
|
||||
.ports
|
||||
.get(PortId::new(1))
|
||||
.unwrap()
|
||||
.role,
|
||||
Role::Mavlink
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_persist_keeps_active_and_invalidates_committed() {
|
||||
let probe = RamNor::default();
|
||||
let mut manager = recover(&probe);
|
||||
let mut next = factory();
|
||||
next.set_field(FieldId::new(201), ConfigValue::U8(1))
|
||||
.unwrap();
|
||||
manager.replace(next.clone()).unwrap();
|
||||
let operations = probe.0.borrow().operations;
|
||||
for fail_after in 0..operations {
|
||||
let flash = RamNor::default();
|
||||
let mut store = Store::new(flash.clone());
|
||||
store.persist(&factory()).unwrap();
|
||||
let mut manager = recover(&flash);
|
||||
{
|
||||
let mut state = flash.0.borrow_mut();
|
||||
state.operations = 0;
|
||||
state.fail_after = Some(fail_after);
|
||||
}
|
||||
assert!(matches!(
|
||||
manager.replace(next.clone()),
|
||||
Err(ReplaceError::Store(_))
|
||||
));
|
||||
assert!(manager.committed().is_none());
|
||||
assert_eq!(
|
||||
manager.active().ports.get(PortId::new(1)).unwrap().role,
|
||||
Role::Disabled
|
||||
);
|
||||
assert!(matches!(
|
||||
manager.replace(factory()),
|
||||
Err(ReplaceError::DurableStateUnknown)
|
||||
));
|
||||
let mut provider = Provider::new(manager);
|
||||
assert_eq!(
|
||||
read_result(&mut provider, "/config/user.kcfg"),
|
||||
Err(FileStatus::StorageFailure)
|
||||
);
|
||||
assert_eq!(
|
||||
decode(&read(&mut provider, "/config/active.kcfg")).field_value(FieldId::new(201)),
|
||||
Some(ConfigValue::U8(0))
|
||||
);
|
||||
flash.0.borrow_mut().fail_after = None;
|
||||
assert_eq!(
|
||||
recover(&flash)
|
||||
.active()
|
||||
.ports
|
||||
.get(PortId::new(1))
|
||||
.unwrap()
|
||||
.role,
|
||||
Role::Disabled
|
||||
);
|
||||
}
|
||||
let flash = RamNor::default();
|
||||
let mut manager = recover(&flash);
|
||||
flash.0.borrow_mut().fail_after_commit = true;
|
||||
assert!(matches!(manager.replace(next), Err(ReplaceError::Store(_))));
|
||||
assert!(manager.committed().is_none());
|
||||
assert_eq!(
|
||||
manager.active().ports.get(PortId::new(1)).unwrap().role,
|
||||
Role::Disabled
|
||||
);
|
||||
flash.0.borrow_mut().fail_after_commit = false;
|
||||
assert_eq!(
|
||||
recover(&flash)
|
||||
.active()
|
||||
.ports
|
||||
.get(PortId::new(1))
|
||||
.unwrap()
|
||||
.role,
|
||||
Role::Mavlink
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_provider_close_blocks_saves_but_keeps_active_export_available() {
|
||||
let flash = RamNor::default();
|
||||
let mut provider = Provider::new(recover(&flash));
|
||||
let mut next = factory();
|
||||
next.set_field(FieldId::new(201), ConfigValue::U8(1))
|
||||
.unwrap();
|
||||
flash.0.borrow_mut().fail_after = Some(2);
|
||||
assert_eq!(write(&mut provider, &next), Err(FileStatus::StorageFailure));
|
||||
flash.0.borrow_mut().fail_after = None;
|
||||
assert_eq!(
|
||||
write(&mut provider, &factory()),
|
||||
Err(FileStatus::StorageFailure)
|
||||
);
|
||||
assert_eq!(
|
||||
decode(&read(&mut provider, "/config/active.kcfg")).field_value(FieldId::new(201)),
|
||||
Some(ConfigValue::U8(0))
|
||||
);
|
||||
write_to(&mut provider, b"/config/reset.kcfg", &factory()).unwrap();
|
||||
write(&mut provider, &next).unwrap();
|
||||
assert_eq!(
|
||||
decode(&read(&mut provider, "/config/active.kcfg")).field_value(FieldId::new(201)),
|
||||
Some(ConfigValue::U8(0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_read_failure_is_explicit_and_cannot_activate_factory_uart_defaults() {
|
||||
let flash = RamNor::default();
|
||||
flash.0.borrow_mut().fail_reads = true;
|
||||
let mut manager = recover(&flash);
|
||||
assert_eq!(manager.diagnostic(), BootDiagnostic::StorageUnknown);
|
||||
assert!(
|
||||
manager
|
||||
.active()
|
||||
.ports
|
||||
.iter()
|
||||
.all(|(_, p)| p.role == Role::Disabled)
|
||||
);
|
||||
assert!(manager.committed().is_none());
|
||||
assert!(matches!(
|
||||
manager.replace(factory()),
|
||||
Err(ReplaceError::DurableStateUnknown)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupied_but_undecodable_storage_is_not_a_healthy_factory_boot() {
|
||||
let flash = RamNor::default();
|
||||
flash.0.borrow_mut().bytes[1][128] = 0;
|
||||
let manager = recover(&flash);
|
||||
assert_eq!(manager.diagnostic(), BootDiagnostic::StorageUnknown);
|
||||
assert!(manager.committed().is_none());
|
||||
assert!(
|
||||
manager
|
||||
.active()
|
||||
.ports
|
||||
.iter()
|
||||
.all(|(_, p)| p.role == Role::Disabled)
|
||||
);
|
||||
assert_eq!(flash.0.borrow().operations, 0);
|
||||
assert_eq!(flash.0.borrow().bytes[1][128], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_downloaded_drafts_conflict_before_noop_and_fresh_noop_skips_flash() {
|
||||
let flash = RamNor::default();
|
||||
let mut provider = Provider::new(recover(&flash));
|
||||
let a = read(&mut provider, "/config/user.kcfg");
|
||||
let b = read(&mut provider, "/config/user.kcfg");
|
||||
let mut next = decode(&b);
|
||||
next.set_field(FieldId::new(201), ConfigValue::U8(1))
|
||||
.unwrap();
|
||||
upload(&mut provider, b"/config/user.kcfg", &edit(&b, &next)).unwrap();
|
||||
let operations = flash.0.borrow().operations;
|
||||
for stale in [a.clone(), edit(&a, &next)] {
|
||||
assert_eq!(
|
||||
upload(&mut provider, b"/config/user.kcfg", &stale),
|
||||
Err(FileStatus::RequestConflict)
|
||||
);
|
||||
assert_eq!(flash.0.borrow().operations, operations);
|
||||
}
|
||||
let fresh = read(&mut provider, "/config/user.kcfg");
|
||||
let result = upload(&mut provider, b"/config/user.kcfg", &fresh).unwrap();
|
||||
assert!(
|
||||
KcfgResultDetail::decode(result.as_bytes())
|
||||
.unwrap()
|
||||
.0
|
||||
.restart_required()
|
||||
);
|
||||
assert_eq!(flash.0.borrow().operations, operations);
|
||||
assert_eq!(
|
||||
decode(&read(&mut provider, "/config/active.kcfg")).field_value(FieldId::new(201)),
|
||||
Some(ConfigValue::U8(0))
|
||||
);
|
||||
assert_eq!(
|
||||
recover(&flash)
|
||||
.active()
|
||||
.ports
|
||||
.get(PortId::new(1))
|
||||
.unwrap()
|
||||
.role,
|
||||
Role::Mavlink
|
||||
);
|
||||
}
|
||||
|
||||
// Use the codec to produce an incompatible snapshot without duplicating its layout.
|
||||
#[derive(Clone)]
|
||||
struct OldSchema(Model);
|
||||
impl ConfigModel for OldSchema {
|
||||
type Active = <Model as ConfigModel>::Active;
|
||||
type Context = I;
|
||||
const SCHEMA_ID: [u8; 16] = *b"OLD-BOARD-SCHEMA";
|
||||
fn descriptor(index: u16) -> Option<&'static kaptan_config::FieldDescriptor> {
|
||||
Model::descriptor(index)
|
||||
}
|
||||
fn field_count() -> u16 {
|
||||
Model::field_count()
|
||||
}
|
||||
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
|
||||
self.0.field_value(id)
|
||||
}
|
||||
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
|
||||
Model::active_field_value(active, id)
|
||||
}
|
||||
fn set_field(
|
||||
&mut self,
|
||||
id: FieldId,
|
||||
value: ConfigValue,
|
||||
) -> Result<(), kaptan_config::FieldWriteError> {
|
||||
self.0.set_field(id, value)
|
||||
}
|
||||
fn validate(&self, context: &I) -> Result<Self::Active, kaptan_config::ConfigViolation> {
|
||||
self.0.validate(context)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_schema_can_only_be_reset_explicitly_to_exact_factory_from_active_artifact() {
|
||||
let flash = RamNor::default();
|
||||
Store::new(flash.clone())
|
||||
.persist(&OldSchema(factory()))
|
||||
.unwrap();
|
||||
let before = flash.0.borrow().operations;
|
||||
assert!(matches!(
|
||||
ConfigManager::recover(
|
||||
factory(),
|
||||
Board::IMMUTABLE_CONFIG,
|
||||
Store::new(flash.clone())
|
||||
),
|
||||
Err(kaptan_config::StartupError::UnreadablePersisted)
|
||||
));
|
||||
let manager = recover(&flash);
|
||||
assert_eq!(manager.diagnostic(), BootDiagnostic::StorageUnknown);
|
||||
let mut provider = Provider::new(manager);
|
||||
assert_eq!(flash.0.borrow().operations, before);
|
||||
assert_eq!(
|
||||
read_result(&mut provider, "/config/user.kcfg"),
|
||||
Err(FileStatus::StorageFailure)
|
||||
);
|
||||
let active = read(&mut provider, "/config/active.kcfg");
|
||||
let mut nonfactory = factory();
|
||||
nonfactory
|
||||
.set_field(FieldId::new(201), ConfigValue::U8(1))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
upload(
|
||||
&mut provider,
|
||||
b"/config/reset.kcfg",
|
||||
&edit(&active, &nonfactory)
|
||||
),
|
||||
Err(FileStatus::ContentRejected)
|
||||
);
|
||||
assert_eq!(flash.0.borrow().operations, before);
|
||||
let reset = edit(&active, &factory());
|
||||
assert_eq!(
|
||||
upload(&mut provider, b"/config/user.kcfg", &reset),
|
||||
Err(FileStatus::StorageFailure)
|
||||
);
|
||||
let result = upload(&mut provider, b"/config/reset.kcfg", &reset).unwrap();
|
||||
assert!(
|
||||
!KcfgResultDetail::decode(result.as_bytes())
|
||||
.unwrap()
|
||||
.0
|
||||
.restart_required()
|
||||
);
|
||||
assert!(flash.0.borrow().operations > before);
|
||||
assert_eq!(
|
||||
artifact(&decode(&read(&mut provider, "/config/active.kcfg"))),
|
||||
artifact(&decode(&active))
|
||||
);
|
||||
assert_eq!(
|
||||
decode(&read(&mut provider, "/config/user.kcfg")).field_value(FieldId::new(201)),
|
||||
Some(ConfigValue::U8(0))
|
||||
);
|
||||
let reboot = recover(&flash);
|
||||
assert_eq!(reboot.diagnostic(), BootDiagnostic::Healthy);
|
||||
assert_eq!(
|
||||
reboot.active().ports.get(PortId::new(1)).unwrap().role,
|
||||
Role::Disabled
|
||||
);
|
||||
assert!(!reboot.restart_required());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_failures_remain_unknown_and_explicit_retry_can_recover_after_durable_marker() {
|
||||
for after_commit in [false, true] {
|
||||
let flash = RamNor::default();
|
||||
Store::new(flash.clone())
|
||||
.persist(&OldSchema(factory()))
|
||||
.unwrap();
|
||||
let mut provider = Provider::new(recover(&flash));
|
||||
let active = read(&mut provider, "/config/active.kcfg");
|
||||
let reset = edit(&active, &factory());
|
||||
{
|
||||
let mut state = flash.0.borrow_mut();
|
||||
state.fail_after_commit = after_commit;
|
||||
if !after_commit {
|
||||
state.fail_after = Some(state.operations + 2);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
upload(&mut provider, b"/config/reset.kcfg", &reset),
|
||||
Err(FileStatus::StorageFailure)
|
||||
);
|
||||
assert_eq!(
|
||||
read_result(&mut provider, "/config/user.kcfg"),
|
||||
Err(FileStatus::StorageFailure)
|
||||
);
|
||||
assert_eq!(read(&mut provider, "/config/active.kcfg"), active);
|
||||
{
|
||||
let mut state = flash.0.borrow_mut();
|
||||
state.fail_after_commit = false;
|
||||
state.fail_after = None;
|
||||
}
|
||||
assert_eq!(
|
||||
upload(&mut provider, b"/config/user.kcfg", &reset),
|
||||
Err(FileStatus::StorageFailure)
|
||||
);
|
||||
let result = upload(&mut provider, b"/config/reset.kcfg", &reset).unwrap();
|
||||
assert!(
|
||||
!KcfgResultDetail::decode(result.as_bytes())
|
||||
.unwrap()
|
||||
.0
|
||||
.restart_required()
|
||||
);
|
||||
assert_eq!(
|
||||
artifact(&decode(&read(&mut provider, "/config/active.kcfg"))),
|
||||
artifact(&decode(&active))
|
||||
);
|
||||
assert_eq!(
|
||||
recover(&flash)
|
||||
.active()
|
||||
.ports
|
||||
.get(PortId::new(1))
|
||||
.unwrap()
|
||||
.role,
|
||||
Role::Disabled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readable_journal_recovery_does_not_scan_entire_slots() {
|
||||
let flash = RamNor::default();
|
||||
Store::new(flash.clone()).persist(&factory()).unwrap();
|
||||
flash.0.borrow_mut().bytes_read = 0;
|
||||
assert_eq!(recover(&flash).diagnostic(), BootDiagnostic::Healthy);
|
||||
assert!(
|
||||
flash.0.borrow().bytes_read < 2048,
|
||||
"readable records should reuse journal recovery"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user