Files
mywhoosh2garmin/tests/fit/test_rewriter_patching.py
Bastian Wagner ef9ca9eeca fix: harden FIT patcher error boundary and verify patched metadata
Final-review fixes for Plan 2 (fit-rewriter). Every failure mode below now
surfaces as FitFormatError so Plan 3 can classify invalid FIT input as a
non-retryable activity error (spec 10.4).

- Range-check numeric values against the field's declared size before
  struct.pack, so an oversized serial number or a 1-byte product field
  raises FitFormatError instead of leaking a raw struct.error.
- Reject zero-size field definitions during parsing. A zero-size
  device_info field 0 read back as device_index == 0 via
  int.from_bytes(b"", ...), which could have let a paired sensor be
  rewritten as an Edge 1030 Plus (spec 10.2).
- Add DeviceFieldValue.is_creator so callers can tell the creator
  device_info record from sensor records instead of silently keeping
  whichever record appeared last.
- Implement the missing spec 10.4 post-patch step: read the patched
  buffer back and verify file_id 1/2/8 and creator device_info 2/4/27
  hold the target values. A field that could not be written (e.g. a
  product_name field too small for the target string) now fails the whole
  conversion rather than producing a silent partial patch. Verification
  runs before the output is written, so a half-rewritten file never lands
  on disk.
- Use the field's actual endianness in _read_field_value's fallback path.
- Add curated re-exports in app/fit/__init__.py for Plan 3.
- Document _iter_data_fields' caller invariant (validate the container
  first; end_offset is not clamped).
- Extend the preservation fixture with a product_name string field so the
  zero-filling string write path is covered by the byte-preservation
  proof, and test convert_fit_device against a 12-byte header.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 14:29:40 +02:00

346 lines
14 KiB
Python

