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>
This commit is contained in:
Bastian Wagner
2026-08-15 14:29:40 +02:00
parent 95aa8507f0
commit ef9ca9eeca
5 changed files with 343 additions and 37 deletions

View File

@@ -8,6 +8,7 @@ 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
@@ -74,6 +75,44 @@ def _build_product_name_fixture() -> bytes:
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."""
@@ -214,3 +253,93 @@ def test_read_device_field_values_returns_device_field_value_instances(tmp_path:
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)