From a47ad7707197d13fde03f4112c6080198e91c751 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 15 Aug 2026 10:27:05 +0200 Subject: [PATCH] feat: parse and validate FIT containers --- app/fit/models.py | 16 +++ app/fit/rewriter.py | 143 ++++++++++++++++++++++++++ tests/fit/builders.py | 32 ++++++ tests/fit/test_rewriter_validation.py | 21 ++++ 4 files changed, 212 insertions(+) create mode 100644 app/fit/models.py create mode 100644 app/fit/rewriter.py create mode 100644 tests/fit/builders.py create mode 100644 tests/fit/test_rewriter_validation.py diff --git a/app/fit/models.py b/app/fit/models.py new file mode 100644 index 0000000..0b27e81 --- /dev/null +++ b/app/fit/models.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FieldDefinition: + num: int + size: int + base_type: int + + +@dataclass(frozen=True) +class LocalDefinition: + global_message_num: int + endian: str + fields: tuple[FieldDefinition, ...] + developer_field_size: int diff --git a/app/fit/rewriter.py b/app/fit/rewriter.py new file mode 100644 index 0000000..d87518c --- /dev/null +++ b/app/fit/rewriter.py @@ -0,0 +1,143 @@ +import struct +from pathlib import Path + +from app.fit.crc import fit_crc +from app.fit.models import FieldDefinition, LocalDefinition + + +class FitFormatError(ValueError): + pass + + +def _validate_fit_container(data: bytearray) -> None: + if len(data) < 14: + raise FitFormatError("FIT file is too small") + header_size = data[0] + if header_size not in {12, 14}: + raise FitFormatError(f"Unsupported FIT header size: {header_size}") + if len(data) < header_size + 2: + raise FitFormatError("FIT file is shorter than its header") + if bytes(data[8:12]) != b".FIT": + raise FitFormatError("Missing .FIT signature") + data_size = struct.unpack_from(" tuple[LocalDefinition, int]: + if offset + 5 > end_offset: + raise FitFormatError("Truncated FIT definition message") + offset += 1 # reserved byte + architecture = data[offset] + offset += 1 + if architecture not in {0, 1}: + raise FitFormatError(f"Unsupported FIT architecture: {architecture}") + endian = ">" if architecture == 1 else "<" + global_message_num = struct.unpack_from(f"{endian}H", data, offset)[0] + offset += 2 + field_count = data[offset] + offset += 1 + + fields: list[FieldDefinition] = [] + for _ in range(field_count): + if offset + 3 > end_offset: + raise FitFormatError("Truncated FIT field definition") + fields.append(FieldDefinition(data[offset], data[offset + 1], data[offset + 2])) + offset += 3 + + developer_field_size = 0 + if has_developer_fields: + if offset >= end_offset: + raise FitFormatError("Truncated FIT developer field count") + developer_count = data[offset] + offset += 1 + for _ in range(developer_count): + if offset + 3 > end_offset: + raise FitFormatError("Truncated FIT developer field definition") + developer_field_size += data[offset + 1] + offset += 3 + + return LocalDefinition(global_message_num, endian, tuple(fields), developer_field_size), offset + + +def _collect_field_offsets( + definition: LocalDefinition, + offset: int, + end_offset: int, +) -> tuple[list[tuple[FieldDefinition, int]], int]: + result: list[tuple[FieldDefinition, int]] = [] + current = offset + for field in definition.fields: + if current + field.size > end_offset: + raise FitFormatError("Truncated FIT data record") + result.append((field, current)) + current += field.size + if current + definition.developer_field_size > end_offset: + raise FitFormatError("Truncated FIT developer field payload") + current += definition.developer_field_size + return result, current + + +def _iter_data_fields( + data: bytearray, +) -> list[tuple[LocalDefinition, list[tuple[FieldDefinition, int]]]]: + header_size = data[0] + data_size = struct.unpack_from("> 5) & 0x03 + definition = definitions.get(local) + if definition is None: + raise FitFormatError(f"Compressed timestamp record used unknown local definition {local}") + field_offsets, offset = _collect_field_offsets(definition, offset, end_offset) + records.append((definition, field_offsets)) + continue + + local = record_header & 0x0F + is_definition = bool(record_header & 0x40) + has_developer_fields = bool(record_header & 0x20) + if is_definition: + definition, offset = _read_definition(data, offset, has_developer_fields, end_offset) + definitions[local] = definition + continue + + definition = definitions.get(local) + if definition is None: + raise FitFormatError(f"Data record used unknown local definition {local}") + field_offsets, offset = _collect_field_offsets(definition, offset, end_offset) + records.append((definition, field_offsets)) + + if offset != end_offset: + raise FitFormatError("FIT parser did not end on data boundary") + return records + + +def is_fit_file(path: Path) -> bool: + try: + data = bytearray(path.read_bytes()) + _validate_fit_container(data) + _iter_data_fields(data) + except (OSError, FitFormatError): + return False + return True diff --git a/tests/fit/builders.py b/tests/fit/builders.py new file mode 100644 index 0000000..6f3c2d4 --- /dev/null +++ b/tests/fit/builders.py @@ -0,0 +1,32 @@ +import struct + +from app.fit.crc import fit_crc + + +def make_fit(data_records: bytes, *, header_size: int = 14) -> bytes: + if header_size not in {12, 14}: + raise ValueError(header_size) + header = bytearray(header_size) + header[0] = header_size + header[1] = 0x20 + struct.pack_into(" bytes: + architecture = 1 if endian == ">" else 0 + payload = bytearray([0, architecture]) + payload.extend(struct.pack(f"{endian}H", global_num)) + payload.append(len(fields)) + for num, size, base_type in fields: + payload.extend(bytes([num, size, base_type])) + return bytes([0x40 | local]) + bytes(payload) + + +def data(local: int, payload: bytes) -> bytes: + return bytes([local]) + payload diff --git a/tests/fit/test_rewriter_validation.py b/tests/fit/test_rewriter_validation.py new file mode 100644 index 0000000..1390f1c --- /dev/null +++ b/tests/fit/test_rewriter_validation.py @@ -0,0 +1,21 @@ +from pathlib import Path + +import pytest + +from app.fit.rewriter import FitFormatError, is_fit_file +from tests.fit.builders import make_fit + + +def test_valid_12_and_14_byte_headers(tmp_path: Path) -> None: + for size in (12, 14): + path = tmp_path / f"valid-{size}.fit" + path.write_bytes(make_fit(b"", header_size=size)) + assert is_fit_file(path) is True + + +def test_bad_file_crc_is_rejected(tmp_path: Path) -> None: + payload = bytearray(make_fit(b"")) + payload[-1] ^= 0xFF + path = tmp_path / "bad.fit" + path.write_bytes(payload) + assert is_fit_file(path) is False