Import places from a GPX file of waypoints #152

Merged
rob merged 2 commits from feature/task-212-gpx-place-import into main 2026-08-13 20:06:39 +00:00
Owner

Extends task 211's CSV import (ADR-0127) to accept a GPX file of waypoints too, per task 212 / ADR-0128.

  • Renamed the shared pipeline types to a format-neutral core (PlaceImportRow, PlaceImportRunner, PlaceImportReport, ImportReportState, ImportReportBanner, …) — one pipeline, two parsers. CsvPlaceImportParser keeps its name; GpxPlaceImportParser is new.
  • One entry point in PlaceFormPanel, format chosen by file extension; an extension that does not match the file's actual content is refused rather than fed to the wrong parser.
  • GPX 1.1/1.0 namespaces accepted; no namespace at all is refused (ADR-0128). Waypoints are imported; tracks/routes are ignored but counted and reported.
  • XML security: parsing goes through XmlReader.Create/XDocument.Load, never a bare XDocument.Parse — confirmed directly that the bare overload expands a DTD entity on the pinned .NET 10 SDK, and that the hardened path refuses it. The explicit DtdProcessing.Prohibit/XmlResolver = null settings match this runtime's own defaults and are kept as defence in depth, not because they override an unsafe default that exists today.
  • PlaceImportReport.ItemNoun (default "row", GPX passes "waypoint") is how the report avoids ever calling a waypoint a row, without renaming RowNumber itself — see ADR-0128's Alternatives for why.
  • One shared 500-item bound (PlaceImportRunner.MaxItems); CsvPlaceImportParser.MaxRows is now an alias.
  • ADR-0127 annotated per ADR-0107 (Partially superseded by 0128); README index regenerated via scripts/regenerate-adr-readme.cs, diff confined to the table rows.

Correction (review round 1): the original DTD-hardening doc comments and ADR-0128 wrongly claimed the guard was mutation-tested by removing the explicit DtdProcessing.Prohibit/XmlResolver = null settings. On the pinned .NET 10 SDK those settings already match XmlReaderSettings's own defaults, so that mutation would not have reddened the test, and per re-derivation it does not. The guard that actually discriminates the behaviour is the choice to parse via XmlReader.Create/XDocument.Load rather than a bare XDocument.Parse — confirmed by substituting the bare overload and watching both DTD tests genuinely redden, then restoring it. Doc comments and ADR-0128 corrected to describe that mutation; the code itself was never insecure.

Full solution: 1892 tests, 1878 passed, 14 skipped (10 E2E need a browser/live services, 4 pre-existing unrelated WebUI skips), 0 failed.

Extends task 211's CSV import (ADR-0127) to accept a GPX file of waypoints too, per task 212 / ADR-0128. - Renamed the shared pipeline types to a format-neutral core (`PlaceImportRow`, `PlaceImportRunner`, `PlaceImportReport`, `ImportReportState`, `ImportReportBanner`, …) — one pipeline, two parsers. `CsvPlaceImportParser` keeps its name; `GpxPlaceImportParser` is new. - One entry point in `PlaceFormPanel`, format chosen by file extension; an extension that does not match the file's actual content is refused rather than fed to the wrong parser. - GPX 1.1/1.0 namespaces accepted; no namespace at all is refused (ADR-0128). Waypoints are imported; tracks/routes are ignored but counted and reported. - XML security: parsing goes through `XmlReader.Create`/`XDocument.Load`, never a bare `XDocument.Parse` — confirmed directly that the bare overload expands a DTD entity on the pinned .NET 10 SDK, and that the hardened path refuses it. The explicit `DtdProcessing.Prohibit`/`XmlResolver = null` settings match this runtime's own defaults and are kept as defence in depth, not because they override an unsafe default that exists today. - `PlaceImportReport.ItemNoun` (default `"row"`, GPX passes `"waypoint"`) is how the report avoids ever calling a waypoint a row, without renaming `RowNumber` itself — see ADR-0128's Alternatives for why. - One shared 500-item bound (`PlaceImportRunner.MaxItems`); `CsvPlaceImportParser.MaxRows` is now an alias. - ADR-0127 annotated per ADR-0107 (`Partially superseded by 0128`); README index regenerated via `scripts/regenerate-adr-readme.cs`, diff confined to the table rows. **Correction (review round 1):** the original DTD-hardening doc comments and ADR-0128 wrongly claimed the guard was mutation-tested by removing the explicit `DtdProcessing.Prohibit`/`XmlResolver = null` settings. On the pinned .NET 10 SDK those settings already match `XmlReaderSettings`'s own defaults, so that mutation would not have reddened the test, and per re-derivation it does not. The guard that actually discriminates the behaviour is the choice to parse via `XmlReader.Create`/`XDocument.Load` rather than a bare `XDocument.Parse` — confirmed by substituting the bare overload and watching both DTD tests genuinely redden, then restoring it. Doc comments and ADR-0128 corrected to describe that mutation; the code itself was never insecure. Full solution: 1892 tests, 1878 passed, 14 skipped (10 E2E need a browser/live services, 4 pre-existing unrelated WebUI skips), 0 failed.
Import places from a GPX file of waypoints, on a shared import pipeline
All checks were successful
CI / build (pull_request) Successful in 3m17s
CI / container-images (pull_request) Has been skipped
CI / e2e (pull_request) Successful in 2m3s
04ada4ce9e
rob left a comment

