Skip to main content

fit/
header.rs

1//! FIT file header (12 or 14 bytes).
2//!
3//! Layout (all multi-byte integers little-endian):
4//!
5//! | Offset | Size | Field            | Meaning                                  |
6//! |-------:|-----:|------------------|------------------------------------------|
7//! |     0  |   1  | Header Size      | `0x0E` (14) or `0x0C` (12)              |
8//! |     1  |   1  | Protocol Version | e.g. `0x20` for v2.0                    |
9//! |     2  |   2  | Profile Version  | u16 LE, e.g. 21200                      |
10//! |     4  |   4  | Data Size        | u32 LE, bytes of records (no header/CRC)|
11//! |     8  |   4  | Signature        | ASCII `.FIT` = `[0x2E,0x46,0x49,0x54]`  |
12//! |    12  |   2  | Header CRC       | u16 LE — only present in 14-byte header |
13//!
14//! Reference: `guide/fit_binary_learning_notes.md` §1.1.
15
16use crate::error::FitError;
17
18/// Parsed FIT file header.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct FileHeader {
21    /// Either 12 or 14.
22    pub header_size: u8,
23    /// Protocol version byte (high nibble = major, low = minor).
24    pub protocol_version: u8,
25    /// Profile version (e.g. 21200 = v21.200).
26    pub profile_version: u16,
27    /// Number of bytes in the records area (between header and trailing file CRC).
28    pub data_size: u32,
29    /// Header CRC, only present when `header_size == 14`.
30    /// A stored value of `Some(0)` means "skip verification" (legacy firmware).
31    pub header_crc: Option<u16>,
32}
33
34impl FileHeader {
35    /// The four-byte ASCII signature at offset 8..12.
36    pub const SIGNATURE: [u8; 4] = *b".FIT";
37    /// Smallest valid header size.
38    pub const MIN_SIZE: u8 = 12;
39    /// Largest valid header size.
40    pub const MAX_SIZE: u8 = 14;
41
42    /// Parse a header from the start of `bytes`.
43    ///
44    /// The slice must hold at least `header_size` bytes (12 or 14). The
45    /// signature `.FIT` is verified. The header CRC is **not** verified here;
46    /// see [`crate::check_integrity`] for full validation.
47    pub fn parse(bytes: &[u8]) -> Result<Self, FitError> {
48        if bytes.len() < Self::MIN_SIZE as usize {
49            return Err(FitError::TooShort {
50                expected: Self::MIN_SIZE as usize,
51                actual: bytes.len(),
52            });
53        }
54
55        let header_size = bytes[0];
56        if header_size != 12 && header_size != 14 {
57            return Err(FitError::InvalidHeaderSize(header_size));
58        }
59        if bytes.len() < header_size as usize {
60            return Err(FitError::TooShort {
61                expected: header_size as usize,
62                actual: bytes.len(),
63            });
64        }
65
66        let protocol_version = bytes[1];
67        let profile_version = u16::from_le_bytes([bytes[2], bytes[3]]);
68        let data_size = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
69
70        // Signature is unconditionally at 8..12 in both header sizes.
71        let signature: [u8; 4] = [bytes[8], bytes[9], bytes[10], bytes[11]];
72        if signature != Self::SIGNATURE {
73            return Err(FitError::InvalidSignature(signature));
74        }
75
76        let header_crc = if header_size == 14 {
77            Some(u16::from_le_bytes([bytes[12], bytes[13]]))
78        } else {
79            None
80        };
81
82        Ok(Self {
83            header_size,
84            protocol_version,
85            profile_version,
86            data_size,
87            header_crc,
88        })
89    }
90
91    /// Total expected file size: header + data + 2-byte trailing file CRC.
92    #[inline]
93    pub fn total_file_size(&self) -> usize {
94        self.header_size as usize + self.data_size as usize + 2
95    }
96
97    /// Byte offset where the trailing file CRC begins.
98    #[inline]
99    pub fn file_crc_offset(&self) -> usize {
100        self.header_size as usize + self.data_size as usize
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    /// Build a minimal valid 14-byte header. `data_size` is in records-area
109    /// bytes; `header_crc` is the literal value to store.
110    fn make_14_byte_header(data_size: u32, header_crc: u16) -> [u8; 14] {
111        let ds = data_size.to_le_bytes();
112        let hc = header_crc.to_le_bytes();
113        [
114            14, 0x20, 0xD0, 0x52, // size, protocol, profile_version (21200 LE = D0 52)
115            ds[0], ds[1], ds[2], ds[3], 0x2E, 0x46, 0x49, 0x54, // data_size, ".FIT"
116            hc[0], hc[1],
117        ]
118    }
119
120    #[test]
121    fn parses_14_byte_header() {
122        let bytes = make_14_byte_header(94070, 0xABCD);
123        let h = FileHeader::parse(&bytes).unwrap();
124        assert_eq!(h.header_size, 14);
125        assert_eq!(h.protocol_version, 0x20);
126        assert_eq!(h.profile_version, 21200);
127        assert_eq!(h.data_size, 94070);
128        assert_eq!(h.header_crc, Some(0xABCD));
129        assert_eq!(h.total_file_size(), 14 + 94070 + 2);
130    }
131
132    #[test]
133    fn parses_12_byte_header() {
134        // Same shape but header_size=12, no CRC.
135        let bytes: [u8; 12] = [12, 0x10, 0x10, 0x00, 0x40, 0, 0, 0, 0x2E, 0x46, 0x49, 0x54];
136        let h = FileHeader::parse(&bytes).unwrap();
137        assert_eq!(h.header_size, 12);
138        assert_eq!(h.protocol_version, 0x10);
139        assert_eq!(h.data_size, 0x40);
140        assert_eq!(h.header_crc, None);
141    }
142
143    #[test]
144    fn rejects_invalid_header_size() {
145        let mut bytes = make_14_byte_header(0, 0);
146        bytes[0] = 13; // not 12 or 14
147        assert_eq!(
148            FileHeader::parse(&bytes),
149            Err(FitError::InvalidHeaderSize(13))
150        );
151    }
152
153    #[test]
154    fn rejects_invalid_signature() {
155        let mut bytes = make_14_byte_header(0, 0);
156        bytes[8] = b'X'; // corrupt signature
157        let err = FileHeader::parse(&bytes).unwrap_err();
158        assert!(matches!(err, FitError::InvalidSignature(sig) if sig[0] == b'X'));
159    }
160
161    #[test]
162    fn rejects_too_short_for_minimum() {
163        let bytes = [14u8; 11];
164        assert_eq!(
165            FileHeader::parse(&bytes),
166            Err(FitError::TooShort {
167                expected: 12,
168                actual: 11,
169            }),
170        );
171    }
172
173    #[test]
174    fn rejects_too_short_for_declared_14_byte() {
175        // Says size 14 but only provides 12 bytes (just enough for signature check).
176        let bytes: [u8; 12] = [14, 0x20, 0, 0, 0, 0, 0, 0, 0x2E, 0x46, 0x49, 0x54];
177        assert_eq!(
178            FileHeader::parse(&bytes),
179            Err(FitError::TooShort {
180                expected: 14,
181                actual: 12,
182            }),
183        );
184    }
185}