Skip to content

Clamp both bounds of slice to avoid trapping on truncated records - #6

Merged
RISCfuture merged 1 commit into
mainfrom
fix/slice-lower-bound-clamp
Sep 8, 2026
Merged

Clamp both bounds of slice to avoid trapping on truncated records#6
RISCfuture merged 1 commit into
mainfrom
fix/slice-lower-bound-clamp

Conversation

@RISCfuture

Copy link
Copy Markdown
Owner

The bug

RandomAccessCollection.slice(_:) (Sources/SwiftCIFP/Parser/ByteParsing.swift)
clamped its upper bound but not its lower bound:

let lower = startIndex + range.lowerBound
let upper = startIndex + range.upperBound
return self[lower..<Swift.min(upper, endIndex)]

When range.lowerBound exceeds count, lower > upper and the subscript traps
with Fatal error: Range requires lowerBound <= upperBound — an uncatchable
_assertionFailure, not a Swift error.

This is reachable from ordinary input. CIFPByteParser.parseRecord only checks
bytes.count >= 12 (and >= 13 for the P/H subsections) before dispatching,
but the record parsers then slice far past that — up to lower bound 117 in
+PathPoint.swift, 112 in +Heliport.swift and +Procedure.swift, 98 in
+Waypoint.swift, 95 in +Airport.swift, 93 in +Navaid.swift and
+Airspace.swift. A 13-byte airport record (SUSAP KLAXK2A) reaches
parseAirport's bytes.slice(27..<30) and takes down the process.

The trap defeats the library's own error design: CIFP.init(data:…) wraps
parseRecord in do/catch and routes failures to errorCallback, deliberately
making a malformed record non-fatal for the host app. A trap bypasses that entirely.

This is a pre-existing bug in short-line handling. Its trigger is line
truncation — nothing to do with any particular NASR/CIFP cycle or with field
widening.

The fix

Clamp both bounds, so an out-of-range field reads as an empty subsequence:

let lower = Swift.min(startIndex + range.lowerBound, endIndex),
  upper = Swift.min(startIndex + range.upperBound, endIndex)
return self[lower..<upper]

Empty is the right contract here, verified against the callers rather than
assumed. Single-byte field reads in the same files already tolerate short lines
the same way — bytes.count > 28 ? bytes[bytes.startIndex + 28] : ASCII.space
(+Navaid.swift:25, +Procedure.swift:58, and ~40 more) — so short lines were
anticipated for scalar reads and missed only for slices. And every record parser
except one guards a mandatory field that lives past column 12 (coordinate,
magnetic variation, elevation, sequence number). On a truncated line those
now-empty slices parse to nil and the parser throws
CIFPError.missingRequiredField, which reaches errorCallback like any other
parse failure. Empty slices restore the intended error path rather than papering
over it.

One caller that does not report a truncated record — flagging, not fixing

parsePathPointPrimary (Sources/SwiftCIFP/Parser/RecordParsers/CIFPByteParser+PathPoint.swift)
is the only primary-record parser with no required-field guard. Every field it
reads is optional in the PathPoint model, so a line truncated after column 10
yields a record with a real airportId and nil for everything else, which
buildPathPoints folds into that airport with no error reported. That is a
silent partial record, not a misparse — no field takes a wrong value — and it is
strictly better than today's trap, but it is worth knowing about.

I left it alone deliberately: deciding which fields make a PathPoint valid is a
change to the record model's contract, not part of fixing the trap.
PathPoint.coordinate is Coordinate? by design, so a sparse path point is
representable on purpose and a guard would need a considered rule about what
"required" means here. Happy to follow up if you want one.

The two continuation parsers (parsePathPointContinuation,
parseApproachContinuation) are also unguarded, but their payloads are
all-optional by construction — a continuation carrying nothing merges nothing.

Tests

Two tests, both in Tests/SwiftCIFPTests/SwiftCIFPTests.swift:

  • returns an empty slice for a range beginning past the end, added to the
    existing ByteParsing tests suite next to slices a byte range.
  • reports a truncated record through the error callback, which drives the
    public CIFP(data:errorCallback:) entry point with a truncated airport record
    and asserts the error arrives on the callback for line 1. Without the fix this
    test does not fail — it kills the test runner.

I also ran a throwaway fuzz over every section and subsection code at every
truncation length from 0 to 140 bytes: it trapped before the fix and passes
after. It is not included in the PR, since slice(_:) being total is now what
makes all those paths safe and a broad fuzz adds little over the targeted tests.

Verification

$ swift build
Build complete! (1.11s)

$ swift test
Test run with 69 tests in 15 suites passed after 0.002 seconds.

$ swiftlint --strict
Done linting! Found 0 violations, 0 serious in 75 files.

$ swift format lint --strict -r .        # with the CI .swift-format config
(no output, exit 0)

$ swift package generate-documentation --target SwiftCIFP --warnings-as-errors
Finished building documentation for 'SwiftCIFP' (0.62s)

CHANGELOG.md gains an ## [Unreleased] section. No version bump, no tag.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QZ3UWydnavm3g9Gs62Xirb

slice(_:) clamped only its upper bound, so a field whose range began
past the end of a short line produced an inverted range and an
uncatchable runtime failure. A 13-byte airport record was enough to
take down the host app.

Clamping both bounds makes an out-of-range field read as empty, which
the record parsers already handle: their required-field guards throw
CIFPError.missingRequiredField, so the bad record reaches errorCallback
like any other parse failure instead of killing the process. This
matches the short-line tolerance the single-byte field reads have had
all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ3UWydnavm3g9Gs62Xirb
@RISCfuture
RISCfuture merged commit b94b338 into main Sep 8, 2026
6 checks passed
@RISCfuture
RISCfuture deleted the fix/slice-lower-bound-clamp branch September 8, 2026 21:21
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.

1 participant