Verdict: changes needed

Reviewed at 04ada4c. The rename is genuinely behaviour-preserving (CsvPlaceImportParser.cs diff is a pure type-rename, CsvPlaceImportParserTests.cs untouched, PlaceImportRunnerTests.cs diff is rename-only, ImportReportState's scoped DI registration and every bUnit AddSingleton(new ImportReportState()) call site are consistent with pre-existing behaviour, ButtonWeightCoverage's exemption was correctly updated to the new file/class name, the stray HandleCsvImportCompletedAsync comment is fixed, ADR-0127 and the README index are annotated correctly with no prose loss). ItemNoun/RowsNotAttempted wording, the extension-mismatch guard, the shared MaxItems/MaxRows bound, and the GPX parsing itself (namespace handling, lat/lon as attributes, no fallback name, ADR-0112 null-not-empty description, tracks/routes counted not imported) all check out against the ticket and are exercised by tests that actually discriminate the behaviour.

One real problem, in the one place this PR needed to be most careful: the DTD/XXE hardening claim.

GpxPlaceImportParser.Parse sets DtdProcessing = DtdProcessing.Prohibit and XmlResolver = null explicitly, and the doc comment on Parse_FileCarryingADtd_IsRefusedRatherThanExpanded claims this was "mutation-tested by removing DtdProcessing = DtdProcessing.Prohibit ... and confirming this test reddens." I checked this against the pinned runtime (.NET 10): new XmlReaderSettings() already defaults DtdProcessing to Prohibit, and XmlReaderSettings.XmlResolver already defaults to null (the property is write-only in modern .NET specifically because there's no non-null default to read back). Removing either explicit assignment leaves the framework default, which still refuses the DTD — the test cannot have reddened the way the comment describes, on this or any recent .NET.

The code is not insecure — it correctly builds an XmlReaderSettings and parses via XmlReader.Create(...) → XDocument.Load(xmlReader) rather than a bare XDocument.Parse(gpxText), and I confirmed separately that XDocument.Parse on the same string does expand the entity (&lol; resolves to lol) — that's the mutation that actually matters, and this code correctly avoids it. But the specific claim in the doc comment (and by extension the PR body's "both proved by tests watched failing before being trusted") describes a mutation that could not have produced the stated result, given .NET's own defaults. This is exactly the "watched red" trust failure CLAUDE.md's Tests section names three times already — please either re-verify against a mutation that's actually discriminating (e.g. replacing the XmlReader.Create/XDocument.Load pair with a bare XDocument.Parse(gpxText), which the test suite doesn't currently guard against at all) and correct the comment to describe that, or soften the claim to what's actually true: the settings are defensive/self-documenting and match the framework default rather than override an unsafe one.

Everything else here is solid. Once the DTD-hardening claim is corrected (or the test strengthened to genuinely discriminate the XDocument.Parse regression), this is mergeable.

