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

@@ -11,6 +11,7 @@ FILE_ID_MESG_NUM = 0
DEVICE_INFO_MESG_NUM = 23
RECORD_MESG_NUM = 20
HEADER_SIZE = 14
PRODUCT_NAME_SIZE = 24
@dataclass(frozen=True)
@@ -19,6 +20,8 @@ class ComplexFixture:
metadata_offsets: set[int]
file_id_manufacturer_offset: int
file_id_product_offset: int
file_id_product_name_offset: int
file_id_product_name_size: int
creator_manufacturer_offset: int
creator_product_offset: int
preserved_ranges: tuple[tuple[int, int], ...]
@@ -45,13 +48,24 @@ def _build_complex_fixture() -> ComplexFixture:
def pos() -> int:
return HEADER_SIZE + len(records)
# 1. normal file_id definition/data pair.
records.extend(definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84)]))
# 1. normal file_id definition/data pair, including the product_name string
# field (8) so the string write path -- which zero-fills the whole declared
# field, the riskiest byte-preservation behavior in the patcher -- is covered
# by the byte-preservation proof and not only by the patching tests.
records.extend(
definition(
0,
FILE_ID_MESG_NUM,
[(1, 2, 0x84), (2, 2, 0x84), (8, PRODUCT_NAME_SIZE, 0x07)],
)
)
file_id_data_start = pos()
records.extend(data(0, struct.pack("<HH", 255, 999)))
original_product_name = b"MyWhoosh Simulator\x00".ljust(PRODUCT_NAME_SIZE, b"\x2A")
records.extend(data(0, struct.pack("<HH", 255, 999) + original_product_name))
file_id_manufacturer_offset = file_id_data_start + 1 # +1 for the record header byte
file_id_product_offset = file_id_manufacturer_offset + 2 # manufacturer is a u16
file_id_product_name_offset = file_id_product_offset + 2 # product is a u16
# 2. device_info definition with a creator record (device_index == 0).
records.extend(definition(1, DEVICE_INFO_MESG_NUM, [(0, 1, 0x02), (2, 2, 0x84), (4, 2, 0x84)]))
@@ -88,6 +102,9 @@ def _build_complex_fixture() -> ComplexFixture:
metadata_offsets: set[int] = set()
metadata_offsets.update(range(file_id_manufacturer_offset, file_id_manufacturer_offset + 2))
metadata_offsets.update(range(file_id_product_offset, file_id_product_offset + 2))
metadata_offsets.update(
range(file_id_product_name_offset, file_id_product_name_offset + PRODUCT_NAME_SIZE)
)
metadata_offsets.update(range(creator_manufacturer_offset, creator_manufacturer_offset + 2))
metadata_offsets.update(range(creator_product_offset, creator_product_offset + 2))
@@ -96,6 +113,8 @@ def _build_complex_fixture() -> ComplexFixture:
metadata_offsets=metadata_offsets,
file_id_manufacturer_offset=file_id_manufacturer_offset,
file_id_product_offset=file_id_product_offset,
file_id_product_name_offset=file_id_product_name_offset,
file_id_product_name_size=PRODUCT_NAME_SIZE,
creator_manufacturer_offset=creator_manufacturer_offset,
creator_product_offset=creator_product_offset,
preserved_ranges=(
@@ -152,7 +171,7 @@ def test_complex_fixture_patches_targets_and_preserves_advanced_records(
result = convert_fit_device(source, output)
assert is_fit_file(output) is True
assert result.patched_field_count == 4
assert result.patched_field_count == 5
after = output.read_bytes()
assert struct.unpack_from("<H", after, _FIXTURE.file_id_manufacturer_offset)[0] == 1
@@ -172,6 +191,61 @@ def test_complex_fixture_patches_targets_and_preserves_advanced_records(
)
def test_product_name_string_write_stays_inside_its_declared_field(
tmp_path: Path, complex_fit_bytes: bytes
) -> None:
"""The string write path zero-fills the *entire* declared field. Prove that the
rewrite is confined to the product_name field's own bytes: the target string plus
a null terminator plus zero padding, with the surrounding record bytes untouched
(the enclosing preservation test already asserts the global changed-byte set)."""
source = tmp_path / "source.fit"
output = tmp_path / "output.fit"
source.write_bytes(complex_fit_bytes)
convert_fit_device(source, output)
start = _FIXTURE.file_id_product_name_offset
end = start + _FIXTURE.file_id_product_name_size
after = output.read_bytes()
expected = b"Edge 1030 Plus\x00".ljust(_FIXTURE.file_id_product_name_size, b"\x00")
assert after[start:end] == expected
# The source deliberately padded past its null terminator with 0x2A bytes, so a
# write that overran (or under-cleared) the field would be visible here.
assert complex_fit_bytes[start:end] != expected
values = read_device_field_values(output)
assert any(
v.global_message_num == FILE_ID_MESG_NUM
and v.field_num == 8
and v.value == "Edge 1030 Plus"
and v.is_creator
for v in values
)
def test_convert_fit_device_supports_12_byte_header(tmp_path: Path) -> None:
"""12-byte headers carry no header CRC field, so conversion must succeed and
report header_crc=None while still rewriting the file CRC."""
file_def = definition(0, FILE_ID_MESG_NUM, [(1, 2, 0x84), (2, 2, 0x84)])
file_data = data(0, struct.pack("<HH", 255, 999))
source = tmp_path / "source12.fit"
source.write_bytes(make_fit(file_def + file_data, header_size=12))
output = tmp_path / "output12.fit"
result = convert_fit_device(source, output)
assert result.header_crc is None
assert result.file_crc is not None
assert result.patched_field_count == 2
assert is_fit_file(output) is True
assert output.read_bytes()[0] == 12
values = {(v.global_message_num, v.field_num): v.value for v in read_device_field_values(output)}
assert values[(FILE_ID_MESG_NUM, 1)] == 1
assert values[(FILE_ID_MESG_NUM, 2)] == 3570
def test_truncated_definition_is_non_recoverable(tmp_path: Path) -> None:
path = tmp_path / "truncated.fit"
path.write_bytes(make_fit(bytes([0x40, 0x00, 0x00])))