Standalone binary embeds the frontend, avoiding the dev-server dependency that made the window fail to load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
615 lines
21 KiB
Rust
615 lines
21 KiB
Rust
//! The FIT binary container: file header, definition messages, data messages
|
|
//! and the trailing CRC.
|
|
//!
|
|
//! This is deliberately a small, literal implementation of the FIT protocol
|
|
//! rather than a wrapper around a generated SDK. The container is about two
|
|
//! hundred lines and every byte of it matters for whether an upload is
|
|
//! accepted, so it is worth being able to read all of it.
|
|
//!
|
|
//! # File layout
|
|
//!
|
|
//! ```text
|
|
//! +--------------------------------+
|
|
//! | header (14 bytes) | size, protocol, profile, data size,
|
|
//! | | ".FIT", header CRC
|
|
//! +--------------------------------+
|
|
//! | data records (data_size bytes) | definition + data messages
|
|
//! +--------------------------------+
|
|
//! | file CRC (2 bytes) | over header + data records
|
|
//! +--------------------------------+
|
|
//! ```
|
|
|
|
use crate::crc::Crc16;
|
|
|
|
/// Header length we emit. The 12-byte variant (no header CRC) is legal but the
|
|
/// 14-byte form is universally expected.
|
|
pub const HEADER_SIZE: u8 = 14;
|
|
|
|
/// Protocol version 2.0, encoded as `major << 4 | minor`.
|
|
pub const PROTOCOL_VERSION: u8 = 0x20;
|
|
|
|
/// Profile version, `major * 100 + minor`, from FIT SDK 21.
|
|
pub const PROFILE_VERSION: u16 = 21_205;
|
|
|
|
/// The `.FIT` data type signature at bytes 8..12 of the header.
|
|
pub const DATA_TYPE: &[u8; 4] = b".FIT";
|
|
|
|
/// FIT base type identifiers. The high bit marks an endian-sensitive type; the
|
|
/// low 5 bits are the type number. Variant names are the FIT type names.
|
|
#[allow(missing_docs)]
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
#[repr(u8)]
|
|
pub enum BaseType {
|
|
Enum = 0x00,
|
|
Sint8 = 0x01,
|
|
Uint8 = 0x02,
|
|
Sint16 = 0x83,
|
|
Uint16 = 0x84,
|
|
Sint32 = 0x85,
|
|
Uint32 = 0x86,
|
|
String = 0x07,
|
|
Float32 = 0x88,
|
|
Uint8z = 0x0A,
|
|
Uint16z = 0x8B,
|
|
Uint32z = 0x8C,
|
|
Byte = 0x0D,
|
|
}
|
|
|
|
/// One encoded field value, carrying its own base type and width. Variant
|
|
/// names mirror the FIT base types they encode to.
|
|
#[allow(missing_docs)]
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum Value {
|
|
Enum(u8),
|
|
Uint8(u8),
|
|
Uint8z(u8),
|
|
Sint8(i8),
|
|
Uint16(u16),
|
|
Uint16z(u16),
|
|
Sint16(i16),
|
|
Uint32(u32),
|
|
Uint32z(u32),
|
|
Sint32(i32),
|
|
Float32(f32),
|
|
/// Null-terminated UTF-8. The encoded size includes the terminator.
|
|
String(String),
|
|
}
|
|
|
|
impl Value {
|
|
/// The FIT base type of this value.
|
|
pub fn base_type(&self) -> BaseType {
|
|
match self {
|
|
Value::Enum(_) => BaseType::Enum,
|
|
Value::Uint8(_) => BaseType::Uint8,
|
|
Value::Uint8z(_) => BaseType::Uint8z,
|
|
Value::Sint8(_) => BaseType::Sint8,
|
|
Value::Uint16(_) => BaseType::Uint16,
|
|
Value::Uint16z(_) => BaseType::Uint16z,
|
|
Value::Sint16(_) => BaseType::Sint16,
|
|
Value::Uint32(_) => BaseType::Uint32,
|
|
Value::Uint32z(_) => BaseType::Uint32z,
|
|
Value::Sint32(_) => BaseType::Sint32,
|
|
Value::Float32(_) => BaseType::Float32,
|
|
Value::String(_) => BaseType::String,
|
|
}
|
|
}
|
|
|
|
/// Encoded width in bytes, as it appears in the definition message.
|
|
pub fn size(&self) -> u8 {
|
|
match self {
|
|
Value::Enum(_) | Value::Uint8(_) | Value::Uint8z(_) | Value::Sint8(_) => 1,
|
|
Value::Uint16(_) | Value::Uint16z(_) | Value::Sint16(_) => 2,
|
|
Value::Uint32(_) | Value::Uint32z(_) | Value::Sint32(_) | Value::Float32(_) => 4,
|
|
// UTF-8 bytes plus the null terminator; clamped so a pathological
|
|
// name cannot overflow the single-byte size field.
|
|
Value::String(s) => (s.len().min(u8::MAX as usize - 1) + 1) as u8,
|
|
}
|
|
}
|
|
|
|
/// Append this value to `out` in little-endian order.
|
|
fn write(&self, out: &mut Vec<u8>) {
|
|
match self {
|
|
Value::Enum(v) | Value::Uint8(v) | Value::Uint8z(v) => out.push(*v),
|
|
Value::Sint8(v) => out.push(*v as u8),
|
|
Value::Uint16(v) | Value::Uint16z(v) => out.extend_from_slice(&v.to_le_bytes()),
|
|
Value::Sint16(v) => out.extend_from_slice(&v.to_le_bytes()),
|
|
Value::Uint32(v) | Value::Uint32z(v) => out.extend_from_slice(&v.to_le_bytes()),
|
|
Value::Sint32(v) => out.extend_from_slice(&v.to_le_bytes()),
|
|
Value::Float32(v) => out.extend_from_slice(&v.to_le_bytes()),
|
|
Value::String(s) => {
|
|
let max = usize::from(self.size()) - 1;
|
|
let mut bytes = s.as_bytes();
|
|
if bytes.len() > max {
|
|
// Never split a UTF-8 sequence.
|
|
let mut end = max;
|
|
while end > 0 && (bytes[end] & 0xC0) == 0x80 {
|
|
end -= 1;
|
|
}
|
|
bytes = &bytes[..end];
|
|
}
|
|
out.extend_from_slice(bytes);
|
|
out.resize(out.len() + (max - bytes.len()) + 1, 0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A message under construction: an ordered set of (field number, value) pairs.
|
|
///
|
|
/// Setting the same field twice replaces the value rather than emitting a
|
|
/// duplicate, which a definition message may not contain.
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct Message {
|
|
fields: Vec<(u8, Value)>,
|
|
}
|
|
|
|
impl Message {
|
|
/// An empty message.
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Set a field.
|
|
pub fn set(&mut self, field: u8, value: Value) -> &mut Self {
|
|
match self.fields.iter_mut().find(|(n, _)| *n == field) {
|
|
Some(slot) => slot.1 = value,
|
|
None => self.fields.push((field, value)),
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Set a field only when the value is present. Absent optional fields are
|
|
/// omitted from the definition entirely rather than written as the base
|
|
/// type's "invalid" sentinel, which keeps files small and stops decoders
|
|
/// from surfacing phantom all-invalid streams.
|
|
pub fn set_opt(&mut self, field: u8, value: Option<Value>) -> &mut Self {
|
|
if let Some(v) = value {
|
|
self.set(field, v);
|
|
}
|
|
self
|
|
}
|
|
|
|
/// The fields, in the order they will be written.
|
|
pub fn fields(&self) -> &[(u8, Value)] {
|
|
&self.fields
|
|
}
|
|
|
|
/// True when no field has been set.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.fields.is_empty()
|
|
}
|
|
|
|
/// The definition-message shape of this message.
|
|
fn shape(&self) -> Shape {
|
|
self.fields
|
|
.iter()
|
|
.map(|(n, v)| (*n, v.size(), v.base_type() as u8))
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// A definition-message shape: `(field number, size in bytes, base type)` per
|
|
/// field. Two messages sharing a shape can share a definition.
|
|
type Shape = Vec<(u8, u8, u8)>;
|
|
|
|
/// Accumulates data records and emits a complete FIT file.
|
|
///
|
|
/// Definitions are cached per local message type, so a definition is re-emitted
|
|
/// only when a message's shape changes — which is what lets a thousand `record`
|
|
/// messages share a single definition.
|
|
#[derive(Debug, Default)]
|
|
pub struct FitEncoder {
|
|
data: Vec<u8>,
|
|
/// Cached definition shape per local message type (0..16).
|
|
defs: [Option<(u16, Shape)>; 16],
|
|
message_count: usize,
|
|
}
|
|
|
|
impl FitEncoder {
|
|
/// A new, empty encoder.
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Write `msg` as global message `global` using local message type `local`.
|
|
///
|
|
/// Emits a definition message first if the shape is not already cached for
|
|
/// this local type. `local` must be 0..=15; anything larger is masked, and
|
|
/// an empty message is skipped (a zero-field definition is legal but
|
|
/// pointless and confuses some parsers).
|
|
pub fn write_message(&mut self, local: u8, global: u16, msg: &Message) {
|
|
if msg.is_empty() {
|
|
return;
|
|
}
|
|
let local = local & 0x0F;
|
|
let shape = msg.shape();
|
|
|
|
let cached = self.defs[local as usize]
|
|
.as_ref()
|
|
.is_some_and(|(g, s)| *g == global && *s == shape);
|
|
|
|
if !cached {
|
|
self.write_definition(local, global, &shape);
|
|
self.defs[local as usize] = Some((global, shape));
|
|
}
|
|
|
|
// Data message header: bit 7 = 0 (normal), bit 6 = 0 (data),
|
|
// bits 0..4 = local message type.
|
|
self.data.push(local);
|
|
for (_, value) in msg.fields() {
|
|
value.write(&mut self.data);
|
|
}
|
|
self.message_count += 1;
|
|
}
|
|
|
|
fn write_definition(&mut self, local: u8, global: u16, shape: &[(u8, u8, u8)]) {
|
|
// Definition message header: bit 7 = 0 (normal), bit 6 = 1 (definition).
|
|
self.data.push(0x40 | local);
|
|
self.data.push(0); // reserved
|
|
self.data.push(0); // architecture: 0 = little endian
|
|
self.data.extend_from_slice(&global.to_le_bytes());
|
|
// A definition may describe at most 255 fields.
|
|
self.data.push(shape.len().min(u8::MAX as usize) as u8);
|
|
for &(num, size, base) in shape.iter().take(u8::MAX as usize) {
|
|
self.data.push(num);
|
|
self.data.push(size);
|
|
self.data.push(base);
|
|
}
|
|
}
|
|
|
|
/// Number of data messages written so far.
|
|
pub fn message_count(&self) -> usize {
|
|
self.message_count
|
|
}
|
|
|
|
/// Byte length of the data-records section written so far.
|
|
pub fn data_len(&self) -> usize {
|
|
self.data.len()
|
|
}
|
|
|
|
/// Finish the file: prepend the 14-byte header (with its own CRC) and
|
|
/// append the file CRC over header plus data.
|
|
pub fn finish(self) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(self.data.len() + 16);
|
|
out.extend_from_slice(&file_header(self.data.len() as u32));
|
|
out.extend_from_slice(&self.data);
|
|
|
|
let mut crc = Crc16::new();
|
|
crc.update(&out);
|
|
out.extend_from_slice(&crc.value().to_le_bytes());
|
|
out
|
|
}
|
|
}
|
|
|
|
/// Build the 14-byte FIT file header for a given data-records length.
|
|
///
|
|
/// `data_size` counts *only* the data records — not the header and not the
|
|
/// trailing CRC. Getting that wrong is the second classic way to produce a file
|
|
/// that every uploader rejects.
|
|
pub fn file_header(data_size: u32) -> [u8; 14] {
|
|
let mut h = [0u8; 14];
|
|
h[0] = HEADER_SIZE;
|
|
h[1] = PROTOCOL_VERSION;
|
|
h[2..4].copy_from_slice(&PROFILE_VERSION.to_le_bytes());
|
|
h[4..8].copy_from_slice(&data_size.to_le_bytes());
|
|
h[8..12].copy_from_slice(DATA_TYPE);
|
|
|
|
let mut crc = Crc16::new();
|
|
crc.update(&h[0..12]);
|
|
h[12..14].copy_from_slice(&crc.value().to_le_bytes());
|
|
h
|
|
}
|
|
|
|
/// Structural check on an encoded FIT file: header self-consistency, declared
|
|
/// data size against actual length, and both CRCs.
|
|
///
|
|
/// Exposed because it is exactly the check an uploader performs before deciding
|
|
/// whether to look at the contents, and it is cheap enough to run on every file
|
|
/// we write.
|
|
pub fn verify(bytes: &[u8]) -> Result<(), VerifyError> {
|
|
if bytes.len() < 16 {
|
|
return Err(VerifyError::TooShort(bytes.len()));
|
|
}
|
|
let header_size = bytes[0] as usize;
|
|
if header_size != 12 && header_size != 14 {
|
|
return Err(VerifyError::BadHeaderSize(bytes[0]));
|
|
}
|
|
if &bytes[8..12] != DATA_TYPE {
|
|
return Err(VerifyError::BadSignature([
|
|
bytes[8], bytes[9], bytes[10], bytes[11],
|
|
]));
|
|
}
|
|
|
|
let data_size = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
|
|
let expected_len = header_size + data_size + 2;
|
|
if bytes.len() != expected_len {
|
|
return Err(VerifyError::DataSizeMismatch {
|
|
declared: data_size,
|
|
actual: bytes.len().saturating_sub(header_size + 2),
|
|
});
|
|
}
|
|
|
|
if header_size == 14 {
|
|
let stored = u16::from_le_bytes([bytes[12], bytes[13]]);
|
|
// A zero header CRC means "not present", which is legal.
|
|
if stored != 0 {
|
|
let computed = crate::crc::crc16(&bytes[0..12]);
|
|
if stored != computed {
|
|
return Err(VerifyError::HeaderCrc { stored, computed });
|
|
}
|
|
}
|
|
}
|
|
|
|
let stored = u16::from_le_bytes([bytes[expected_len - 2], bytes[expected_len - 1]]);
|
|
let computed = crate::crc::crc16(&bytes[..expected_len - 2]);
|
|
if stored != computed {
|
|
return Err(VerifyError::FileCrc { stored, computed });
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Why [`verify`] rejected a file.
|
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
pub enum VerifyError {
|
|
/// Shorter than the smallest legal file (header plus CRC).
|
|
#[error("file is {0} bytes, too short to be a FIT file")]
|
|
TooShort(
|
|
/// Actual file length.
|
|
usize,
|
|
),
|
|
/// Byte 0 is neither 12 nor 14.
|
|
#[error("header size {0} is neither 12 nor 14")]
|
|
BadHeaderSize(
|
|
/// The declared header size.
|
|
u8,
|
|
),
|
|
/// Bytes 8..12 are not `.FIT`.
|
|
#[error("data type signature is {0:?}, expected \".FIT\"")]
|
|
BadSignature(
|
|
/// The bytes found where the signature should be.
|
|
[u8; 4],
|
|
),
|
|
/// The header's data size does not match the bytes actually present.
|
|
#[error("header declares {declared} data bytes but the file carries {actual}")]
|
|
DataSizeMismatch {
|
|
/// Data size from the header.
|
|
declared: usize,
|
|
/// Data bytes actually present.
|
|
actual: usize,
|
|
},
|
|
/// The header CRC does not check out.
|
|
#[error("header CRC is {stored:#06x}, computed {computed:#06x}")]
|
|
HeaderCrc {
|
|
/// CRC read from the file.
|
|
stored: u16,
|
|
/// CRC computed over bytes 0..12.
|
|
computed: u16,
|
|
},
|
|
/// The trailing file CRC does not check out.
|
|
#[error("file CRC is {stored:#06x}, computed {computed:#06x}")]
|
|
FileCrc {
|
|
/// CRC read from the end of the file.
|
|
stored: u16,
|
|
/// CRC computed over header plus data.
|
|
computed: u16,
|
|
},
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn header_layout_is_byte_exact() {
|
|
let h = file_header(0x1234);
|
|
assert_eq!(h[0], 14, "header size");
|
|
assert_eq!(h[1], 0x20, "protocol version 2.0");
|
|
assert_eq!(&h[2..4], &PROFILE_VERSION.to_le_bytes(), "profile version");
|
|
assert_eq!(&h[4..8], &[0x34, 0x12, 0x00, 0x00], "data size, little endian");
|
|
assert_eq!(&h[8..12], b".FIT", "data type signature");
|
|
|
|
let crc = u16::from_le_bytes([h[12], h[13]]);
|
|
assert_eq!(crc, crate::crc::crc16(&h[0..12]));
|
|
assert_ne!(crc, 0, "a real header CRC, not the 'absent' sentinel");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_file_is_header_plus_crc() {
|
|
let bytes = FitEncoder::new().finish();
|
|
assert_eq!(bytes.len(), 16);
|
|
assert_eq!(&bytes[4..8], &[0, 0, 0, 0]);
|
|
assert!(verify(&bytes).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn definition_and_data_bytes_are_exact() {
|
|
let mut enc = FitEncoder::new();
|
|
let mut msg = Message::new();
|
|
msg.set(0, Value::Enum(4));
|
|
msg.set(1, Value::Uint16(255));
|
|
enc.write_message(0, 0, &msg);
|
|
let bytes = enc.finish();
|
|
|
|
let data = &bytes[14..bytes.len() - 2];
|
|
#[rustfmt::skip]
|
|
let expected: &[u8] = &[
|
|
// definition message for global 0, local 0, two fields
|
|
0x40, // header: normal, definition, local 0
|
|
0x00, // reserved
|
|
0x00, // little endian
|
|
0x00, 0x00, // global message number 0 (file_id)
|
|
0x02, // two fields
|
|
0x00, 0x01, 0x00, // field 0, 1 byte, enum
|
|
0x01, 0x02, 0x84, // field 1, 2 bytes, uint16
|
|
// data message
|
|
0x00, // header: normal, data, local 0
|
|
0x04, // type = activity
|
|
0xFF, 0x00, // manufacturer = 255, little endian
|
|
];
|
|
assert_eq!(data, expected);
|
|
assert_eq!(
|
|
u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize,
|
|
expected.len()
|
|
);
|
|
assert!(verify(&bytes).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn definition_is_reused_for_identical_shapes() {
|
|
let mut enc = FitEncoder::new();
|
|
let mut msg = Message::new();
|
|
msg.set(253, Value::Uint32(1));
|
|
for i in 0..5u32 {
|
|
msg.set(253, Value::Uint32(i));
|
|
enc.write_message(3, mesg_record(), &msg);
|
|
}
|
|
// One 3+3+1-byte definition (6 header bytes + 3 per field) plus five
|
|
// 5-byte data messages.
|
|
assert_eq!(enc.data_len(), (6 + 3) + 5 * (1 + 4));
|
|
assert_eq!(enc.message_count(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn definition_is_re_emitted_when_the_shape_changes() {
|
|
let mut enc = FitEncoder::new();
|
|
let mut a = Message::new();
|
|
a.set(253, Value::Uint32(1));
|
|
enc.write_message(3, mesg_record(), &a);
|
|
let after_first = enc.data_len();
|
|
|
|
let mut b = Message::new();
|
|
b.set(253, Value::Uint32(2));
|
|
b.set(7, Value::Uint16(250));
|
|
enc.write_message(3, mesg_record(), &b);
|
|
// Second write costs a new 12-byte definition plus a 7-byte data message.
|
|
assert_eq!(enc.data_len() - after_first, (6 + 6) + (1 + 4 + 2));
|
|
}
|
|
|
|
fn mesg_record() -> u16 {
|
|
crate::profile::mesg::RECORD
|
|
}
|
|
|
|
#[test]
|
|
fn strings_are_null_terminated_and_sized_with_the_terminator() {
|
|
let v = Value::String("BikeControl".into());
|
|
assert_eq!(v.size(), 12);
|
|
let mut out = Vec::new();
|
|
v.write(&mut out);
|
|
assert_eq!(out, b"BikeControl\0");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_string_is_a_single_null() {
|
|
let v = Value::String(String::new());
|
|
assert_eq!(v.size(), 1);
|
|
let mut out = Vec::new();
|
|
v.write(&mut out);
|
|
assert_eq!(out, b"\0");
|
|
}
|
|
|
|
#[test]
|
|
fn overlong_strings_are_truncated_on_a_char_boundary() {
|
|
let v = Value::String("é".repeat(200));
|
|
let size = usize::from(v.size());
|
|
let mut out = Vec::new();
|
|
v.write(&mut out);
|
|
assert_eq!(out.len(), size);
|
|
assert_eq!(*out.last().unwrap(), 0);
|
|
// Truncation must not leave a partial UTF-8 sequence.
|
|
assert!(std::str::from_utf8(&out[..out.len() - 1]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn setting_a_field_twice_replaces_rather_than_duplicates() {
|
|
let mut msg = Message::new();
|
|
msg.set(7, Value::Uint16(100));
|
|
msg.set(7, Value::Uint16(200));
|
|
assert_eq!(msg.fields().len(), 1);
|
|
assert_eq!(msg.fields()[0].1, Value::Uint16(200));
|
|
}
|
|
|
|
#[test]
|
|
fn set_opt_skips_none() {
|
|
let mut msg = Message::new();
|
|
msg.set_opt(3, None);
|
|
msg.set_opt(4, Some(Value::Uint8(90)));
|
|
assert_eq!(msg.fields().len(), 1);
|
|
assert_eq!(msg.fields()[0].0, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn signed_values_use_twos_complement_little_endian() {
|
|
let mut out = Vec::new();
|
|
Value::Sint16(-100).write(&mut out);
|
|
assert_eq!(out, vec![0x9C, 0xFF]);
|
|
out.clear();
|
|
Value::Sint8(-1).write(&mut out);
|
|
assert_eq!(out, vec![0xFF]);
|
|
out.clear();
|
|
Value::Sint32(-2).write(&mut out);
|
|
assert_eq!(out, vec![0xFE, 0xFF, 0xFF, 0xFF]);
|
|
}
|
|
|
|
#[test]
|
|
fn verify_rejects_a_corrupted_file_crc() {
|
|
let mut bytes = FitEncoder::new().finish();
|
|
let last = bytes.len() - 1;
|
|
bytes[last] ^= 0xFF;
|
|
assert!(matches!(verify(&bytes), Err(VerifyError::FileCrc { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_rejects_a_corrupted_header_crc() {
|
|
let mut bytes = FitEncoder::new().finish();
|
|
bytes[12] ^= 0xFF;
|
|
assert!(matches!(verify(&bytes), Err(VerifyError::HeaderCrc { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_rejects_a_wrong_data_size() {
|
|
let mut bytes = FitEncoder::new().finish();
|
|
bytes[4] = 99;
|
|
assert!(matches!(
|
|
verify(&bytes),
|
|
Err(VerifyError::DataSizeMismatch { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_rejects_a_bad_signature() {
|
|
let mut bytes = FitEncoder::new().finish();
|
|
bytes[8] = b'X';
|
|
assert!(matches!(verify(&bytes), Err(VerifyError::BadSignature(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_rejects_truncation() {
|
|
let bytes = FitEncoder::new().finish();
|
|
assert!(matches!(
|
|
verify(&bytes[..10]),
|
|
Err(VerifyError::TooShort(10))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn every_single_byte_corruption_is_caught() {
|
|
// The strongest statement we can make about the checksums without an
|
|
// uploader: no one-byte change to the file survives verification.
|
|
let mut enc = FitEncoder::new();
|
|
let mut msg = Message::new();
|
|
msg.set(253, Value::Uint32(1_000_000_000));
|
|
msg.set(7, Value::Uint16(250));
|
|
enc.write_message(3, crate::profile::mesg::RECORD, &msg);
|
|
let good = enc.finish();
|
|
|
|
for i in 0..good.len() {
|
|
let mut bad = good.clone();
|
|
bad[i] ^= 0x01;
|
|
assert!(
|
|
verify(&bad).is_err(),
|
|
"flipping a bit in byte {i} was not detected"
|
|
);
|
|
}
|
|
}
|
|
}
|