//! Board-agnostic configuration client over Kaptan's generic file service. use std::time::{Duration, Instant}; use kaptan_config::{ConfigValue, FieldId, KcfgResultDetail, ValueKind}; use kaptan_file_mavlink::{ Content, ControlRequest, ControlResponse, DATA_BYTES, DataRequest, DataResponse, FileOperation, FileRequest, FileResponse, FileStatus, Principal, TRANSFER_REPLAY_LEASE_SECONDS, Target, crc32c, wire::{decode_response, encode_request}, }; #[cfg(test)] use kaptan_mavlink::receiver::IntegrityMavlinkReceiver; use kaptan_mavlink::{MavlinkFrame, encoder::MavlinkEncoder, generated::kaptan::MavMessage}; const CONFIG_PATH: &[u8] = b"/config/user.kcfg"; const CHUNK_BYTES: usize = DATA_BYTES; const MAX_CONFIG_FILE_BYTES: usize = 1024 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_millis(500); // Closing a write may erase a 128-KiB STM32F7 sector. The controller cannot // service serial traffic during that erase, so this operation gets its own // bounded timeout. const CLOSE_WRITE_TIMEOUT: Duration = Duration::from_secs(5); const REQUEST_ATTEMPTS: u8 = 3; // The extra second keeps the retry beyond the controller's lease despite // independent host/controller clocks and service-loop scheduling. const OPEN_CONFLICT_LEASE_WAIT: Duration = Duration::from_secs(TRANSFER_REPLAY_LEASE_SECONDS as u64 + 1); #[cfg(test)] fn parse_test_message(frame: &MavlinkFrame) -> Option { let mut receiver = IntegrityMavlinkReceiver::<1>::new(); for byte in frame.raw_bytes() { receiver.receive_byte(*byte); } receiver.take_received()?.decode() } pub(crate) fn scalar_json(value: ConfigValue) -> serde_json::Value { match value { ConfigValue::Bool(value) => value.into(), ConfigValue::U8(value) => value.into(), ConfigValue::U16(value) => value.into(), ConfigValue::I32(value) => value.into(), ConfigValue::U32(value) => value.into(), // JSON numbers cannot preserve the full u64 domain in a browser. ConfigValue::U64(value) => value.to_string().into(), ConfigValue::F32(value) => serde_json::Value::from(value), ConfigValue::F64(value) => serde_json::Value::from(value), } } pub(crate) const fn scalar_less_than(left: ConfigValue, right: ConfigValue) -> bool { match (left, right) { (ConfigValue::I32(left), ConfigValue::I32(right)) => left < right, (ConfigValue::U8(left), ConfigValue::U8(right)) => left < right, (ConfigValue::U16(left), ConfigValue::U16(right)) => left < right, (ConfigValue::U32(left), ConfigValue::U32(right)) => left < right, (ConfigValue::U64(left), ConfigValue::U64(right)) => left < right, (ConfigValue::F32(left), ConfigValue::F32(right)) => left < right, (ConfigValue::F64(left), ConfigValue::F64(right)) => left < right, (ConfigValue::Bool(left), ConfigValue::Bool(right)) => !left && right, _ => false, } } #[derive(Clone)] pub(crate) struct RemoteEnumOption { pub(crate) value: u8, pub(crate) label: String, } #[derive(Clone)] pub(crate) struct RemoteField { pub(crate) id: FieldId, pub(crate) key: String, pub(crate) unit: String, pub(crate) kind: ValueKind, pub(crate) minimum: Option, pub(crate) maximum: Option, pub(crate) factory: ConfigValue, pub(crate) value: ConfigValue, pub(crate) options: Vec, } #[derive(Clone)] pub(crate) struct RemoteConfig { pub(crate) schema_id: [u8; 16], pub(crate) restart_required: bool, pub(crate) fields: Vec, } pub(crate) struct Edit { pub(crate) field_id: FieldId, pub(crate) value: ConfigValue, } pub(crate) enum ClientEvent { Configuration(RemoteConfig), Failure(String), RediscoveryRequired(String), DurableStateUnknown(String), } #[expect( clippy::large_enum_variant, reason = "the bounded host result keeps one encoded frame allocation-free" )] pub(crate) enum Outcome { Idle, Event(ClientEvent), Send(MavlinkFrame), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PendingKind { OpenRead { transfer_id: u16 }, Read { position: u32 }, CloseRead { position: u32 }, OpenWrite, Write { position: u32, next: u32 }, CloseWrite { position: u32 }, Abort { transfer_id: u16 }, } struct Pending { kind: PendingKind, raw: MavlinkFrame, sent_at: Instant, attempts_left: u8, } struct ReadTransfer { id: u16, size: u32, checksum: u32, bytes: Vec, } struct WriteTransfer { id: u16, bytes: Vec, position: u32, checksum: u32, } enum Transfer { Read(ReadTransfer), Write(WriteTransfer), } struct DownloadedConfig { document: Vec, config: RemoteConfig, } #[derive(Clone, Copy)] enum OpenConflictRecovery { Disabled, FreshId, LeaseDelay, Waiting(Instant), } /// Downloads and imports exactly one self-describing configuration artifact. /// /// The client contains no board IDs, field names, schema IDs, or field catalog. /// The sole known resource is the stable generic virtual path. pub(crate) struct ConfigClient { local: Principal, encoder: MavlinkEncoder, next_transfer_id: u16, target: Option, downloaded: Option, transfer: Option, pending: Option, abort_failure: Option, open_conflict_recovery: OpenConflictRecovery, } impl ConfigClient { pub(crate) const fn new(local: Principal) -> Self { Self { local, encoder: MavlinkEncoder::new(local.system(), local.component()), next_transfer_id: 1, target: None, downloaded: None, transfer: None, pending: None, abort_failure: None, open_conflict_recovery: OpenConflictRecovery::Disabled, } } pub(crate) fn reset(&mut self) { self.target = None; self.downloaded = None; self.transfer = None; self.pending = None; self.abort_failure = None; self.open_conflict_recovery = OpenConflictRecovery::Disabled; } pub(crate) fn discover(&mut self, target: Target) -> Result { self.reset(); self.target = Some(target); self.open_conflict_recovery = OpenConflictRecovery::FreshId; self.request_open_read() } fn request_open_read(&mut self) -> Result { let target = self.target()?; let transfer_id = self.transfer_id(); Ok(self.request_control( PendingKind::OpenRead { transfer_id }, ControlRequest { operation: FileOperation::OpenRead, target, position: 0, file_size: 0, checksum_crc32c: 0, transfer_id, content: Content::try_new(CONFIG_PATH).expect("static config path is bounded"), }, )) } pub(crate) fn save(&mut self, edits: Vec) -> Result { if edits.is_empty() { return Err("configuration has no changed fields".into()); } if self.pending.is_some() || self.transfer.is_some() { return Err("configuration file transfer is busy".into()); } let downloaded = self .downloaded .as_ref() .ok_or("configuration has not been downloaded")?; validate_edits(&downloaded.config, &edits)?; let bytes = encode_edited_document(&downloaded.document, &edits)?; let size = u32::try_from(bytes.len()) .map_err(|_| "configuration artifact exceeds the file protocol".to_owned())?; let checksum = crc32c(&bytes); let transfer_id = self.transfer_id(); let target = self.target()?; self.transfer = Some(Transfer::Write(WriteTransfer { id: transfer_id, bytes, position: 0, checksum, })); Ok(self.request_control( PendingKind::OpenWrite, ControlRequest { operation: FileOperation::OpenWrite, target, position: 0, file_size: size, checksum_crc32c: checksum, transfer_id, content: Content::try_new(CONFIG_PATH).expect("static config path is bounded"), }, )) } pub(crate) fn observe(&mut self, source: Target, message: &MavMessage) -> Outcome { let Some(target) = self.target else { return Outcome::Idle; }; if source != target { return Outcome::Idle; } match decode_response(message) { Ok(FileResponse::Control(control)) => self.observe_control(control), Ok(FileResponse::Data(data)) => self.observe_data(data), Err(_) => Outcome::Idle, } } #[cfg(test)] fn observe_frame(&mut self, frame: &MavlinkFrame) -> Outcome { let Some(message) = parse_test_message(frame) else { return Outcome::Idle; }; let Some(source) = Target::try_new(frame.system_id(), frame.component_id()) else { return Outcome::Idle; }; self.observe(source, &message) } pub(crate) fn retry_due(&mut self) -> Outcome { if let OpenConflictRecovery::Waiting(retry_at) = self.open_conflict_recovery { if Instant::now() < retry_at { return Outcome::Idle; } self.open_conflict_recovery = OpenConflictRecovery::Disabled; let request = self.request_open_read(); return self.follow_up(request); } let Some(pending) = self.pending.as_mut() else { return Outcome::Idle; }; if pending.sent_at.elapsed() < timeout(pending.kind) { return Outcome::Idle; } if pending.attempts_left != 0 { pending.attempts_left -= 1; pending.sent_at = Instant::now(); return Outcome::Send(pending.raw); } let kind = pending.kind; self.pending = None; let detail = format!( "configuration file {} timed out after bounded retries", operation_name(kind) ); if matches!(kind, PendingKind::CloseWrite { .. }) { return self.durable_state_unknown(format!( "{detail}; the durable state is unknown, so power-cycle the controller and reconnect before another save" )); } if matches!(kind, PendingKind::Abort { .. }) { let original = self.abort_failure.take().unwrap_or(detail); return self.rediscovery_required(format!( "{original}; the file transfer could not be released" )); } self.abort_after(detail) } fn observe_control(&mut self, data: ControlResponse) -> Outcome { if data.target != self.local_target() { return Outcome::Idle; } let Some(kind) = self.pending.as_ref().map(|pending| pending.kind) else { return Outcome::Idle; }; if !control_matches(kind, &data) { // A delayed duplicate may arrive after its retry was already // accepted. It must not disturb the current stop-and-wait request. return Outcome::Idle; } if !self.response_identity_matches(kind, data.transfer_id) { return Outcome::Idle; } self.pending = None; if matches!(kind, PendingKind::Abort { .. }) { let original = self .abort_failure .take() .unwrap_or_else(|| "configuration file transfer aborted".into()); self.transfer = None; return if data.status == FileStatus::Ok { self.fail(original) } else { self.rediscovery_required(format!( "{original}; controller rejected the transfer abort: {:?}", data.status )) }; } if matches!(kind, PendingKind::OpenRead { .. }) && data.status == FileStatus::RequestConflict { self.transfer = None; match self.open_conflict_recovery { OpenConflictRecovery::FreshId => { self.open_conflict_recovery = OpenConflictRecovery::LeaseDelay; let request = self.request_open_read(); return self.follow_up(request); } OpenConflictRecovery::LeaseDelay => { self.open_conflict_recovery = OpenConflictRecovery::Waiting(Instant::now() + OPEN_CONFLICT_LEASE_WAIT); return Outcome::Idle; } OpenConflictRecovery::Disabled | OpenConflictRecovery::Waiting(_) => {} } } if data.status != FileStatus::Ok { let detail = rejected_detail(&data); return match kind { PendingKind::OpenRead { .. } => self.rediscovery_required(detail), PendingKind::OpenWrite => self.fail(detail), PendingKind::CloseWrite { .. } if data.status == FileStatus::StorageFailure => self .durable_state_unknown(format!( "{detail}; power-cycle the controller and reconnect before another save" )), PendingKind::Read { .. } | PendingKind::Write { .. } | PendingKind::CloseRead { .. } | PendingKind::CloseWrite { .. } if data.status == FileStatus::InvalidTransfer => { self.rediscovery_required(detail) } PendingKind::Read { .. } | PendingKind::Write { .. } | PendingKind::CloseRead { .. } | PendingKind::CloseWrite { .. } if matches!( data.status, FileStatus::InvalidPosition | FileStatus::InvalidState | FileStatus::RequestConflict ) => { self.abort_after(detail) } PendingKind::Read { .. } | PendingKind::Write { .. } | PendingKind::CloseRead { .. } | PendingKind::CloseWrite { .. } => self.fail(detail), PendingKind::Abort { .. } => unreachable!("abort responses returned above"), }; } match kind { PendingKind::OpenRead { transfer_id } => { self.open_conflict_recovery = OpenConflictRecovery::Disabled; self.opened_read(data, transfer_id) } PendingKind::CloseRead { .. } => self.closed_read(data), PendingKind::OpenWrite => self.opened_write(data), PendingKind::Write { next, .. } => self.accepted_write(data, next), PendingKind::CloseWrite { .. } => self.closed_write(data), PendingKind::Abort { .. } => unreachable!("abort responses returned above"), PendingKind::Read { .. } => Outcome::Idle, } } fn observe_data(&mut self, data: DataResponse) -> Outcome { if data.target != self.local_target() { return Outcome::Idle; } let Some(Pending { kind: PendingKind::Read { position }, .. }) = self.pending.as_ref() else { return Outcome::Idle; }; let position = *position; let Some(Transfer::Read(read)) = self.transfer.as_mut() else { return self.fail("configuration read response has no open transfer"); }; if data.transfer_id != read.id || data.position != position { return Outcome::Idle; } let remaining = read.size.saturating_sub(position); let count = usize::try_from(remaining.min(CHUNK_BYTES as u32)) .expect("bounded file chunk length fits usize"); if count == 0 || read.bytes.len() != position as usize { return self.abort_after("controller returned an invalid file position".into()); } if data.data[count..].iter().any(|byte| *byte != 0) { return self.abort_after("controller returned a noncanonical file chunk".into()); } read.bytes.extend_from_slice(&data.data[..count]); self.pending = None; let next = position + count as u32; if next < read.size { let request = self.request_read(next); self.follow_up(request) } else if crc32c(&read.bytes) != read.checksum { self.abort_after("configuration file checksum did not match".into()) } else { let transfer_id = read.id; let document = std::mem::take(&mut read.bytes); let config = match decode_document(&document) { Ok(config) => config, Err(error) => return self.abort_after(error), }; self.downloaded = Some(DownloadedConfig { document, config }); let request = self.request_close(PendingKind::CloseRead { position: next }, transfer_id, next); self.follow_up(request) } } fn opened_read(&mut self, data: ControlResponse, requested_id: u16) -> Outcome { let Ok(size) = usize::try_from(data.file_size) else { return self.fail("controller returned an unsupported configuration file size"); }; if data.status != FileStatus::Ok || data.transfer_id != requested_id || data.position != 0 || size == 0 || size > MAX_CONFIG_FILE_BYTES { return self.abort_transfer_after( requested_id, "controller returned an invalid configuration file size".into(), ); } self.transfer = Some(Transfer::Read(ReadTransfer { id: data.transfer_id, size: data.file_size, checksum: data.checksum_crc32c, bytes: Vec::with_capacity(size), })); let request = self.request_read(0); self.follow_up(request) } fn closed_read(&mut self, data: ControlResponse) -> Outcome { let expected_position = match self.transfer.as_ref() { Some(Transfer::Read(read)) => read.size, _ => return self.fail("configuration export close has no transfer"), }; if data.status != FileStatus::Ok || data.position != expected_position { return self.fail("controller did not close the configuration export"); } self.transfer = None; let Some(config) = self .downloaded .as_ref() .map(|downloaded| downloaded.config.clone()) else { return self.fail("configuration artifact was not decoded"); }; Outcome::Event(ClientEvent::Configuration(config)) } fn opened_write(&mut self, data: ControlResponse) -> Outcome { let Some(Transfer::Write(write)) = self.transfer.as_mut() else { return self.fail("configuration write response has no pending artifact"); }; if data.status != FileStatus::Ok || data.transfer_id == 0 || data.transfer_id != write.id || data.position != 0 || usize::try_from(data.file_size).ok() != Some(write.bytes.len()) || data.checksum_crc32c != write.checksum { return self.abort_after("controller returned an invalid write transfer".into()); } let request = self.request_write(); self.follow_up(request) } fn accepted_write(&mut self, data: ControlResponse, next: u32) -> Outcome { let Some(Transfer::Write(write)) = self.transfer.as_mut() else { return self.fail("configuration write acknowledgement has no transfer"); }; if data.status != FileStatus::Ok || data.transfer_id != write.id || data.position != next || usize::try_from(data.file_size).ok() != Some(write.bytes.len()) || data.checksum_crc32c != write.checksum { return self.abort_after("controller returned an invalid write acknowledgement".into()); } write.position = next; if usize::try_from(next).ok() == Some(write.bytes.len()) { let transfer_id = write.id; let request = self.request_close( PendingKind::CloseWrite { position: next }, transfer_id, next, ); self.follow_up(request) } else { let request = self.request_write(); self.follow_up(request) } } fn closed_write(&mut self, data: ControlResponse) -> Outcome { let (expected_position, expected_checksum) = match self.transfer.as_ref() { Some(Transfer::Write(write)) => (write.bytes.len() as u32, write.checksum), _ => return self.rediscovery_required("completed save has no local artifact"), }; let Some((result, _)) = KcfgResultDetail::decode(data.content.as_bytes()) else { return self.rediscovery_required( "controller returned a malformed configuration import result", ); }; if data.status != FileStatus::Ok || data.position != expected_position || data.file_size != expected_position || data.checksum_crc32c != expected_checksum || result.primary().is_some() || result.related_field().is_some() || result.rule().is_some() { return self.rediscovery_required( "controller returned an invalid configuration import result", ); } let restart_required = result.restart_required(); let Some(Transfer::Write(write)) = self.transfer.take() else { return self.rediscovery_required("completed save has no local artifact"); }; let document = match encode_document_state(&write.bytes, restart_required) { Ok(document) => document, Err(error) => return self.rediscovery_required(error), }; let config = match decode_document(&document) { Ok(config) => config, Err(error) => return self.rediscovery_required(error), }; self.downloaded = Some(DownloadedConfig { document, config: config.clone(), }); Outcome::Event(ClientEvent::Configuration(config)) } fn request_read(&mut self, position: u32) -> Result { let id = match self.transfer.as_ref() { Some(Transfer::Read(read)) => read.id, _ => return Err("configuration read transfer is not open".into()), }; let target = self.target()?; Ok(self.request_control( PendingKind::Read { position }, ControlRequest { operation: FileOperation::Read, target, position, file_size: 0, checksum_crc32c: 0, transfer_id: id, content: Content::EMPTY, }, )) } fn request_write(&mut self) -> Result { let (id, position, data, next) = match self.transfer.as_ref() { Some(Transfer::Write(write)) => { let position = usize::try_from(write.position) .map_err(|_| "configuration write position is invalid")?; let end = (position + CHUNK_BYTES).min(write.bytes.len()); if position >= end { return Err("configuration write has no remaining bytes".into()); } ( write.id, write.position, &write.bytes[position..end], end as u32, ) } _ => return Err("configuration write transfer is not open".into()), }; let target = self.target()?; let mut bytes = [0_u8; CHUNK_BYTES]; bytes[..data.len()].copy_from_slice(data); let message = encode_request(FileRequest::Data(DataRequest { target, position, transfer_id: id, data: bytes, })); Ok(self.send(PendingKind::Write { position, next }, message)) } fn request_close( &mut self, kind: PendingKind, transfer_id: u16, position: u32, ) -> Result { let target = self.target()?; Ok(self.request_control( kind, ControlRequest { operation: FileOperation::Close, target, position, file_size: 0, checksum_crc32c: 0, transfer_id, content: Content::EMPTY, }, )) } fn request_control(&mut self, kind: PendingKind, request: ControlRequest) -> MavlinkFrame { let message = encode_request(FileRequest::Control(request)); self.send(kind, message) } fn abort_after(&mut self, detail: String) -> Outcome { let transfer_id = match self.transfer.as_ref() { Some(Transfer::Read(read)) => read.id, Some(Transfer::Write(write)) => write.id, None => 0, }; self.abort_transfer_after(transfer_id, detail) } fn abort_transfer_after(&mut self, transfer_id: u16, detail: String) -> Outcome { if transfer_id == 0 { return self.fail(detail); } self.abort_failure = Some(detail); let target = match self.target() { Ok(target) => target, Err(error) => return self.rediscovery_required(error), }; let request = self.request_control( PendingKind::Abort { transfer_id }, ControlRequest { operation: FileOperation::Abort, target, position: 0, file_size: 0, checksum_crc32c: 0, transfer_id, content: Content::EMPTY, }, ); Outcome::Send(request) } pub(crate) fn target(&self) -> Result { self.target .ok_or_else(|| "configuration target is unknown".into()) } fn response_identity_matches(&self, kind: PendingKind, transfer_id: u16) -> bool { let expected = match kind { PendingKind::OpenRead { transfer_id: expected, } => Some(expected), PendingKind::OpenWrite | PendingKind::Write { .. } | PendingKind::CloseWrite { .. } => { match self.transfer.as_ref() { Some(Transfer::Write(write)) => Some(write.id), _ => None, } } PendingKind::Read { .. } | PendingKind::CloseRead { .. } => { match self.transfer.as_ref() { Some(Transfer::Read(read)) => Some(read.id), _ => None, } } PendingKind::Abort { transfer_id: expected, } => Some(expected), }; expected == Some(transfer_id) } fn transfer_id(&mut self) -> u16 { let id = self.next_transfer_id; self.next_transfer_id = self.next_transfer_id.wrapping_add(1).max(1); id } fn send(&mut self, kind: PendingKind, message: MavMessage) -> MavlinkFrame { let raw = match message { MavMessage::FILE_CONTROL(data) => self.encoder.encode(&data), MavMessage::FILE_DATA(data) => self.encoder.encode(&data), _ => unreachable!("file request encoder returned another message"), }; self.pending = Some(Pending { kind, raw, sent_at: Instant::now(), attempts_left: REQUEST_ATTEMPTS - 1, }); raw } fn fail(&mut self, detail: impl Into) -> Outcome { self.pending = None; self.transfer = None; self.abort_failure = None; self.open_conflict_recovery = OpenConflictRecovery::Disabled; Outcome::Event(ClientEvent::Failure(detail.into())) } fn durable_state_unknown(&mut self, detail: impl Into) -> Outcome { let detail = detail.into(); self.downloaded = None; self.transfer = None; self.pending = None; self.abort_failure = None; self.open_conflict_recovery = OpenConflictRecovery::Disabled; Outcome::Event(ClientEvent::DurableStateUnknown(detail)) } const fn local_target(&self) -> Target { Target::new(self.local.system(), self.local.component()) } fn follow_up(&mut self, request: Result) -> Outcome { match request { Ok(request) => Outcome::Send(request), Err(error) => self.fail(error), } } fn rediscovery_required(&mut self, detail: impl Into) -> Outcome { let detail = detail.into(); self.reset(); Outcome::Event(ClientEvent::RediscoveryRequired(detail)) } } fn validate_edits(config: &RemoteConfig, edits: &[Edit]) -> Result<(), String> { for edit in edits { let Some(field) = config.fields.iter().find(|field| field.id == edit.field_id) else { return Err(format!("unknown field id {}", edit.field_id.get())); }; if edit.value.kind() != field.kind { return Err(format!( "field {} has the wrong value type", edit.field_id.get() )); } if !field.options.is_empty() && !matches!( edit.value, ConfigValue::U8(value) if field.options.iter().any(|option| option.value == value) ) { return Err(format!( "field {} has an unnamed enum value", edit.field_id.get() )); } } Ok(()) } fn timeout(kind: PendingKind) -> Duration { if matches!(kind, PendingKind::CloseWrite { .. }) { CLOSE_WRITE_TIMEOUT } else { REQUEST_TIMEOUT } } fn operation_name(kind: PendingKind) -> &'static str { match kind { PendingKind::OpenRead { .. } => "open-read", PendingKind::Read { .. } => "read", PendingKind::CloseRead { .. } => "close-read", PendingKind::OpenWrite => "open-write", PendingKind::Write { .. } => "write", PendingKind::CloseWrite { .. } => "close-write", PendingKind::Abort { .. } => "abort", } } fn control_matches(kind: PendingKind, data: &ControlResponse) -> bool { let successful = data.status == FileStatus::Ok; match kind { PendingKind::OpenRead { .. } => { data.operation == FileOperation::OpenRead && data.position == 0 } PendingKind::OpenWrite => data.operation == FileOperation::OpenWrite && data.position == 0, PendingKind::Read { position } => { data.operation == FileOperation::Read && !successful && data.position == position } PendingKind::Write { position, next } => { data.operation == FileOperation::WriteAck && data.position == if successful { next } else { position } } PendingKind::CloseRead { position } | PendingKind::CloseWrite { position } => { data.operation == FileOperation::Close && data.position == position } PendingKind::Abort { .. } => data.operation == FileOperation::Abort && data.position == 0, } } fn rejected_detail(data: &ControlResponse) -> String { if let Some((detail, text)) = KcfgResultDetail::decode(data.content.as_bytes()) { let mut identities = Vec::new(); if let Some(field) = detail.primary() { identities.push(format!("field {}", field.get())); } if let Some(field) = detail.related_field() { identities.push(format!("related field {}", field.get())); } if let Some(rule) = detail.rule() { identities.push(format!("rule {} ({rule:?})", rule.code())); } let suffix = if identities.is_empty() { String::new() } else { format!(" [{}]", identities.join(", ")) }; return if text.is_empty() { format!("configuration file rejected: {:?}{suffix}", data.status) } else { format!( "configuration file rejected: {:?}: {text}{suffix}", data.status ) }; } let context = std::str::from_utf8(data.content.as_bytes()) .ok() .filter(|text| !text.is_empty()); match context { Some(context) => format!("configuration file rejected: {:?}: {context}", data.status), None => format!("configuration file rejected: {:?}", data.status), } } // KCFG owns the schema and value representation. These two adapters are the // only place where the host configurator depends on that artifact codec. fn decode_document(bytes: &[u8]) -> Result { kaptan_config::decode_kcfg(bytes) .map_err(|error| format!("invalid KCFG artifact: {error:?}")) .and_then(remote_config) } fn remote_config(artifact: kaptan_config::KcfgArtifact<'_>) -> Result { let mut fields = Vec::with_capacity(usize::from(artifact.field_count())); for field in artifact.fields() { let mut options = Vec::with_capacity(field.enum_options().len()); for option in field.enum_options() { options.push(RemoteEnumOption { value: option.value(), label: option.label().into(), }); } let field = RemoteField { id: field.id(), key: field.key().into(), unit: field.unit().into(), kind: field.kind(), minimum: field.minimum(), maximum: field.maximum(), factory: field.factory_value(), value: field.value(), options, }; if fields .iter() .any(|previous: &RemoteField| previous.key == field.key) { return Err("KCFG artifact contains a duplicate display key".into()); } validate_remote_field(&field)?; fields.push(field); } Ok(RemoteConfig { schema_id: artifact.schema_id(), restart_required: artifact.restart_required(), fields, }) } fn validate_remote_field(field: &RemoteField) -> Result<(), String> { let values = [ Some(field.value), Some(field.factory), field.minimum, field.maximum, ]; if values.into_iter().flatten().any(non_finite) { return Err(format!( "KCFG field {} contains a non-finite value", field.id.get() )); } if field.minimum.is_some_and(|minimum| { field .maximum .is_some_and(|maximum| scalar_less_than(maximum, minimum)) }) || field .minimum .is_some_and(|minimum| scalar_less_than(field.value, minimum)) || field .maximum .is_some_and(|maximum| scalar_less_than(maximum, field.value)) || field .minimum .is_some_and(|minimum| scalar_less_than(field.factory, minimum)) || field .maximum .is_some_and(|maximum| scalar_less_than(maximum, field.factory)) { return Err(format!( "KCFG field {} has inconsistent bounds", field.id.get() )); } if !field.options.is_empty() && ![field.value, field.factory].into_iter().all(|value| { matches!( value, ConfigValue::U8(value) if field.options.iter().any(|option| option.value == value) ) }) { return Err(format!( "KCFG field {} contains an unnamed enum value", field.id.get() )); } Ok(()) } const fn non_finite(value: ConfigValue) -> bool { match value { ConfigValue::F32(value) => !value.is_finite(), ConfigValue::F64(value) => !value.is_finite(), ConfigValue::Bool(_) | ConfigValue::U8(_) | ConfigValue::U16(_) | ConfigValue::I32(_) | ConfigValue::U32(_) | ConfigValue::U64(_) => false, } } fn encode_edited_document(document: &[u8], edits: &[Edit]) -> Result, String> { let artifact = kaptan_config::decode_kcfg(document) .map_err(|error| format!("cannot edit KCFG artifact: {error:?}"))?; let mut output = vec![0_u8; document.len()]; let encoded = kaptan_config::encode_edited_kcfg(&artifact, true, &mut output, |field| { edits .iter() .find(|edit| edit.field_id == field.id()) .map_or_else(|| field.value(), |edit| edit.value) }) .map_err(|error| format!("cannot encode KCFG artifact: {error:?}"))?; let length = encoded.len(); output.truncate(length); Ok(output) } fn encode_document_state(document: &[u8], restart_required: bool) -> Result, String> { let artifact = kaptan_config::decode_kcfg(document) .map_err(|error| format!("cannot update KCFG artifact state: {error:?}"))?; let mut output = vec![0_u8; document.len()]; let encoded = kaptan_config::encode_edited_kcfg(&artifact, restart_required, &mut output, |field| { field.value() }) .map_err(|error| format!("cannot encode KCFG artifact state: {error:?}"))?; let length = encoded.len(); output.truncate(length); Ok(output) } #[cfg(test)] mod tests { use super::*; use kaptan_file_mavlink::{ ControlResponse, DataResponse, FileRequest, FileResponse, FileStatus, wire::{decode_request, encode_response}, }; const CONTROLLER: Target = Target::new(42, 7); const CLIENT: Principal = Principal::new(255, 190); const FIELD_ID: FieldId = FieldId::new(401); fn artifact(current: u8) -> Vec { let key = b"telemetry.heartbeat_hz"; let unit = b"hz"; let mut bytes = Vec::new(); bytes.extend_from_slice(b"KCFG"); bytes.push(1); // KCFG version 1. bytes.push(0); bytes.extend_from_slice(b"UNKNOWN-SCHEMA01"); bytes.extend_from_slice(&1_u16.to_le_bytes()); bytes.extend_from_slice(&FIELD_ID.get().to_le_bytes()); bytes.push(ValueKind::U8 as u8); bytes.push(3); bytes.push(key.len() as u8); bytes.push(unit.len() as u8); bytes.push(0); bytes.extend_from_slice(&[current, 2, 1, 100]); bytes.extend_from_slice(key); bytes.extend_from_slice(unit); let checksum = crc32c(&bytes); bytes.extend_from_slice(&checksum.to_be_bytes()); bytes } fn response(response: FileResponse) -> MavlinkFrame { let mut encoder = MavlinkEncoder::new(CONTROLLER.system(), CONTROLLER.component()); match encode_response(response) { MavMessage::FILE_CONTROL(data) => encoder.encode(&data), MavMessage::FILE_DATA(data) => encoder.encode(&data), _ => unreachable!("file response encoder returned another message"), } } fn request(raw: &MavlinkFrame) -> FileRequest { let message = parse_test_message(raw).unwrap(); decode_request(&message).unwrap() } fn sent(outcome: Outcome) -> MavlinkFrame { let Outcome::Send(frame) = outcome else { panic!("expected a follow-up frame"); }; frame } fn event(outcome: Outcome) -> ClientEvent { let Outcome::Event(event) = outcome else { panic!("expected a client event"); }; event } fn control_response( operation: FileOperation, transfer_id: u16, position: u32, file_size: u32, checksum: u32, status: FileStatus, ) -> MavlinkFrame { control_response_with_content( operation, transfer_id, position, file_size, checksum, status, Content::EMPTY, ) } fn control_response_with_content( operation: FileOperation, transfer_id: u16, position: u32, file_size: u32, checksum: u32, status: FileStatus, content: Content, ) -> MavlinkFrame { response(FileResponse::Control(ControlResponse { operation, target: Target::new(CLIENT.system(), CLIENT.component()), position, file_size, checksum_crc32c: checksum, transfer_id, status, content, })) } fn discover(client: &mut ConfigClient, document: &[u8]) -> RemoteConfig { let open = client.discover(CONTROLLER).unwrap(); let FileRequest::Control(open) = request(&open) else { panic!("open-read must be a control request"); }; assert_eq!(open.operation, FileOperation::OpenRead); assert_eq!(open.content.as_bytes(), CONFIG_PATH); assert_ne!(open.transfer_id, 0); let size = document.len() as u32; let checksum = crc32c(document); let read = sent(client.observe_frame(&control_response( FileOperation::OpenRead, open.transfer_id, 0, size, checksum, FileStatus::Ok, ))); let FileRequest::Control(read) = request(&read) else { panic!("read must be a control request"); }; assert_eq!(read.operation, FileOperation::Read); assert_eq!(read.position, 0); let mut data = [0_u8; CHUNK_BYTES]; data[..document.len()].copy_from_slice(document); let close = sent( client.observe_frame(&response(FileResponse::Data(DataResponse { target: Target::new(CLIENT.system(), CLIENT.component()), position: 0, transfer_id: open.transfer_id, data, }))), ); let FileRequest::Control(close) = request(&close) else { panic!("close-read must be a control request"); }; assert_eq!(close.operation, FileOperation::Close); assert_eq!(close.position, size); let event = event(client.observe_frame(&control_response( FileOperation::Close, open.transfer_id, size, size, checksum, FileStatus::Ok, ))); let ClientEvent::Configuration(config) = event else { panic!("closed export must publish configuration"); }; config } #[test] fn transfer_crc_is_crc32c() { assert_eq!(crc32c(b"123456789"), 0xe306_9283); } #[test] fn configurator_knows_only_the_canonical_config_path() { assert_eq!(CONFIG_PATH, b"/config/user.kcfg"); } #[test] fn file_data_uses_the_full_declared_chunk() { assert_eq!(CHUNK_BYTES, 246); } #[test] fn exact_local_endpoints_isolate_clients_with_the_same_transfer_id() { let left_endpoint = Principal::new(250, 190); let right_endpoint = Principal::new(251, 190); let mut left = ConfigClient::new(left_endpoint); let mut right = ConfigClient::new(right_endpoint); let left_open = left.discover(CONTROLLER).unwrap(); let right_open = right.discover(CONTROLLER).unwrap(); assert_eq!(left_open.system_id(), left_endpoint.system()); assert_eq!(right_open.system_id(), right_endpoint.system()); let FileRequest::Control(left_request) = request(&left_open) else { panic!("left discovery must open a file"); }; let FileRequest::Control(right_request) = request(&right_open) else { panic!("right discovery must open a file"); }; assert_eq!(left_request.transfer_id, right_request.transfer_id); let document = artifact(20); let reply = response(FileResponse::Control(ControlResponse { operation: FileOperation::OpenRead, target: Target::new(left_endpoint.system(), left_endpoint.component()), position: 0, file_size: document.len() as u32, checksum_crc32c: crc32c(&document), transfer_id: left_request.transfer_id, status: FileStatus::Ok, content: Content::EMPTY, })); assert!(matches!(left.observe_frame(&reply), Outcome::Send(_))); assert!(matches!(right.observe_frame(&reply), Outcome::Idle)); assert!(matches!( right.pending.as_ref().map(|pending| pending.kind), Some(PendingKind::OpenRead { .. }) )); } #[test] fn discovery_rejection_resets_for_a_later_heartbeat() { let mut client = ConfigClient::new(CLIENT); let open = client.discover(CONTROLLER).unwrap(); let FileRequest::Control(open) = request(&open) else { panic!("discovery must open a file"); }; let event = event(client.observe_frame(&control_response( FileOperation::OpenRead, open.transfer_id, 0, 0, 0, FileStatus::Busy, ))); assert!(matches!(event, ClientEvent::RediscoveryRequired(_))); assert_eq!(client.target, None); assert!(client.pending.is_none()); } #[test] fn stateful_rejection_and_noncanonical_data_emit_abort() { let document = artifact(20); for noncanonical_data in [false, true] { let mut client = ConfigClient::new(CLIENT); let open = client.discover(CONTROLLER).unwrap(); let FileRequest::Control(open) = request(&open) else { panic!("discovery must open a file"); }; let read = sent(client.observe_frame(&control_response( FileOperation::OpenRead, open.transfer_id, 0, document.len() as u32, crc32c(&document), FileStatus::Ok, ))); assert!(matches!(request(&read), FileRequest::Control(_))); let abort = sent(if noncanonical_data { let mut data = [0_u8; CHUNK_BYTES]; data[..document.len()].copy_from_slice(&document); data[document.len()] = 1; client.observe_frame(&response(FileResponse::Data(DataResponse { target: Target::new(CLIENT.system(), CLIENT.component()), position: 0, transfer_id: open.transfer_id, data, }))) } else { client.observe_frame(&control_response( FileOperation::Read, open.transfer_id, 0, document.len() as u32, crc32c(&document), FileStatus::InvalidPosition, )) }); let FileRequest::Control(abort) = request(&abort) else { panic!("rejected active transfer must abort"); }; assert_eq!(abort.operation, FileOperation::Abort); assert_eq!(abort.transfer_id, open.transfer_id); } } #[test] fn delayed_write_ack_does_not_consume_the_next_pending_chunk() { let response = |position, status| ControlResponse { operation: FileOperation::WriteAck, target: Target::new(CLIENT.system(), CLIENT.component()), position, file_size: 600, checksum_crc32c: 0, transfer_id: 9, status, content: Content::EMPTY, }; let pending = PendingKind::Write { position: CHUNK_BYTES as u32, next: 2 * CHUNK_BYTES as u32, }; assert!(!control_matches( pending, &response(CHUNK_BYTES as u32, FileStatus::Ok) )); assert!(control_matches( pending, &response(2 * CHUNK_BYTES as u32, FileStatus::Ok) )); assert!(control_matches( pending, &response(CHUNK_BYTES as u32, FileStatus::InvalidPosition) )); } #[test] fn one_file_export_discovers_an_unknown_schema_without_a_catalog() { let mut client = ConfigClient::new(CLIENT); let config = discover(&mut client, &artifact(20)); assert_eq!(config.schema_id, *b"UNKNOWN-SCHEMA01"); assert!(!config.restart_required); assert_eq!(config.fields.len(), 1); assert_eq!(config.fields[0].id, FIELD_ID); assert_eq!(config.fields[0].key, "telemetry.heartbeat_hz"); assert_eq!(config.fields[0].value, ConfigValue::U8(20)); assert_eq!(config.fields[0].factory, ConfigValue::U8(2)); assert_eq!(config.fields[0].minimum, Some(ConfigValue::U8(1))); assert_eq!(config.fields[0].maximum, Some(ConfigValue::U8(100))); } #[test] fn reconnect_retries_once_then_waits_quietly_past_the_replay_lease() { let mut client = ConfigClient::new(CLIENT); let first = client.discover(CONTROLLER).unwrap(); let FileRequest::Control(first) = request(&first) else { panic!("discovery must open the configuration file"); }; let retry = sent(client.observe_frame(&control_response( FileOperation::OpenRead, first.transfer_id, 0, 0, 0, FileStatus::RequestConflict, ))); let FileRequest::Control(retry) = request(&retry) else { panic!("conflict recovery must reopen the configuration file"); }; assert_eq!(retry.operation, FileOperation::OpenRead); assert_ne!(retry.transfer_id, first.transfer_id); assert!(matches!( client.observe_frame(&control_response( FileOperation::OpenRead, retry.transfer_id, 0, 0, 0, FileStatus::RequestConflict, )), Outcome::Idle )); let OpenConflictRecovery::Waiting(retry_at) = client.open_conflict_recovery else { panic!("the second conflict must wait for the controller lease"); }; assert!( retry_at.duration_since(Instant::now()) > Duration::from_secs(u64::from(TRANSFER_REPLAY_LEASE_SECONDS)) ); let next_transfer_id = client.next_transfer_id; for _ in 0..3 { assert!(matches!(client.retry_due(), Outcome::Idle)); assert_eq!(client.next_transfer_id, next_transfer_id); } client.open_conflict_recovery = OpenConflictRecovery::Waiting(Instant::now() - REQUEST_TIMEOUT); let delayed = sent(client.retry_due()); let FileRequest::Control(delayed) = request(&delayed) else { panic!("lease expiry must reopen the configuration file"); }; assert_eq!(delayed.operation, FileOperation::OpenRead); assert_ne!(delayed.transfer_id, first.transfer_id); assert_ne!(delayed.transfer_id, retry.transfer_id); let document = artifact(20); let read = sent(client.observe_frame(&control_response( FileOperation::OpenRead, delayed.transfer_id, 0, document.len() as u32, crc32c(&document), FileStatus::Ok, ))); let FileRequest::Control(read) = request(&read) else { panic!("the reopened file must advance to its first read"); }; assert_eq!(read.operation, FileOperation::Read); assert_eq!(read.transfer_id, delayed.transfer_id); } #[test] fn save_uploads_one_complete_edited_artifact_and_closes_it_once() { let mut client = ConfigClient::new(CLIENT); let original = artifact(20); discover(&mut client, &original); let open = client .save(vec![Edit { field_id: FIELD_ID, value: ConfigValue::U8(30), }]) .unwrap(); let FileRequest::Control(open) = request(&open) else { panic!("open-write must be a control request"); }; assert_eq!(open.operation, FileOperation::OpenWrite); assert_eq!(open.content.as_bytes(), CONFIG_PATH); assert_eq!(open.file_size as usize, original.len()); let data = sent(client.observe_frame(&control_response( FileOperation::OpenWrite, open.transfer_id, 0, open.file_size, open.checksum_crc32c, FileStatus::Ok, ))); let FileRequest::Data(data) = request(&data) else { panic!("save body must be file data"); }; let written = &data.data[..open.file_size as usize]; assert_eq!(crc32c(written), open.checksum_crc32c); let edited = kaptan_config::decode_kcfg(written).unwrap(); assert!(edited.restart_required()); assert_eq!(edited.fields().next().unwrap().value(), ConfigValue::U8(30)); let close = sent(client.observe_frame(&control_response( FileOperation::WriteAck, open.transfer_id, open.file_size, open.file_size, open.checksum_crc32c, FileStatus::Ok, ))); let FileRequest::Control(close) = request(&close) else { panic!("complete upload must close"); }; assert_eq!(close.operation, FileOperation::Close); assert_eq!(close.position, open.file_size); let event = event(client.observe_frame(&control_response_with_content( FileOperation::Close, open.transfer_id, open.file_size, open.file_size, open.checksum_crc32c, FileStatus::Ok, Content::try_new(&KcfgResultDetail::success(true).encode()).unwrap(), ))); let ClientEvent::Configuration(config) = event else { panic!("completed import must publish configuration"); }; assert!(config.restart_required); assert_eq!(config.fields[0].value, ConfigValue::U8(30)); } #[test] fn save_can_restore_the_boot_active_values_without_forcing_restart_state() { let document = encode_edited_document( &artifact(20), &[Edit { field_id: FIELD_ID, value: ConfigValue::U8(2), }], ) .unwrap(); let mut client = ConfigClient::new(CLIENT); client.transfer = Some(Transfer::Write(WriteTransfer { id: 9, position: document.len() as u32, bytes: document.clone(), checksum: crc32c(&document), })); let event = event(client.closed_write(ControlResponse { operation: FileOperation::Close, target: Target::new(CLIENT.system(), CLIENT.component()), position: document.len() as u32, file_size: document.len() as u32, checksum_crc32c: crc32c(&document), transfer_id: 9, status: FileStatus::Ok, content: Content::try_new(&KcfgResultDetail::success(false).encode()).unwrap(), })); let ClientEvent::Configuration(config) = event else { panic!("restored values must publish configuration"); }; assert!(!config.restart_required); assert!( !kaptan_config::decode_kcfg(&client.downloaded.as_ref().unwrap().document) .unwrap() .restart_required() ); } fn client_awaiting_close() -> (ConfigClient, Vec) { let document = encode_edited_document( &artifact(20), &[Edit { field_id: FIELD_ID, value: ConfigValue::U8(30), }], ) .unwrap(); let mut client = ConfigClient::new(CLIENT); client.target = Some(CONTROLLER); client.transfer = Some(Transfer::Write(WriteTransfer { id: 9, position: document.len() as u32, checksum: crc32c(&document), bytes: document.clone(), })); client .request_close( PendingKind::CloseWrite { position: document.len() as u32, }, 9, document.len() as u32, ) .unwrap(); (client, document) } #[test] fn malformed_success_and_storage_uncertainty_never_publish_configuration() { let (mut malformed, document) = client_awaiting_close(); let malformed_event = event(malformed.observe_frame(&control_response( FileOperation::Close, 9, document.len() as u32, document.len() as u32, crc32c(&document), FileStatus::Ok, ))); assert!(matches!( malformed_event, ClientEvent::RediscoveryRequired(_) )); let (mut uncertain, document) = client_awaiting_close(); let uncertain_event = event(uncertain.observe_frame(&control_response( FileOperation::Close, 9, document.len() as u32, document.len() as u32, crc32c(&document), FileStatus::StorageFailure, ))); assert!(matches!( uncertain_event, ClientEvent::DurableStateUnknown(_) )); } #[test] fn exhausted_close_retries_leave_durable_state_unknown() { let (mut client, _) = client_awaiting_close(); let pending = client.pending.as_mut().unwrap(); pending.attempts_left = 0; pending.sent_at = Instant::now() - CLOSE_WRITE_TIMEOUT; let event = event(client.retry_due()); assert!(matches!(event, ClientEvent::DurableStateUnknown(_))); assert!(client.pending.is_none()); assert!(client.transfer.is_none()); } #[test] fn typed_kcfg_rejection_retains_field_and_rule_identity() { let violation = kaptan_config::ConfigViolation::related( kaptan_config::FieldId::new(401), kaptan_config::FieldId::new(402), kaptan_config::ValidationRule::Inconsistent, ); let prefix = KcfgResultDetail::violation(violation).encode(); let detail = rejected_detail(&ControlResponse { operation: FileOperation::Close, target: Target::new(CLIENT.system(), CLIENT.component()), position: 0, file_size: 0, checksum_crc32c: 0, transfer_id: 9, status: FileStatus::ContentRejected, content: Content::try_new(&prefix).unwrap(), }); assert!(detail.contains("field 401")); assert!(detail.contains("related field 402")); assert!(detail.contains("rule 5 (Inconsistent)")); } }