Files
Onur Özkan f9a61a086f 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.
2026-11-07 18:12:11 +03:00

633 lines
20 KiB
Rust

use kaptan_file_mavlink::{
Content, ControlRequest, ControlResponse, DATA_BYTES, DataRequest, DispatchResult, FileFailure,
FileMetadata, FileOperation, FilePath, FileProvider, FileRequest, FileResponse, FileService,
FileStatus, IngressId, Principal, Target, TransferPeer, crc32c, wire,
};
const TARGET: Target = Target::new(1, 1);
const UART: TransferPeer = TransferPeer {
principal: Principal::new(42, 7),
ingress: IngressId(0),
};
const USB: TransferPeer = TransferPeer {
ingress: IngressId(1),
..UART
};
const ID: u16 = 9;
#[derive(Default)]
struct Provider {
bytes: Vec<u8>,
opens: usize,
reads: usize,
writes: usize,
closes: usize,
aborts: usize,
fail_open: bool,
fail_read: bool,
fail_write: bool,
fail_close: bool,
}
fn failure(status: FileStatus) -> FileFailure {
FileFailure::new(status, Content::try_new(b"provider detail").unwrap()).unwrap()
}
impl FileProvider for Provider {
fn open_read(&mut self, _: &FilePath) -> Result<FileMetadata, FileFailure> {
self.opens += 1;
if self.fail_open {
return Err(failure(FileStatus::NotFound));
}
Ok(FileMetadata {
size: self.bytes.len() as u32,
checksum_crc32c: crc32c(&self.bytes),
})
}
fn open_write(&mut self, path: &FilePath, _: FileMetadata) -> Result<(), FileFailure> {
self.open_read(path)?;
self.bytes.clear();
Ok(())
}
fn read(&mut self, position: u32, output: &mut [u8]) -> Result<(), FileFailure> {
self.reads += 1;
if self.fail_read {
return Err(failure(FileStatus::InvalidPosition));
}
let start = position as usize;
output.copy_from_slice(&self.bytes[start..start + output.len()]);
Ok(())
}
fn write(&mut self, position: u32, data: &[u8]) -> Result<(), FileFailure> {
self.writes += 1;
if self.fail_write {
return Err(failure(FileStatus::InvalidTransfer));
}
assert_eq!(position as usize, self.bytes.len());
self.bytes.extend_from_slice(data);
Ok(())
}
fn close(&mut self) -> Result<Content, FileFailure> {
self.closes += 1;
if self.fail_close {
return Err(failure(FileStatus::StorageFailure));
}
Ok(Content::try_new(b"committed").unwrap())
}
fn abort(&mut self) {
self.aborts += 1;
}
}
fn control(operation: FileOperation, position: u32) -> FileRequest {
FileRequest::Control(ControlRequest {
operation,
target: TARGET,
position,
file_size: 0,
checksum_crc32c: 0,
transfer_id: ID,
content: Content::EMPTY,
})
}
fn alter(request: FileRequest, change: impl FnOnce(&mut ControlRequest)) -> FileRequest {
let FileRequest::Control(mut request) = request else {
panic!("expected control request");
};
change(&mut request);
FileRequest::Control(request)
}
fn open(operation: FileOperation, bytes: &[u8]) -> FileRequest {
alter(control(operation, 0), |request| {
request.content = Content::try_new(b"/test/data.bin").unwrap();
if operation == FileOperation::OpenWrite {
request.file_size = bytes.len() as u32;
request.checksum_crc32c = crc32c(bytes);
}
})
}
fn data(position: u32, bytes: &[u8]) -> FileRequest {
let mut data = [0; DATA_BYTES];
data[..bytes.len()].copy_from_slice(bytes);
FileRequest::Data(DataRequest {
target: TARGET,
transfer_id: ID,
position,
data,
})
}
// Exercise the real message codecs while keeping ingress entirely out of band.
fn exchange(
service: &mut FileService,
provider: &mut Provider,
peer: TransferPeer,
request: FileRequest,
) -> DispatchResult {
let decoded = wire::decode_request(&wire::encode_request(request)).unwrap();
let result = service.dispatch(provider, peer, decoded).unwrap();
assert_eq!(
wire::decode_response(&wire::encode_response(result.response)),
Ok(result.response)
);
let target = match result.response {
FileResponse::Control(response) => response.target,
FileResponse::Data(response) => response.target,
};
assert_eq!(
target,
Target::new(peer.principal.system(), peer.principal.component())
);
result
}
fn expect_control(result: DispatchResult, status: FileStatus, refresh: bool) -> ControlResponse {
assert_eq!(result.refresh_lease, refresh);
let FileResponse::Control(response) = result.response else {
panic!("expected control response");
};
assert_eq!(response.status, status);
response
}
#[test]
fn equal_wire_identities_cannot_modify_or_replay_another_ingress_upload() {
for (owner, other_ingress) in [(UART, USB), (USB, UART)] {
let mut service = FileService::new(TARGET);
let mut provider = Provider::default();
let start = open(FileOperation::OpenWrite, b"abc");
let chunk = data(0, b"abc");
let close = control(FileOperation::Close, 3);
let opened = exchange(&mut service, &mut provider, owner, start);
expect_control(opened, FileStatus::Ok, true);
assert_eq!(exchange(&mut service, &mut provider, owner, start), opened);
assert_eq!(provider.opens, 1);
// Check both components of ownership, including identical transfer IDs.
for foreign in [
other_ingress,
TransferPeer {
principal: Principal::new(43, 7),
..owner
},
] {
for (request, status) in [
(start, FileStatus::Busy),
(alter(start, |r| r.transfer_id += 1), FileStatus::Busy),
(chunk, FileStatus::InvalidTransfer),
(control(FileOperation::Read, 0), FileStatus::InvalidTransfer),
(
control(FileOperation::Abort, 0),
FileStatus::InvalidTransfer,
),
(close, FileStatus::InvalidTransfer),
(
control(FileOperation::WriteAck, 0),
FileStatus::InvalidState,
),
] {
expect_control(
exchange(&mut service, &mut provider, foreign, request),
status,
false,
);
}
}
assert_eq!(
(
provider.opens,
provider.reads,
provider.writes,
provider.closes,
provider.aborts
),
(1, 0, 0, 0, 0)
);
let ack = exchange(&mut service, &mut provider, owner, chunk);
expect_control(ack, FileStatus::Ok, true);
expect_control(
exchange(&mut service, &mut provider, other_ingress, chunk),
FileStatus::InvalidTransfer,
false,
);
assert_eq!(exchange(&mut service, &mut provider, owner, chunk), ack);
assert_eq!(provider.writes, 1);
// Lose the durable close reply, then try to steal, evict, or corrupt it.
let lost_reply = exchange(&mut service, &mut provider, owner, close);
expect_control(lost_reply, FileStatus::Ok, true);
for (request, status) in [
(close, FileStatus::InvalidTransfer),
(chunk, FileStatus::InvalidTransfer),
(start, FileStatus::Busy),
(alter(start, |r| r.transfer_id += 1), FileStatus::Busy),
(
control(FileOperation::Abort, 3),
FileStatus::InvalidTransfer,
),
(
control(FileOperation::WriteAck, 3),
FileStatus::InvalidState,
),
] {
expect_control(
exchange(&mut service, &mut provider, other_ingress, request),
status,
false,
);
}
expect_control(
exchange(
&mut service,
&mut provider,
owner,
control(FileOperation::Close, 2),
),
FileStatus::RequestConflict,
false,
);
expect_control(
exchange(&mut service, &mut provider, owner, start),
FileStatus::RequestConflict,
false,
);
for _ in 0..3 {
assert_eq!(
exchange(&mut service, &mut provider, owner, close),
lost_reply
);
}
assert_eq!(provider.bytes, b"abc");
assert_eq!(
(
provider.opens,
provider.writes,
provider.closes,
provider.aborts
),
(1, 1, 1, 0)
);
assert!(!service.expire(&mut provider));
expect_control(
exchange(&mut service, &mut provider, owner, close),
FileStatus::InvalidTransfer,
false,
);
expect_control(
exchange(&mut service, &mut provider, other_ingress, start),
FileStatus::Ok,
true,
);
expect_control(
exchange(&mut service, &mut provider, owner, close),
FileStatus::InvalidTransfer,
false,
);
assert_eq!(provider.closes, 1);
}
}
#[test]
fn read_and_abort_replay_are_bound_to_the_full_peer() {
let mut service = FileService::new(TARGET);
let mut provider = Provider {
bytes: b"snapshot".to_vec(),
..Provider::default()
};
let start = open(FileOperation::OpenRead, &[]);
expect_control(
exchange(&mut service, &mut provider, UART, start),
FileStatus::Ok,
true,
);
let read = control(FileOperation::Read, 0);
expect_control(
exchange(&mut service, &mut provider, USB, read),
FileStatus::InvalidTransfer,
false,
);
let first = exchange(&mut service, &mut provider, UART, read);
assert!(first.refresh_lease);
let FileResponse::Data(response) = first.response else {
panic!("expected data")
};
assert_eq!(&response.data[..8], b"snapshot");
expect_control(
exchange(&mut service, &mut provider, USB, read),
FileStatus::InvalidTransfer,
false,
);
expect_control(
exchange(&mut service, &mut provider, UART, data(0, b"changed")),
FileStatus::InvalidState,
false,
);
expect_control(
exchange(
&mut service,
&mut provider,
UART,
control(FileOperation::Read, 8),
),
FileStatus::InvalidPosition,
false,
);
assert_eq!(exchange(&mut service, &mut provider, UART, read), first);
assert_eq!(provider.reads, 1);
let abort = control(FileOperation::Abort, 8);
expect_control(
exchange(&mut service, &mut provider, USB, abort),
FileStatus::InvalidTransfer,
false,
);
let aborted = exchange(&mut service, &mut provider, UART, abort);
expect_control(aborted, FileStatus::Ok, true);
expect_control(
exchange(&mut service, &mut provider, USB, abort),
FileStatus::InvalidTransfer,
false,
);
assert_eq!(exchange(&mut service, &mut provider, UART, abort), aborted);
assert!(!service.expire(&mut provider));
assert!(!service.expire(&mut provider));
expect_control(
exchange(&mut service, &mut provider, UART, abort),
FileStatus::InvalidTransfer,
false,
);
expect_control(
exchange(&mut service, &mut provider, USB, start),
FileStatus::Ok,
true,
);
assert_eq!(provider.aborts, 1);
}
#[test]
fn rejected_activity_cannot_retain_open_or_completed_lease() {
const LEASE: u64 = 10;
for completed in [false, true] {
let mut service = FileService::new(TARGET);
let mut provider = Provider::default();
let start = open(FileOperation::OpenWrite, &[]);
expect_control(
exchange(&mut service, &mut provider, UART, start),
FileStatus::Ok,
true,
);
if completed {
expect_control(
exchange(
&mut service,
&mut provider,
UART,
control(FileOperation::Close, 0),
),
FileStatus::Ok,
true,
);
}
let mut lease_until = LEASE;
for now in 1..LEASE {
for peer in [UART, USB] {
for request in [
control(FileOperation::WriteAck, 0),
alter(start, |r| {
r.content = Content::try_new(b"bad path").unwrap()
}),
alter(start, |r| r.transfer_id = 0),
alter(control(FileOperation::Read, 0), |r| r.file_size = 1),
alter(control(FileOperation::Abort, 0), |r| r.checksum_crc32c = 1),
alter(control(FileOperation::Close, 0), |r| {
r.content = Content::try_new(b"bad").unwrap()
}),
data(1, b"x"),
] {
let result = exchange(&mut service, &mut provider, peer, request);
if result.refresh_lease {
lease_until = now + LEASE;
}
assert_eq!(
lease_until, LEASE,
"rejection retained the lease: {result:?}"
);
}
}
for request in [
start,
control(FileOperation::Close, 0),
control(FileOperation::Abort, 0),
] {
assert!(!exchange(&mut service, &mut provider, USB, request).refresh_lease);
}
}
assert_eq!(provider.opens, 1);
assert_eq!(provider.closes, usize::from(completed));
assert_eq!(provider.aborts, 0);
// A clock-owning caller expires the entire slot at the unchanged deadline.
assert_eq!(service.expire(&mut provider), !completed);
assert!(!service.expire(&mut provider));
assert_eq!(provider.aborts, usize::from(!completed));
expect_control(
exchange(
&mut service,
&mut provider,
UART,
control(FileOperation::Close, 0),
),
FileStatus::InvalidTransfer,
false,
);
expect_control(
exchange(&mut service, &mut provider, USB, start),
FileStatus::Ok,
true,
);
}
}
#[test]
fn invalid_owner_requests_do_not_mutate_a_live_upload_or_its_retry() {
let mut service = FileService::new(TARGET);
let mut provider = Provider::default();
let start = open(FileOperation::OpenWrite, b"abc");
expect_control(
exchange(&mut service, &mut provider, UART, start),
FileStatus::Ok,
true,
);
for (request, status) in [
(control(FileOperation::Read, 0), FileStatus::InvalidState),
(
control(FileOperation::Close, 0),
FileStatus::InvalidPosition,
),
(data(1, b"bc"), FileStatus::InvalidPosition),
(data(0, b"abc!"), FileStatus::RequestConflict),
(
alter(start, |r| r.checksum_crc32c ^= 1),
FileStatus::RequestConflict,
),
(
alter(control(FileOperation::Close, 0), |r| r.transfer_id += 1),
FileStatus::InvalidTransfer,
),
] {
expect_control(
exchange(&mut service, &mut provider, UART, request),
status,
false,
);
}
assert_eq!(
(provider.writes, provider.closes, provider.aborts),
(0, 0, 0)
);
let accepted = exchange(&mut service, &mut provider, UART, data(0, b"abc"));
expect_control(accepted, FileStatus::Ok, true);
expect_control(
exchange(&mut service, &mut provider, UART, data(0, b"abd")),
FileStatus::RequestConflict,
false,
);
expect_control(
exchange(&mut service, &mut provider, UART, start),
FileStatus::RequestConflict,
false,
);
assert_eq!(
exchange(&mut service, &mut provider, UART, data(0, b"abc")),
accepted
);
expect_control(
exchange(
&mut service,
&mut provider,
UART,
control(FileOperation::Close, 3),
),
FileStatus::Ok,
true,
);
assert_eq!(provider.bytes, b"abc");
assert_eq!(
(provider.writes, provider.closes, provider.aborts),
(1, 1, 0)
);
}
#[test]
fn terminal_failures_refresh_and_replay_only_for_the_accepted_peer() {
for stage in 0..4 {
let mut service = FileService::new(TARGET);
let mut provider = Provider {
bytes: b"abc".to_vec(),
fail_read: stage == 0,
fail_write: stage == 1,
fail_close: stage == 2,
..Provider::default()
};
let operation = if stage == 0 {
FileOperation::OpenRead
} else {
FileOperation::OpenWrite
};
let start = open(operation, b"abc");
expect_control(
exchange(&mut service, &mut provider, UART, start),
FileStatus::Ok,
true,
);
if stage >= 2 {
let bytes = if stage == 3 { b"abd" } else { b"abc" };
expect_control(
exchange(&mut service, &mut provider, UART, data(0, bytes)),
FileStatus::Ok,
true,
);
}
let (request, status) = match stage {
0 => (control(FileOperation::Read, 0), FileStatus::InvalidPosition),
1 => (data(0, b"abc"), FileStatus::InvalidTransfer),
2 => (control(FileOperation::Close, 3), FileStatus::StorageFailure),
_ => (
control(FileOperation::Close, 3),
FileStatus::ChecksumMismatch,
),
};
let lost_reply = exchange(&mut service, &mut provider, UART, request);
expect_control(lost_reply, status, true);
expect_control(
exchange(&mut service, &mut provider, USB, request),
FileStatus::InvalidTransfer,
false,
);
let conflict = match request {
FileRequest::Control(_) => alter(request, |r| r.position += 1),
FileRequest::Data(_) => data(0, b"abd"),
};
expect_control(
exchange(&mut service, &mut provider, UART, conflict),
FileStatus::RequestConflict,
false,
);
assert_eq!(
exchange(&mut service, &mut provider, UART, request),
lost_reply
);
assert_eq!(provider.reads, usize::from(stage == 0));
assert_eq!(provider.writes, usize::from(stage != 0));
assert_eq!(provider.closes, usize::from(stage == 2));
assert_eq!(provider.aborts, 1);
}
}
#[test]
fn failed_open_does_not_claim_or_renew_the_slot_or_evict_a_completed_reply() {
let mut service = FileService::new(TARGET);
let mut provider = Provider {
fail_open: true,
..Provider::default()
};
let start = open(FileOperation::OpenWrite, &[]);
expect_control(
exchange(&mut service, &mut provider, UART, start),
FileStatus::NotFound,
false,
);
provider.fail_open = false;
expect_control(
exchange(&mut service, &mut provider, USB, start),
FileStatus::Ok,
true,
);
let close = control(FileOperation::Close, 0);
let completed = exchange(&mut service, &mut provider, USB, close);
expect_control(completed, FileStatus::Ok, true);
provider.fail_open = true;
expect_control(
exchange(
&mut service,
&mut provider,
USB,
alter(start, |r| r.transfer_id += 1),
),
FileStatus::NotFound,
false,
);
assert_eq!(exchange(&mut service, &mut provider, USB, close), completed);
assert_eq!(provider.closes, 1);
}