Fix ADIF parser crash on records with multi-byte UTF-8 characters

Regex.scan with `return: :index` reports BYTE offsets, but String.slice/3
uses CHARACTER offsets. When a record contained a multi-byte UTF-8
character in an earlier field (e.g. "café" in NOTES), every subsequent
field's slice shifted one byte left, eventually landing on a literal
">" that crashed String.to_integer/1 with ArgumentError.

Switch to :binary.part/3 which is byte-based and matches what Regex.scan
reports. Also simplify the value offset calculation to use the scanned
tag_len directly instead of re-splitting the tag text. Reproduced in a
new regression test.

Prod stack trace: AdifImport.parse_fields/1 crashed on upload with
binary_to_integer(">") -- a FreeDV/N1MM logger that embedded UTF-8
characters in a NOTES field triggered it.
This commit is contained in:
Graham McIntire 2026-04-12 15:13:59 -05:00
parent 58cb8bb5bc
commit 265669fc3a
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 28 additions and 8 deletions

View file

@ -159,20 +159,23 @@ defmodule Microwaveprop.Radio.AdifImport do
|> Enum.reject(&(&1 == %{}))
end
# Regex.scan with `return: :index` returns BYTE offsets. We must use
# `:binary.part/3` (byte-based) rather than `String.slice/3` (character-
# based); otherwise multi-byte UTF-8 characters earlier in the record
# (e.g. an accented NOTES field) shift every subsequent field and we
# wind up slicing the wrong substring.
defp parse_fields(record_string) do
~r/<([^:>]+):(\d+)(?::[^>]*)?>/i
|> Regex.scan(record_string, return: :index)
|> Enum.reduce(%{}, fn indices, acc ->
[{tag_start, _tag_len}, {name_start, name_len}, {size_start, size_len} | _] = indices
raw_name = record_string |> String.slice(name_start, name_len) |> String.upcase()
[{tag_start, tag_len}, {name_start, name_len}, {size_start, size_len} | _] = indices
raw_name = record_string |> :binary.part(name_start, name_len) |> String.upcase()
name = strip_app_prefix(raw_name)
size = record_string |> String.slice(size_start, size_len) |> String.to_integer()
size = record_string |> :binary.part(size_start, size_len) |> String.to_integer()
# Value starts right after the closing >
# Find the > after the tag
tag_text = String.slice(record_string, tag_start, 999)
value_offset = tag_start + (tag_text |> String.split(">", parts: 2) |> List.first() |> byte_size()) + 1
value = String.slice(record_string, value_offset, size)
# Value starts right after the closing > of the tag.
value_offset = tag_start + tag_len
value = :binary.part(record_string, value_offset, size)
# Standard ADIF fields take precedence over app-defined ones
if raw_name != name and Map.has_key?(acc, name) do

View file

@ -295,5 +295,22 @@ defmodule Microwaveprop.Radio.AdifImportTest do
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
assert length(preview.valid) == 1
end
test "parses records containing UTF-8 characters in earlier fields" do
# Regression: Regex.scan with `return: :index` returns byte offsets,
# but String.slice/3 uses character offsets. A multi-byte UTF-8 char
# (é = 2 bytes) in a NOTES field earlier in the record used to shift
# every subsequent field's character-based slice one byte left,
# causing a crash when the size slice landed on a ">" character.
adif = """
<NOTES:6>caf\u00e9 QSO<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
assert [row] = preview.valid
assert row.attrs["station1"] == "W5XD"
assert row.attrs["station2"] == "K5TR"
assert row.attrs["band"] == "10000"
end
end
end