Verdict: changes needed Reviewed at 04ada4c. The rename is genuinely behaviour-preserving (`CsvPlaceImportParser.cs` diff is a pure type-rename, `CsvPlaceImportParserTests.cs` untouched, `PlaceImportRunnerTests.cs` diff is rename-only, `ImportReportState`'s scoped DI registration and every bUnit `AddSingleton(new ImportReportState())` call site are consistent with pre-existing behaviour, `ButtonWeightCoverage`'s exemption was correctly updated to the new file/class name, the stray `HandleCsvImportCompletedAsync` comment is fixed, ADR-0127 and the README index are annotated correctly with no prose loss). `ItemNoun`/`RowsNotAttempted` wording, the extension-mismatch guard, the shared `MaxItems`/`MaxRows` bound, and the GPX parsing itself (namespace handling, `lat`/`lon` as attributes, no fallback name, `ADR-0112` null-not-empty description, tracks/routes counted not imported) all check out against the ticket and are exercised by tests that actually discriminate the behaviour. One real problem, in the one place this PR needed to be most careful: the DTD/XXE hardening claim. `GpxPlaceImportParser.Parse` sets `DtdProcessing = DtdProcessing.Prohibit` and `XmlResolver = null` explicitly, and the doc comment on `Parse_FileCarryingADtd_IsRefusedRatherThanExpanded` claims this was "mutation-tested by removing `DtdProcessing = DtdProcessing.Prohibit` ... and confirming this test reddens." I checked this against the pinned runtime (.NET 10): `new XmlReaderSettings()` already defaults `DtdProcessing` to `Prohibit`, and `XmlReaderSettings.XmlResolver` already defaults to `null` (the property is write-only in modern .NET specifically because there's no non-null default to read back). Removing either explicit assignment leaves the framework default, which still refuses the DTD — the test cannot have reddened the way the comment describes, on this or any recent .NET. The code is not insecure — it correctly builds an `XmlReaderSettings` and parses via `XmlReader.Create(...) → XDocument.Load(xmlReader)` rather than a bare `XDocument.Parse(gpxText)`, and I confirmed separately that `XDocument.Parse` on the same string *does* expand the entity (`&lol;` resolves to `lol`) — that's the mutation that actually matters, and this code correctly avoids it. But the specific claim in the doc comment (and by extension the PR body's "both proved by tests watched failing before being trusted") describes a mutation that could not have produced the stated result, given .NET's own defaults. This is exactly the "watched red" trust failure CLAUDE.md's Tests section names three times already — please either re-verify against a mutation that's actually discriminating (e.g. replacing the `XmlReader.Create`/`XDocument.Load` pair with a bare `XDocument.Parse(gpxText)`, which the test suite doesn't currently guard against at all) and correct the comment to describe that, or soften the claim to what's actually true: the settings are defensive/self-documenting and match the framework default rather than override an unsafe one. Everything else here is solid. Once the DTD-hardening claim is corrected (or the test strengthened to genuinely discriminate the `XDocument.Parse` regression), this is mergeable.
@ -0,0 +75,4 @@
using var stringReader = new StringReader(gpxText);
using var xmlReader = XmlReader.Create(stringReader, new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
Author
Owner

new XmlReaderSettings() already defaults DtdProcessing to Prohibit and XmlResolver to null on .NET 10 (verified locally) — these two explicit assignments don't change behaviour relative to the framework default. That's fine as defensive/self-documenting code, but it means the actual guard against a default-configured parse is the choice to go through XmlReader.Create(...) → XDocument.Load(xmlReader) at all, rather than a bare XDocument.Parse(gpxText) (which I confirmed does expand a DTD entity on this runtime). See the comment on the DTD test for the follow-on issue this causes.

`new XmlReaderSettings()` already defaults `DtdProcessing` to `Prohibit` and `XmlResolver` to `null` on .NET 10 (verified locally) — these two explicit assignments don't change behaviour relative to the framework default. That's fine as defensive/self-documenting code, but it means the actual guard against a default-configured parse is the choice to go through `XmlReader.Create(...) → XDocument.Load(xmlReader)` at all, rather than a bare `XDocument.Parse(gpxText)` (which I confirmed *does* expand a DTD entity on this runtime). See the comment on the DTD test for the follow-on issue this causes.
@ -0,0 +255,4 @@
/// accepted rather than refused), then restoring it.
/// </summary>
[Fact]
public void Parse_FileCarryingADtd_IsRefusedRatherThanExpanded()
Author
Owner

This mutation-testing claim doesn't hold: removing DtdProcessing = DtdProcessing.Prohibit from the XmlReaderSettings leaves the framework default, which is also Prohibit on .NET 10 — the test would not redden. Same for XmlResolver = null (the framework default is already null). I checked both against the pinned SDK. The mutation that would actually matter — swapping the hardened XmlReader.Create/XDocument.Load path for a bare XDocument.Parse(gpxText) — isn't guarded by any test here, and I confirmed it does let the entity expand. Please correct the "watched failing" claim (it describes a mutation that can't have produced the stated result) and, ideally, add a test or comment that actually pins down the XDocument.Parse regression this code protects against.

This mutation-testing claim doesn't hold: removing `DtdProcessing = DtdProcessing.Prohibit` from the `XmlReaderSettings` leaves the framework default, which is *also* `Prohibit` on .NET 10 — the test would not redden. Same for `XmlResolver = null` (the framework default is already `null`). I checked both against the pinned SDK. The mutation that would actually matter — swapping the hardened `XmlReader.Create`/`XDocument.Load` path for a bare `XDocument.Parse(gpxText)` — isn't guarded by any test here, and I confirmed it *does* let the entity expand. Please correct the "watched failing" claim (it describes a mutation that can't have produced the stated result) and, ideally, add a test or comment that actually pins down the `XDocument.Parse` regression this code protects against.
Correct the DTD-guard mutation-testing claim in the GPX parser
All checks were successful
CI / build (pull_request) Successful in 3m11s
CI / container-images (pull_request) Has been skipped
CI / e2e (pull_request) Successful in 1m56s
914c7f2720
rob left a comment

Verdict: mergeable

Reviewed at 914c7f2. This is a re-review of the correction alone; the round-1 findings (rename behaviour-preserving, DI/bUnit registrations, ButtonWeightCoverage exemption, ADR-0127 annotation, README index, GPX parsing/extension guard/ItemNoun wording/shared MaxItems bound) all still hold at this head — nothing outside the DTD-hardening doc/ADR text changed.

I performed the mutation myself rather than accepting the corrected claim: built a scratch harness with the same two payloads as Parse_FileCarryingADtd_IsRefusedRatherThanExpanded and Parse_FileCarryingADtdWithAnExternalEntity_IsRefusedRatherThanFetched, on .NET 10.0.110 (the pinned SDK, 10.0.100, isn't installed here — a patch-level gap I don't expect to move XML reader defaults, but noting it for the record).

  • Confirmed the runtime-defaults claim directly: new XmlReaderSettings().DtdProcessing is Prohibit, and XmlResolver is a write-only property with no way to read back a non-null default. Removing DtdProcessing = DtdProcessing.Prohibit/XmlResolver = null and going through XmlReader.Create/XDocument.Load with plain default settings still refused both the internal-entity and external-entity payloads — the original claim really doesn't hold, exactly as the correction says.
  • Confirmed the corrected claim: substituting a bare XDocument.Parse(gpxText) for the XmlReader.Create/XDocument.Load pair let both payloads' entities expand (&lol; resolved to lol, &xxe; resolved to empty rather than refusing) — i.e. IsRefused would read false and both named tests genuinely redden. This matches what the doc comments, the test remarks and ADR-0128 now say, and it's the mutation that actually discriminates the guard.

Spot-checked two more "watched red" claims for the same failure pattern (a mutation that leaves equivalent behaviour in place):

  • The extension/content-mismatch guard in PlaceFormPanel.HandleImportFileSelectedAsync — ADR-0128 itself flags that the .gpx-with-non-XML-content direction is also caught by GpxPlaceImportParser's own malformed-XML refusal, which made me suspicious the bUnit tests might not actually discriminate the panel-level sniff. Traced it through: if the sniff were deleted, GPX-extension/CSV-content falls through to GpxPlaceImportParser.Parse, which still refuses (XmlException → "not valid XML"), but that message doesn't contain the literal .gpx the test asserts on, and CSV-extension/GPX-content falls through to CsvPlaceImportParser.Parse, which also still refuses (no header match) but without the literal .csv the test asserts on. Both PlaceFormPanel_CsvExtensionButGpxContent_IsRefusedRatherThanFedToTheCsvParser and PlaceFormPanel_GpxExtensionButCsvContent_IsRefusedRatherThanFedToTheGpxParser do genuinely redden if the sniff is removed, just via message wording rather than the IsRefused flag. Holds up.
  • The malformed-XML refusal (catch (XmlException) in GpxPlaceImportParser.Parse) — removing the catch lets the exception propagate unhandled out of Parse, which fails the test with an unhandled exception rather than an assertion mismatch. Straightforwardly discriminating; no equivalent-behaviour trap here.

Nothing else raised. CI (run #634, SHA 914c7f2) was still reporting running with the known negative-duration artefact when I checked; the identical pipeline succeeded in 5m23s for the prior commit on this PR, so I'm not treating the in-flight status as a blocker per the review brief.

Verdict: mergeable Reviewed at 914c7f2. This is a re-review of the correction alone; the round-1 findings (rename behaviour-preserving, DI/bUnit registrations, `ButtonWeightCoverage` exemption, ADR-0127 annotation, README index, GPX parsing/extension guard/`ItemNoun` wording/shared `MaxItems` bound) all still hold at this head — nothing outside the DTD-hardening doc/ADR text changed. I performed the mutation myself rather than accepting the corrected claim: built a scratch harness with the same two payloads as `Parse_FileCarryingADtd_IsRefusedRatherThanExpanded` and `Parse_FileCarryingADtdWithAnExternalEntity_IsRefusedRatherThanFetched`, on .NET 10.0.110 (the pinned SDK, 10.0.100, isn't installed here — a patch-level gap I don't expect to move XML reader defaults, but noting it for the record). - Confirmed the runtime-defaults claim directly: `new XmlReaderSettings().DtdProcessing` is `Prohibit`, and `XmlResolver` is a write-only property with no way to read back a non-null default. Removing `DtdProcessing = DtdProcessing.Prohibit`/`XmlResolver = null` and going through `XmlReader.Create`/`XDocument.Load` with plain default settings still refused both the internal-entity and external-entity payloads — the original claim really doesn't hold, exactly as the correction says. - Confirmed the corrected claim: substituting a bare `XDocument.Parse(gpxText)` for the `XmlReader.Create`/`XDocument.Load` pair let both payloads' entities expand (`&lol;` resolved to `lol`, `&xxe;` resolved to empty rather than refusing) — i.e. `IsRefused` would read `false` and both named tests genuinely redden. This matches what the doc comments, the test remarks and ADR-0128 now say, and it's the mutation that actually discriminates the guard. Spot-checked two more "watched red" claims for the same failure pattern (a mutation that leaves equivalent behaviour in place): - The extension/content-mismatch guard in `PlaceFormPanel.HandleImportFileSelectedAsync` — ADR-0128 itself flags that the `.gpx`-with-non-XML-content direction is *also* caught by `GpxPlaceImportParser`'s own malformed-XML refusal, which made me suspicious the bUnit tests might not actually discriminate the panel-level sniff. Traced it through: if the sniff were deleted, GPX-extension/CSV-content falls through to `GpxPlaceImportParser.Parse`, which still refuses (`XmlException` → "not valid XML"), but that message doesn't contain the literal `.gpx` the test asserts on, and CSV-extension/GPX-content falls through to `CsvPlaceImportParser.Parse`, which also still refuses (no header match) but without the literal `.csv` the test asserts on. Both `PlaceFormPanel_CsvExtensionButGpxContent_IsRefusedRatherThanFedToTheCsvParser` and `PlaceFormPanel_GpxExtensionButCsvContent_IsRefusedRatherThanFedToTheGpxParser` do genuinely redden if the sniff is removed, just via message wording rather than the `IsRefused` flag. Holds up. - The malformed-XML refusal (`catch (XmlException)` in `GpxPlaceImportParser.Parse`) — removing the catch lets the exception propagate unhandled out of `Parse`, which fails the test with an unhandled exception rather than an assertion mismatch. Straightforwardly discriminating; no equivalent-behaviour trap here. Nothing else raised. CI (`run #634`, SHA 914c7f2) was still reporting `running` with the known negative-duration artefact when I checked; the identical pipeline succeeded in 5m23s for the prior commit on this PR, so I'm not treating the in-flight status as a blocker per the review brief.
rob merged commit 4bc1452433 into main 2026-08-13 20:06:39 +00:00
rob deleted branch feature/task-212-gpx-place-import 2026-08-13 20:06:39 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!152
No description provided.