import struct
from pathlib import Path
import pytest
from app.fit.models import DeviceFieldValue, FitConversionResult, GarminDevice
from app.fit.rewriter import (
FitFormatError,
_iter_data_fields,
convert_fit_device,
is_fit_file,
read_device_field_values,
)
from tests.fit.builders import data, definition, make_fit
FILE_ID_MESG_NUM = 0
DEVICE_INFO_MESG_NUM = 23
def _build_fixture() -> bytes:
# file_id: manufacturer(1/u16), product(2/u16)
file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84)])
file_data = data(0, struct.pack("<HH", 255, 999))
# device_info: device_index(0/u8), manufacturer(2/u16), product(4/u16)
device_def = definition(1, DEVICE_INFO_MESG_NUM, [(0, 1, 0x02), (2, 2, 0x84), (4, 2, 0x84)])
creator = data(1, struct.pack("<BHH", 0, 255, 999))
sensor = data(1, struct.pack("<BHH", 1, 32, 1234))
records = file_def + file_data + device_def + creator + sensor
return make_fit(records)
def _build_big_endian_fixture() -> bytes:
"""Big-endian (architecture=1) file_id definition/data pair. Closes the
big-endian coverage gap deferred from Task 2: no fixture anywhere in the FIT
test suite previously passed endian=">" to definition()/struct.pack, so the
architecture-byte branch in _read_definition (and the endian format string it
threads into every field read/write) was never actually exercised."""
file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84)], endian=">")
file_data = data(0, struct.pack(">HH", 255, 999))
return make_fit(file_def + file_data)
def _build_no_device_index_fixture() -> bytes:
"""device_info record entirely missing field 0 (device_index) must be left untouched."""
file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84)])
file_data = data(0, struct.pack("<HH", 255, 999))
# device_info WITHOUT device_index field at all: manufacturer(2/u16), product(4/u16)
device_def = definition(1, DEVICE_INFO_MESG_NUM, [(2, 2, 0x84), (4, 2, 0x84)])
no_index_device = data(1, struct.pack("<HH", 32, 1234))
records = file_def + file_data + device_def + no_index_device
return make_fit(records)
def _build_product_name_fixture() -> bytes:
"""file_id field 8 and device_info field 27 are both product_name (string) fields —
the only string field type this task patches. No sensor record here, so the single
device_info record present is unambiguously the creator."""
# file_id: manufacturer(1/u16), product(2/u16), product_name(8/string, size 20)
file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84), (8, 20, 0x07)])
original_file_name = b"MyWhoosh\x00".ljust(20, b"\x00")
file_data = data(0, struct.pack("<HH", 255, 999) + original_file_name)
# device_info: device_index(0/u8), manufacturer(2/u16), product(4/u16), product_name(27/string, size 20)
device_def = definition(
1, DEVICE_INFO_MESG_NUM, [(0, 1, 0x02), (2, 2, 0x84), (4, 2, 0x84), (27, 20, 0x07)]
)
original_device_name = b"MyWhoosh App\x00".ljust(20, b"\x00")
creator = data(1, struct.pack("<BHH", 0, 255, 999) + original_device_name)
records = file_def + file_data + device_def + creator
return make_fit(records)
def _build_zero_size_device_index_fixture() -> bytes:
"""Adversarial file declaring device_info field 0 (device_index) with size 0.
Without an explicit rejection, `int.from_bytes(b"", "little") == 0` would make
every record on this definition look like the creator (device_index == 0) and a
paired sensor would be rewritten as an Edge 1030 Plus."""
file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84)])
file_data = data(0, struct.pack("<HH", 255, 999))
device_def = definition(1, DEVICE_INFO_MESG_NUM, [(0, 0, 0x02), (2, 2, 0x84), (4, 2, 0x84)])
sensor = data(1, struct.pack("<HH", 32, 1234))
return make_fit(file_def + file_data + device_def + sensor)
def _build_undersized_serial_fixture() -> bytes:
"""file_id with a 2-byte serial_number field (3/u16) -- too small to hold a
32-bit serial number handed in through the public API."""
file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84), (3, 2, 0x84)])
file_data = data(0, struct.pack("<HHH", 255, 999, 4242))
return make_fit(file_def + file_data)
def _build_undersized_product_fixture() -> bytes:
"""file_id declaring product (field 2) as a single byte, which cannot hold 3570."""
file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 1, 0x02)])
file_data = data(0, struct.pack("<HB", 255, 99))
return make_fit(file_def + file_data)
def _build_undersized_product_name_fixture() -> bytes:
"""file_id product_name field of size 10 -- too small for "Edge 1030 Plus"
(14 bytes plus a null terminator), so the string write silently no-ops."""
file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84), (8, 10, 0x07)])
file_data = data(0, struct.pack("<HH", 255, 999) + b"MyWhoosh\x00\x00")
return make_fit(file_def + file_data)
def _read_device_info_records(path: Path) -> list[dict[int, int]]:
"""Parse raw bytes and return one dict of {field_num: value} per device_info record,
in file order, so creator and sensor records can be distinguished positionally."""
raw = bytearray(path.read_bytes())
records: list[dict[int, int]] = []
for local_def, field_offsets in _iter_data_fields(raw):
if local_def.global_message_num != DEVICE_INFO_MESG_NUM:
continue
values: dict[int, int] = {}
for field, offset in field_offsets:
raw_bytes = bytes(raw[offset : offset + field.size])
if field.size == 1:
values[field.num] = raw_bytes[0]
elif field.size == 2:
values[field.num] = struct.unpack(f"{local_def.endian}H", raw_bytes)[0]
records.append(values)
return records
def test_convert_fit_device_patches_file_id(tmp_path: Path) -> None:
source = tmp_path / "source.fit"
source.write_bytes(_build_fixture())
output = tmp_path / "output.fit"
result = convert_fit_device(source, output)
assert isinstance(result, FitConversionResult)
assert result.source_path == source
assert result.output_path == output
assert result.file_crc is not None
assert result.patched_field_count > 0
values = read_device_field_values(output)
values_by_key = {(v.global_message_num, v.field_num): v.value for v in values}
assert values_by_key[(FILE_ID_MESG_NUM, 1)] == 1
assert values_by_key[(FILE_ID_MESG_NUM, 2)] == 3570
def test_convert_fit_device_patches_creator_and_leaves_sensor_untouched(
tmp_path: Path,
) -> None:
source = tmp_path / "source.fit"
source.write_bytes(_build_fixture())
output = tmp_path / "output.fit"
convert_fit_device(source, output)
device_info_records = _read_device_info_records(output)
assert len(device_info_records) == 2
creator_values, sensor_values = device_info_records
# creator (device_index=0) patched to Edge 1030 Plus
assert creator_values[0] == 0
assert creator_values[2] == 1
assert creator_values[4] == 3570
# sensor (device_index=1) must remain completely untouched
assert sensor_values[0] == 1
assert sensor_values[2] == 32
assert sensor_values[4] == 1234
def test_convert_fit_device_leaves_device_info_without_device_index_untouched(
tmp_path: Path,
) -> None:
source = tmp_path / "source.fit"
source.write_bytes(_build_no_device_index_fixture())
output = tmp_path / "output.fit"
convert_fit_device(source, output)
device_info_records = _read_device_info_records(output)
assert len(device_info_records) == 1
values = device_info_records[0]
# device_info without a device_index field must remain completely untouched
assert values[2] == 32
assert values[4] == 1234
def test_convert_fit_device_patches_product_name_string_fields(tmp_path: Path) -> None:
"""file_id field 8 and device_info field 27 (creator) are both string fields, written via
_write_field_value's string branch (buffer sizing + null-terminator handling). Neither is
exercised by the numeric-only base fixture used elsewhere in this module."""
source = tmp_path / "source.fit"
source.write_bytes(_build_product_name_fixture())
output = tmp_path / "output.fit"
convert_fit_device(source, output)
values = read_device_field_values(output)
values_by_key = {(v.global_message_num, v.field_num): v.value for v in values}
assert values_by_key[(FILE_ID_MESG_NUM, 8)] == "Edge 1030 Plus"
assert values_by_key[(DEVICE_INFO_MESG_NUM, 27)] == "Edge 1030 Plus"
def test_convert_fit_device_round_trips_big_endian_fields(tmp_path: Path) -> None:
"""Proves both the read side (parsing manufacturer/product under architecture=1)
and the write side (patching them back in big-endian byte order) are correct --
a byte-order bug on either side would flip 255/999 or the patched 1/3570 into an
unrelated value once read back with the (still big-endian) definition."""
source = tmp_path / "source.fit"
source.write_bytes(_build_big_endian_fixture())
output = tmp_path / "output.fit"
convert_fit_device(source, output)
values = read_device_field_values(output)
values_by_key = {(v.global_message_num, v.field_num): v.value for v in values}
assert values_by_key[(FILE_ID_MESG_NUM, 1)] == 1
assert values_by_key[(FILE_ID_MESG_NUM, 2)] == 3570
def test_convert_fit_device_defaults_to_edge_1030_plus() -> None:
device = GarminDevice()
assert device.manufacturer_id == 1
assert device.product_id == 3570
assert device.product_name == "Edge 1030 Plus"
def test_convert_fit_device_rejects_invalid_container(tmp_path: Path) -> None:
source = tmp_path / "bad.fit"
source.write_bytes(b"not a fit file")
output = tmp_path / "output.fit"
with pytest.raises(FitFormatError):
convert_fit_device(source, output)
def test_read_device_field_values_returns_device_field_value_instances(tmp_path: Path) -> None:
source = tmp_path / "source.fit"
source.write_bytes(_build_fixture())
values = read_device_field_values(source)
assert all(isinstance(v, DeviceFieldValue) for v in values)
assert any(v.global_message_num == FILE_ID_MESG_NUM for v in values)
def test_zero_size_field_definition_is_rejected(tmp_path: Path) -> None:
"""A zero-size field is illegal FIT and must be rejected during parsing, so a
zero-size device_index can never make a sensor record read as the creator."""
source = tmp_path / "zero-size.fit"
source.write_bytes(_build_zero_size_device_index_fixture())
output = tmp_path / "output.fit"
assert is_fit_file(source) is False
with pytest.raises(FitFormatError):
convert_fit_device(source, output)
assert not output.exists()
def test_value_too_large_for_declared_field_size_raises_fit_format_error(tmp_path: Path) -> None:
"""A 32-bit serial number against a 2-byte serial field must surface as
FitFormatError, not a raw struct.error escaping the public API."""
source = tmp_path / "source.fit"
source.write_bytes(_build_undersized_serial_fixture())
output = tmp_path / "output.fit"
with pytest.raises(FitFormatError):
convert_fit_device(source, output, GarminDevice(serial_number=4294967295))
assert not output.exists()
def test_product_id_too_large_for_one_byte_field_raises_fit_format_error(tmp_path: Path) -> None:
source = tmp_path / "source.fit"
source.write_bytes(_build_undersized_product_fixture())
output = tmp_path / "output.fit"
with pytest.raises(FitFormatError):
convert_fit_device(source, output)
assert not output.exists()
def test_unwritable_product_name_fails_conversion(tmp_path: Path) -> None:
"""A product_name field too small for the target string used to be silently left
unpatched while manufacturer/product reported success. The post-patch read-back
verification must turn that partial patch into a controlled failure."""
source = tmp_path / "source.fit"
source.write_bytes(_build_undersized_product_name_fixture())
output = tmp_path / "output.fit"
with pytest.raises(FitFormatError):
convert_fit_device(source, output)
assert not output.exists()
def test_read_device_field_values_marks_creator_records(tmp_path: Path) -> None:
"""The creator device_info and every file_id field are is_creator=True; a paired
sensor's device_info fields are is_creator=False, so a caller building a
{(mesg, field): value} dict can no longer be shadowed by sensor values."""
source = tmp_path / "source.fit"
source.write_bytes(_build_fixture())
output = tmp_path / "output.fit"
convert_fit_device(source, output)
values = read_device_field_values(output)
assert all(v.is_creator for v in values if v.global_message_num == FILE_ID_MESG_NUM)
creator = {
(v.global_message_num, v.field_num): v.value
for v in values
if v.global_message_num == DEVICE_INFO_MESG_NUM and v.is_creator
}
sensor = {
(v.global_message_num, v.field_num): v.value
for v in values
if v.global_message_num == DEVICE_INFO_MESG_NUM and not v.is_creator
}
assert creator[(DEVICE_INFO_MESG_NUM, 2)] == 1
assert creator[(DEVICE_INFO_MESG_NUM, 4)] == 3570
assert sensor[(DEVICE_INFO_MESG_NUM, 2)] == 32
assert sensor[(DEVICE_INFO_MESG_NUM, 4)] == 1234
def test_device_info_without_device_index_is_not_creator(tmp_path: Path) -> None:
source = tmp_path / "source.fit"
source.write_bytes(_build_no_device_index_fixture())
values = read_device_field_values(source)
device_info_values = [v for v in values if v.global_message_num == DEVICE_INFO_MESG_NUM]
assert device_info_values
assert all(v.is_creator is False for v in device_info_values)