Skip to content

Narrow field values with truncatingIfNeeded instead of the trapping initializer - #15

Merged
roznet merged 1 commit into
roznet:mainfrom
JanLahmann:narrowing-conversions-truncate
Sep 21, 2026
Merged

roznet merged 1 commit into
roznet:mainfrom
JanLahmann:narrowing-conversions-truncate

Conversation

@JanLahmann

Copy link
Copy Markdown
Contributor

What

rzfit_swift_string_for_type narrows a FIT_UINT32 into the field's own type (FIT_ENUM, FIT_UINT8, FIT_UINT16, …) with the exact initializer:

case 1: return rzfit_swift_string_from_file( FIT_ENUM(val) )

FIT_ENUM(val) traps when val does not fit a byte. A structurally valid FIT can carry such a value — a vendor's private enum, or one corrupt byte in an otherwise good file — and then FitFile.init takes the whole process down before the caller sees a line of it. There is nothing to catch: it is a Swift runtime trap, not an error.

This PR changes the one line in the generator (python/fitsdkparser.py, swift_stmt_case_type_function_call) and the generated rzfit_swift_map.swift it produces to truncatingIfNeeded:, which is what the C SDK does when it reads the same bytes into the narrower type, and adds a test with a value that does not fit an enum and one that does not fit a uint16. The generated diff is 179 lines, all of the shape FIT_X(val)FIT_X(truncatingIfNeeded: val).

How it was found

A mutation fuzz over a corpus of Garmin recordings in a wing-foiling app that imports a rider's whole year from intervals.icu: one tester's first sync crashed the app on their first activity, the crash was inside the decoder, and no amount of validation on our side (header, CRC, message framing) could see it, because the file is valid. We are shipping a vendored copy with this change until it is upstream; thank you for the library.

…nitializer

rzfit_swift_string_for_type narrows a FIT_UINT32 into the field's own type (FIT_ENUM,
FIT_UINT8, FIT_UINT16, ...) with the exact initializer, which traps when the value does
not fit. A structurally valid file can carry such a value - a vendor's private enum, or
one corrupt byte - and then FitFile.init takes the whole process down before the caller
sees a line of it. This changes the generator template and the generated map to
truncatingIfNeeded:, which is what the C SDK does when it reads the same bytes into the
narrower type, and adds a test with a value that does not fit an enum and one that does
not fit a uint16.

Found by a mutation fuzz over a corpus of Garmin recordings in a wing-foiling app that
imports a rider's whole year from intervals.icu.
@roznet

roznet commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Thanks a lot for this, and for the very clear write-up — the trap is real and a library should never take the host app down on a bad (or merely unusual) file. Tests pass locally and the generated diff is exactly the mechanical change described.

One refinement I'm going to make as a follow-up on main right after merging: truncatingIfNeeded turns an out-of-range value into a plausible but wrong name. E.g. in the new test, fit_type: 1, val: 0x1_0001 truncates to 1 and returns "device". I'd rather not fabricate a label, so the follow-up switches the generator to FIT_X(exactly: val) and falls back to the existing default "fit_type_\(fit_type)_\(val)" when the value doesn't fit, keeping the raw value visible. The test will assert the exact fallback string.

I'll also fix the same class of trap in FitMessage.swift (FIT_UINT16(val) on a Double for native_field_num).

Merging now — thanks again!

@roznet
roznet merged commit 6f50aa1 into roznet:main Sep 21, 2026
1 check passed
roznet added a commit that referenced this pull request Sep 21, 2026
Follow-up to #15: truncatingIfNeeded avoided the trap but mapped out of range
values to a plausible but wrong name (e.g. file 0x1_0001 -> "device").
The generator now emits FIT_X(exactly: val) and falls back to the default
"fit_type_<type>_<val>" string, keeping the raw value visible.

Also guard FIT_UINT16(val) for field_description native_field_num, which
could trap on an out of range or NaN double.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
roznet added a commit that referenced this pull request Sep 21, 2026
Record the exactly:/fallback choice for out-of-range typed values
(PR #15 follow-up) in code-generation, fit-message and fit-file docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JanLahmann added a commit to JanLahmann/WingFoil that referenced this pull request Sep 22, 2026
The vendored copy of 1.5.2 existed for one word: the generated map narrowed a
FIT_UINT32 into the field's own type with the trapping initializer, so a structurally
valid FIT carrying a wider value killed the process inside FitFile.init before a line
of ours ran. The mutation fuzz found it; roznet/FitFileParser#15 fixed it upstream and
was merged on 21 September 2026.

The newest release, 1.5.2, predates the fix by three years, so there is no tag to ask
for: the pin is .revision("6f50aa1…") on the merge commit, and Package.resolved records
the same forty hex digits. It moves to .exact("…") the day upstream cuts a release.

The merge also carries Garmin's FIT SDK 21.158 in place of 21.115, so the move was
taken with the goldens and the whole fuzz behind it — swift test (1190 tests, 88
suites) and FUZZ_ALL=1 swift test --filter MutationFuzz over every recording in the
corpus (96 damaged mutants refused, 96 repaired ones read or refused, none trapped).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mg2rYYNYmQjnvCxJQwsEb7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants