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 = ::UserConfig; type I = ::ImmutableConfig; const PORT_COUNT: usize = Board::IMMUTABLE_CONFIG.hardware.serial().len(); type Model = UserConfigModel; const N: usize = platform_stm32f7::config_snapshot_length::(); type Store = DualSlotJournal; type Manager = ConfigManager; type Provider = F722ConfigFile; struct FlashState { bytes: [[u8; 2048]; 2], operations: usize, bytes_read: usize, fail_after: Option, 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>); 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 { 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 { 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, 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 { 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 { 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 { 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() { let factory = UserConfigModel::::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::(); check::< geprc::GeprcF722V2, { geprc::GeprcF722V2::IMMUTABLE_CONFIG.hardware.serial().len() }, { platform_stm32f7::config_snapshot_length::<::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 = ::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 { self.0.field_value(id) } fn active_field_value(active: &Self::Active, id: FieldId) -> Option { 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.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" ); }