Percent-decode the root-absolute (/…) local-path branch of LinkPathResolver and ImagePathResolver so a markdown-authored /Users/me/My%20Doc.md opens the file it names, instead of a nonexistent one called My%20Doc.md. Reviewed read-only against origin/main (PR #421).
URL(fileURLWithPath:) does no percent-decoding, so the root-absolute local branch treated %20 as three literal characters. The plain-relative branch a few lines below in each file already decoded — the two siblings disagreed inside the same function.let decodedPath = pathPart.removingPercentEncoding ?? pathPart before the file URL, in both LinkPathResolver.swift:191 and ImagePathResolver.swift:327. Byte-identical to the idiom already at LinkPathResolver.swift:261 / ImagePathResolver.swift:384.%23/%3F in a filename survives. Both delimiters now have a regression case in both suites; the follow-up commit records a mutation check (decode-before-split fails all four).%2F is deliberately decoded here, diverging from the remote .url branches where an escaped slash must stay escaped (T-1850/T-2140). A filesystem entry cannot contain a literal / in its own name, so no reading of a local path leaves it encoded and still names a real file. T-1850's own closing note endorses exactly this branch.PrismDocSchemeHandler.validateLocalFile confines by component-wise prefix comparison on standardizedFileURL.resolvingSymlinksInPath(), applied after resolution. /scope/..%2F..%2Fetc/passwd used to pass that check as one literal component (then fail to open); it now collapses to /etc/passwd and is rejected as .outsideScope.prism-url-exempt markers are necessary, not decoration. URLChokepointAdoptionTests genuinely bans removingPercentEncoding in production; the two new markers sit on the required line, in the required format, and keep noStaleExemptionMarkers green.fd835e74. The two resolver files and both test files merge clean.Ready to push
The fix is a two-line change per file, correct in mechanism and consistent with the sibling branch it mirrors. Three independent review passes (reuse, quality/correctness, spec/docs) raised no blocking and no major code defect. The two claims the change rests on were verified rather than assumed: PrismURL.split is purely textual (firstIndex(of: "#")), so %23/%3F inside a path can never be read as a live delimiter; and there is no double decode — the emitter's URLQueryItem setter encodes %20 → %2520 and PrismDocSchemeHandler's queryItems getter decodes it back exactly once, so the resolver's decode is the first semantic one. Targeted verification: 204/204 tests passed across the resolver, PrismURL, URL-corpus and URL-chokepoint suites; SwiftLint clean.
Two follow-ups, neither blocking: the CHANGELOG will conflict on rebase against origin/main's fd835e74 (#419, T-2044) — one hunk, both bullets at the same anchor, keep both; and docs/agent-notes/link-handling.md's final bullet still describes the pre-T-861 state of the exact three lines this branch edits, which CLAUDE.md's own rule says to fix or delete. No file was modified by this review.
e144f725 Fix T-2066: Percent-encoded absolute local paths resolve to literal percent filenames 279e038a Add %3F-inside-path regression cases to both T-2066 suites When you tap a link in a markdown document, Prism has to work out which file on disk the link points at. Markdown authors often write file paths with percent-encoding — a web convention where a space is written as %20, because plain spaces are not allowed in a URL. So a link to a file actually called My Doc.md gets written as /Users/me/My%20Doc.md.
Prism was taking that text literally. It looked on disk for a file whose name really contained the characters %, 2, 0 — a file that does not exist. The link failed, and the image version of the same bug showed an error placeholder instead of the picture.
The fix adds one line to each of the two files that do this resolution: translate %20 back into a space (and any other escape back into the character it stands for) before asking the filesystem for the file.
Any local link or image whose filename contains a space — by far the most common case — simply did not work. Spaces in filenames are completely ordinary on macOS and iOS, so this was a visible, everyday failure rather than an edge case.
#section anchor or a ?query on the end. Prism cuts those off first, using the raw text, and only then decodes. Doing it the other way round would turn an encoded %23 inside a filename into a real # and chop the filename in half../images/photo.png was already decoding. This change makes the two halves agree.Both LinkPathResolver.resolveRelativePath and ImagePathResolver.resolveRelativePath switch on DocumentSourceType across two structural shapes: a root-absolute source (source.hasPrefix("/")) and a plain-relative tail. Each shape then forks on .file/.bundled (filesystem) versus .url (remote). That is four branches per file, eight in total, and the encoding contract is the opposite on each side of the fork:
URL(fileURLWithPath:) and appendingPathComponent perform no decoding of their own, so anything still escaped becomes a literal character in the path.percentEncoded* setters — because decoding an escaped %2F in a URL path turns it into a live segment separator and requests a different server resource (the GitLab group%2Fproj case).Of those four filesystem-side sites, three decoded and one did not. This change fixes the fourth pair.
The added idiom is three parts, and all three matter:
PrismURL.split(source) on the raw string. The helper is purely textual — firstIndex(of: "#"), then firstIndex(of: "?") — so an encoded %23 or %3F is three ordinary characters to it and stays inside the path.pathPart.removingPercentEncoding ?? pathPart. The ?? is not defensive noise: removingPercentEncoding is all-or-nothing and returns nil for a string containing any invalid escape, so a real file named 100%.md falls back to its raw text and keeps working.// prism-url-exempt: <why> on the line directly above. URLChokepointAdoptionTests scans production source and fails on any bare removingPercentEncoding, so the marker is load-bearing, and a companion assertion fails a marker that outlives the line it covers.Decoding %2F here is the one deliberate divergence from the remote branches, and it is argued from the domain rather than from symmetry: a filesystem entry cannot contain a literal / in its own name, so there is no structural reading of a local path that leaving it encoded could protect. T-1850's closing note names this exact branch as the correct surviving decode.
The regression accepted is narrow: a file whose name literally contains a valid escape sequence (My%20Doc.md on disk) no longer resolves from an unescaped link. The escape hatch (My%2520Doc.md) exists and works, and the plain-relative branch already made the same trade. This is not called out in the report's Alternatives section, which is where it belongs.
Two new suites, one per resolver, cover %20 under both .file and .bundled, a decoded path alongside a genuine fragment, and both delimiter-survival cases (%23 and %3F) plus %2F. Every case fails against the pre-change code. The follow-up commit that added the %3F pair records a mutation check — moving the decode before the split fails all four ordering cases.
docs/agent-notes/link-handling.md narrates a URL-encoding defect that recurred through T-875, T-1624, T-1663 and T-2140, each fix recording the next residual as a note and each note failing to prevent the recurrence. T-2140's conclusion was to stop writing notes and move the rule into three mechanisms: PrismURL (entry points named for the encoding claim the caller makes), prismTests/URLEncodingCorpus.swift (one hostile-shape table driven through every conversion path, so fixing one branch and leaving its siblings is mechanically impossible), and URLChokepointAdoptionTests (per-line source scan on hand-rolled conversions).
T-2066 is the same recurrence shape one more time — correct sibling, incorrect sibling, same function — and it is worth being precise about why the mechanism did not catch it. URLConversionPath enumerates .url-sourced branches only; resolvedLinkOutcome/resolvedImageOutcome hard-code sourceType: .url. The four .file/.bundled filesystem branches are not in the cartesian product at all, and cannot be added cheaply: URLConversionOutcome.init(url:) reads back percentEncodedPath and compares against encodedPathSegment, whereas a filesystem outcome's expectation is the inverse (decoded). The report's Prevention item proposes extending the corpus and understates that cost. A sibling LocalPathConversionPath table over the four filesystem branches with decoded expectations is the shape that would actually close it; T-2210 (unify the forked relative-path resolution) is the natural home.
The riskiest property of adding a decode is that some caller already decoded. Three inbound paths were traced, and none does:
BlockHTMLEmitter.rewriteImageSrc puts the raw markdown src into a URLQueryItem; the queryItems setter encodes (% is not in .urlQueryAllowed, so %20 → %2520), and PrismDocSchemeHandler.route(for:) reads it back through the getter, decoding exactly once to recover the original. Lossless round trip; the resolver's decode is the first semantic one. This also confirms the fix is reachable in production, not only from the unit tests.imageActivated. WebDocumentMessageRouter discards the JS-supplied src entirely and looks the block up by DOM id, handing the detail sheet the raw parsed source.linkActivated. prism-scroll.js posts getAttribute("href"); InlineHTMLRenderer writes that href as escapeAttribute(visibleText(destination)) — HTML escaping only, no percent-encoding — and routeDocumentLink forwards it verbatim..standardized, so %2E%2E becomes a live .. and collapses. This grants no reach: a document could already write /a/../b plainly, and the image gate (PrismDocSchemeHandler.validateLocalFile) confines by component-wise prefix comparison on standardizedFileURL.resolvingSymlinksInPath() after resolution. The change is net-positive at that gate: /scope/..%2F..%2Fetc/passwd previously passed the scope check as one literal component and merely failed to open; it now collapses to /etc/passwd and is rejected as .outsideScope. LinkPathResolver has no scope confinement, but a root-absolute link already named an arbitrary absolute location, so no capability is added.%00 truncation. Truncation only shortens a path, and a shortened prefix of an in-scope path is still in scope; anything with a decoded .. that escapes is collapsed before the check./Users/a/My%20Doc 50%.md returns nil and falls back whole, so the %20 also stays literal and the file is not found. Identical to the pre-existing plain-relative branch — no new asymmetry introduced — but neither branch handles it and no comment or test states it.isSVG. ImagePathResolver.isSVG falls back to resolved.pathExtension, which decoding strictly improves (/a/photo%2Epng now has an extension).file://. The absolute-file:-scheme branch already produced decoded-path semantics via URL.path, so all three local shapes now agree — a real consistency win the report does not claim.The added block is byte-identical across both files (LinkPathResolver.swift:179-191 ≡ ImagePathResolver.swift:315-327), making it the fifth verbatim-duplicated rationale block in this resolver pair. A shared PrismURL.decodedFilesystemPath is tempting and was considered and rejected in the report, correctly: PrismURL.swift itself records that a Prism-named wrapper around a banned call is worse than the call, because the chokepoint scan cannot see through it — a shared helper would make a future misuse on a remote path invisible. Four per-line exemptions with stated reasons is that design working. What is worth deduplicating is the 13 lines of prose, not the two lines of code: verbatim comments across files are the thing that drifts.
prism/Services/LinkPathResolver.swift
Why it matters. This is the defect. URL(fileURLWithPath:) performs no percent-decoding, so /Users/me/My%20Doc.md became a lookup for a file whose name literally contains %20. Any local link to a file with a space in its name failed.
What to look at. prism/Services/LinkPathResolver.swift:179-192 (decodedPath + prism-url-exempt marker)
prism/Services/ImagePathResolver.swift
Why it matters. The image half of the same bug, reachable in production through the rendered page: BlockHTMLEmitter.rewriteImageSrc round-trips the raw src through a URLQueryItem and PrismDocSchemeHandler reads it back, so the still-encoded path reached this branch and produced an error placeholder instead of the picture.
What to look at. prism/Services/ImagePathResolver.swift:315-328
prism/Services/LinkPathResolver.swift
Why it matters. The ordering is the whole correctness argument. Decoding before the split would turn an encoded %23 inside a filename into a live fragment delimiter and cut the name in half; %3F would do the same as a query delimiter.
What to look at. PrismURL.split(source) at LinkPathResolver.swift:189 / ImagePathResolver.swift:324, verified against PrismURL.swift:268
prism/Services/ImagePathResolver.swift
Why it matters. This is the one decision that could reasonably have gone the other way, and getting it wrong in the other direction is exactly the T-1850/T-2140 defect (an escaped slash becoming a live path separator and requesting a different server resource).
What to look at. The %2F paragraph in both comment blocks; pinned by percentEncodedSlashInAbsoluteLocalPath / percentEncodedSlashInAbsoluteLocalImagePath
prism/Services/LinkPathResolver.swift
Why it matters. Without them the build fails: URLChokepointAdoptionTests scans production source and bans removingPercentEncoding outside PrismURL unless that line carries a marker. They are load-bearing, not commentary.
What to look at. prism/Services/LinkPathResolver.swift:191, prism/Services/ImagePathResolver.swift:327
A filesystem entry can never contain a literal / in its own name, so no reading of a local path leaves %2F encoded and still names a real file. On the .url branches an escaped slash selects a different server resource (T-1850/T-2140, the GitLab group%2Fproj case), so it must survive. The two sides model different things and are argued separately.
PrismURL.split recognises only literal # and ?. Running it on the raw source keeps an encoded %23/%3F inside the filename; running it after a decode would promote those to live delimiters. Pinned by four ordering tests with a recorded mutation check.
Considered and rejected in report.md as refactor risk beyond the ticket's scope, deferred to T-2210 (unify the forked relative-path resolution). Independently the right call for a second reason the report does not give: PrismURL.swift records that a Prism-named wrapper around a banned call is worse than the call, because URLChokepointAdoptionTests cannot see through it — a shared helper would hide a future misuse on a remote path.
A file whose name genuinely contains a valid escape sequence (My%20Doc.md on disk) no longer resolves from an unescaped link. The escape hatch My%2520Doc.md works, and the plain-relative branch already made the same trade. Not stated anywhere in the change — inferred from the mechanism.
removingPercentEncoding is all-or-nothing and returns nil on any invalid escape, so a real file named 100%.md keeps working through the fallback. The corollary — that a path mixing a valid escape with a stray % falls back whole, leaving the valid %20 literal too — is neither commented nor tested, in this branch or its pre-existing sibling.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | docs/agent-notes/link-handling.md (final bullet) | The bullet 'Local absolute paths still need component splitting' says the .file/.bundled branch for a leading-/ destination 'currently constructs URL(fileURLWithPath: source) directly ... Tracked as T-861'. It is stale twice over: T-861 already landed (the split is there), and T-2066 has now changed the same three lines again. CLAUDE.md's own rule is 'If you find a note that's stale or wrong, fix or delete it — a stale note is worse than no note', and this branch edits exactly the code the bullet points at. | Delete the bullet (T-861 is closed) or replace it with a one-line residual. Note the rest of that file deliberately says the URL-encoding rule 'no longer lives here', so no new bullet is wanted — the change correctly adds none. Not fixed: this review was strictly read-only. |
| minor | CHANGELOG.md — rebase conflict | origin/main is one commit ahead of the merge base (fd835e74, 'Fix T-2044: Table Header-Row Notes Have No Indicator', #419). Both it and this branch insert the first bullet under '### Fixed' at the same anchor, so git merge-tree reports one conflict there. The two resolver files and both new test files merge clean. | Resolve on rebase by keeping both bullets. Note #419 added a trailing blank line after its bullet, inconsistent with the rest of the list — decide deliberately whether to keep it. Not fixed: read-only review. |
| minor | specs/bugfixes/percent-encoded-absolute-local-paths/report.md | Two small accuracy gaps. (1) The 'What they verify' list enumerates %20, fragment composition, %23 and %2F but not %3F — the report was not updated when the follow-up commit 279e038a added those two cases. (2) The report calls the /… branch 'root-absolute' where the rest of the repo calls it 'root-relative' (URLConversionPath.linkRootRelative, LinkPathResolverRootRelativeQueryFragmentTests, link-handling.md); as written, 'Root-relative and plain-relative local paths were unaffected — only root-absolute destinations' implies a third branch that does not exist. | Add the %3F line to 'What they verify' and align the branch name with the repo's own vocabulary (or gloss it once). Not fixed: read-only review. |
| nit | Test coverage — the ?? pathPart fallback | Nothing covers the invalid-escape fallback. /Users/a/100%.md (stray %, not a valid triplet) makes removingPercentEncoding return nil, so the raw path survives and the file still resolves — the one behavioural promise in the change with zero coverage. The related shape /Users/a/My%20Doc 50%.md falls back whole, so the valid %20 also stays literal and the file is not found; that is identical to the pre-existing plain-relative branch, so no new asymmetry is introduced, but neither branch states it. | A single case pinning the stray-% fallback would be cheap and would document the all-or-nothing semantics. Optional. Not fixed: read-only review. |
| nit | Duplicated rationale prose across the resolver pair | The added block is byte-identical across both files (LinkPathResolver.swift:179-191 ≡ ImagePathResolver.swift:315-327): 13 comment lines plus the two-line idiom. The pair already carried four verbatim-duplicated rationale blocks; this makes five. The code duplication is defensible (a shared wrapper would blind URLChokepointAdoptionTests, per PrismURL.swift's own note); the prose duplication is what drifts. | Optionally state the rationale once and reduce the second copy to a pointer. Structural unification is already tracked as T-2210. Not fixed: read-only review. |
| nit | prismTests/URLEncodingCorpus.swift — mechanism blind spot | URLConversionPath enumerates only .url-sourced branches (resolvedLinkOutcome/resolvedImageOutcome hard-code sourceType: .url), so the four .file/.bundled filesystem branches are outside the corpus entirely. This recurrence shape — correct sibling, incorrect sibling, same function — is precisely what T-2140 built the corpus to prevent, and the mechanism still cannot see the local half. The report's Prevention item proposes extending the corpus but understates the cost: URLConversionOutcome.init(url:) reads back percentEncodedPath, the inverse of a filesystem expectation. | A sibling LocalPathConversionPath table over the four filesystem branches with decoded expectations is the shape that would close it. Belongs on T-2210, not this PR. Not fixed: read-only review. |
| nit | prismTests/LinkPathResolverPercentEncodedAbsolutePathTests.swift:51 | The comment calls the fragment "LinkPathResolver's existing (encoded) convention". It is really pass-through of whatever the author wrote — PrismURL.split does not encode — which merely happens to be encoded in this fixture. | Slightly overstated comment; reword or drop. Not fixed: read-only review. |
Source: local run at 2026-09-07T02:44:02+10:00 · snapshot e144f725
Baseline: none
Execution: passed · JUnit: 1 file · Coverage: none · Baseline: absent
Coverage scope: as the project configures it
Totals: 204 passed · 0 failed · 0 skipped · 0 errored · 0 flaky
Derived by declaration name, from the diff (no baseline run).
Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 16cf95ae79fb6eb1bd398eaf3c35c7375400fb85.
prism/Resources/mermaid.min.js — blob over 1 MBClick to expand.
diff --git a/prism/Services/LinkPathResolver.swift b/prism/Services/LinkPathResolver.swiftindex c00455ad..736ad5e6 100644--- a/prism/Services/LinkPathResolver.swift+++ b/prism/Services/LinkPathResolver.swift@@ -170,9 +170,26 @@ enum LinkPathResolver { // up as literal characters in the file URL's path (T-861). // The query part is intentionally discarded: local file links // are resolved by classifyLocal against the filesystem, which- // has no use for query parameters.+ // has no use for query parameters. Splitting on the RAW source+ // — before any decoding — is what keeps a percent-encoded+ // `%23`/`%3F` inside the path from ever being mistaken for a+ // live fragment/query delimiter: `PrismURL.split` only+ // recognises the literal characters (T-2066). let (pathPart, _, fragmentPart) = PrismURL.split(source)- let url = URL(fileURLWithPath: pathPart).standardized+ // Decode before constructing the file URL: a filesystem path is+ // not percent-encoded, so `URL(fileURLWithPath:)` treats `%20`+ // as three literal characters rather than a space, resolving to+ // a file that doesn't exist (T-2066). This mirrors the+ // plain-relative branch's `decodedPath` below, including its+ // full decode of an encoded `%2F` — a real filesystem entry can+ // never contain a literal `/` in its name, so there is no+ // "structural" reading of the path left to protect by leaving+ // it encoded here, unlike the `.url` branch just below, where+ // decoding `%2F` would change which remote resource is+ // requested (T-1850/T-2140).+ // prism-url-exempt: URL→filesystem, not URL→URL; a filesystem path is not percent-encoded+ let decodedPath = pathPart.removingPercentEncoding ?? pathPart+ let url = URL(fileURLWithPath: decodedPath).standardized return classifyLocal(url, fragment: PrismURL.nonEmptyFragment(fragmentPart)) case .url: // Split using our own helper rather than URLComponents(string:),
diff --git a/prism/Services/ImagePathResolver.swift b/prism/Services/ImagePathResolver.swiftindex 55ee007a..3ddfe499 100644--- a/prism/Services/ImagePathResolver.swift+++ b/prism/Services/ImagePathResolver.swift@@ -307,9 +307,25 @@ enum ImagePathResolver { // Split path/query/fragment so `#fragment` and `?query` don't end // up as literal characters in the file URL's path (T-861). Query // and fragment are discarded: filesystem image loading has no use- // for them.+ // for them. Splitting on the RAW source — before any decoding —+ // is what keeps a percent-encoded `%23`/`%3F` inside the path+ // from ever being mistaken for a live fragment/query delimiter:+ // `PrismURL.split` only recognises the literal characters (T-2066). let (pathPart, _, _) = PrismURL.split(source)- let url = URL(fileURLWithPath: pathPart).standardized+ // Decode before constructing the file URL: a filesystem path is+ // not percent-encoded, so `URL(fileURLWithPath:)` treats `%20`+ // as three literal characters rather than a space, resolving to+ // a file that doesn't exist (T-2066). This mirrors the+ // plain-relative branch's `decodedPath` below, including its+ // full decode of an encoded `%2F` — a real filesystem entry can+ // never contain a literal `/` in its name, so there is no+ // "structural" reading of the path left to protect by leaving+ // it encoded here, unlike the `.url` branch just below, where+ // decoding `%2F` would change which remote resource is+ // requested (T-1850/T-2140).+ // prism-url-exempt: URL→filesystem, not URL→URL; a filesystem path is not percent-encoded+ let decodedPath = pathPart.removingPercentEncoding ?? pathPart+ let url = URL(fileURLWithPath: decodedPath).standardized return resolvedSource(url: url, asLocal: true) case .url: // Root-relative path in remote context: resolve against origin.
diff --git a/prismTests/LinkPathResolverPercentEncodedAbsolutePathTests.swift b/prismTests/LinkPathResolverPercentEncodedAbsolutePathTests.swiftnew file mode 100644index 00000000..b7b636cb--- /dev/null+++ b/prismTests/LinkPathResolverPercentEncodedAbsolutePathTests.swift@@ -0,0 +1,112 @@+//+// LinkPathResolverPercentEncodedAbsolutePathTests.swift+// prismTests+//++import Foundation+import Testing+@testable import prism++// MARK: - T-2066: Percent-Encoded Absolute Local Paths++/// The root-absolute `.file`/`.bundled` branch of `resolveRelativePath` used+/// to hand the still-encoded split path straight to `URL(fileURLWithPath:)`,+/// unlike its plain-relative sibling a few lines below, which already+/// decodes. Foundation treats `%20` in that initializer as three literal+/// characters rather than a space, so `/Users/a/My%20Doc.md` resolved to a+/// file literally named `My%20Doc.md` — one that does not exist.+@Suite("LinkPathResolver percent-encoded absolute local paths")+struct LinkPathResolverPercentEncodedAbsolutePathTests {++ @Test("Percent-encoded absolute local markdown path decodes to real filename (T-2066)")+ func percentEncodedAbsoluteLocalMarkdownPath() {+ let baseURL = URL(fileURLWithPath: "/Users/test/Documents/")+ let result = LinkPathResolver.resolve(+ destination: "/Users/a/My%20Doc.md",+ baseURL: baseURL,+ sourceType: .file+ )+ #expect(result == .markdownFile(URL(fileURLWithPath: "/Users/a/My Doc.md")))+ }++ @Test("Percent-encoded absolute bundled markdown path decodes to real filename (T-2066)")+ func percentEncodedAbsoluteBundledMarkdownPath() {+ let baseURL = URL(fileURLWithPath: "/App/Bundle/Resources/")+ let result = LinkPathResolver.resolve(+ destination: "/App/Bundle/Resources/My%20Guide.md",+ baseURL: baseURL,+ sourceType: .bundled+ )+ #expect(result == .markdownFile(URL(fileURLWithPath: "/App/Bundle/Resources/My Guide.md")))+ }++ @Test("Percent-encoded absolute local path decodes alongside a real fragment (T-2066)")+ func percentEncodedAbsoluteLocalPathWithFragment() {+ let baseURL = URL(fileURLWithPath: "/Users/test/Documents/")+ let result = LinkPathResolver.resolve(+ destination: "/Users/a/My%20Doc.md#section%20one",+ baseURL: baseURL,+ sourceType: .file+ )+ // The fragment travels through LinkPathResolver's existing (encoded)+ // convention — only the filesystem path half of this bug is decoded.+ #expect(result == .markdownFile(+ URL(fileURLWithPath: "/Users/a/My Doc.md"),+ fragment: "section%20one"+ ))+ }++ @Test("Encoded hash inside an absolute local path is not mistaken for a fragment delimiter (T-2066)")+ func percentEncodedHashInsideAbsoluteLocalPath() {+ // `PrismURL.split` must split on the source's RAW characters before+ // any decoding happens: if the encoded path were decoded first, the+ // `%23` here would become a live `#` and everything after it would be+ // misread as a fragment, splitting one filename into a path and a+ // bogus anchor.+ let baseURL = URL(fileURLWithPath: "/Users/test/Documents/")+ let result = LinkPathResolver.resolve(+ destination: "/Users/a/na%23me.md#real",+ baseURL: baseURL,+ sourceType: .file+ )+ #expect(result == .markdownFile(+ URL(fileURLWithPath: "/Users/a/na#me.md"),+ fragment: "real"+ ))+ }++ @Test("Encoded question mark inside an absolute local path is not mistaken for a query delimiter (T-2066)")+ func percentEncodedQuestionMarkInsideAbsoluteLocalPath() {+ // The sibling of `percentEncodedHashInsideAbsoluteLocalPath` for the+ // other delimiter `PrismURL.split` recognises. Splitting on the RAW+ // source is what keeps the `%3F` here inside the filename: were the+ // path decoded first, the resulting live `?` would cut the name in+ // two and the tail would be discarded as a query.+ let baseURL = URL(fileURLWithPath: "/Users/test/Documents/")+ let result = LinkPathResolver.resolve(+ destination: "/Users/a/na%3Fme.md?real=1",+ baseURL: baseURL,+ sourceType: .file+ )+ // The real query is discarded (local file links have no use for one);+ // the encoded one survives into the filename.+ #expect(result == .markdownFile(URL(fileURLWithPath: "/Users/a/na?me.md")))+ }++ @Test("Encoded slash in an absolute local path decodes to a real subdirectory (T-2066)")+ func percentEncodedSlashInAbsoluteLocalPath() {+ // Unlike the remote `.url` branch (T-1850/T-2140), where an escaped+ // `%2F` must stay escaped because it changes which server resource is+ // requested, a local filesystem entry can never contain a literal `/`+ // in its own name. Decoding it here — consistent with the+ // plain-relative branch's existing `decodedPath` — is the only+ // reading that can name a real file.+ let baseURL = URL(fileURLWithPath: "/Users/test/Documents/")+ let result = LinkPathResolver.resolve(+ destination: "/Users/a/folder%2Ffile.md",+ baseURL: baseURL,+ sourceType: .file+ )+ #expect(result == .markdownFile(URL(fileURLWithPath: "/Users/a/folder/file.md")))+ }+}
diff --git a/prismTests/ImagePathResolverPercentEncodedAbsolutePathTests.swift b/prismTests/ImagePathResolverPercentEncodedAbsolutePathTests.swiftnew file mode 100644index 00000000..5c39d51c--- /dev/null+++ b/prismTests/ImagePathResolverPercentEncodedAbsolutePathTests.swift@@ -0,0 +1,92 @@+//+// ImagePathResolverPercentEncodedAbsolutePathTests.swift+// prismTests+//++import Foundation+import Testing+@testable import prism++// MARK: - T-2066: Percent-Encoded Absolute Local Paths++/// The root-absolute `.file`/`.bundled` branch of `resolveRelativePath` used+/// to hand the still-encoded split path straight to `URL(fileURLWithPath:)`,+/// unlike its plain-relative sibling a few lines below, which already+/// decodes. Foundation treats `%20` in that initializer as three literal+/// characters rather than a space, so `/Users/a/My%20Image.png` resolved to a+/// file literally named `My%20Image.png` — one that does not exist.+@Suite("ImagePathResolver percent-encoded absolute local paths")+struct ImagePathResolverPercentEncodedAbsolutePathTests {++ @Test("Percent-encoded absolute local image path decodes to real filename (T-2066)")+ func percentEncodedAbsoluteLocalImagePath() {+ let baseURL = URL(fileURLWithPath: "/Users/test/Documents/")+ let result = ImagePathResolver.resolve(+ source: "/Users/a/My%20Image.png",+ baseURL: baseURL,+ sourceType: .file+ )+ #expect(result == .localFile(URL(fileURLWithPath: "/Users/a/My Image.png")))+ }++ @Test("Percent-encoded absolute bundled image path decodes to real filename (T-2066)")+ func percentEncodedAbsoluteBundledImagePath() {+ let baseURL = URL(fileURLWithPath: "/App/Bundle/Resources/")+ let result = ImagePathResolver.resolve(+ source: "/App/Bundle/Resources/My%20Logo.png",+ baseURL: baseURL,+ sourceType: .bundled+ )+ #expect(result == .localFile(URL(fileURLWithPath: "/App/Bundle/Resources/My Logo.png")))+ }++ @Test("Encoded hash inside an absolute local image path is not mistaken for a fragment delimiter (T-2066)")+ func percentEncodedHashInsideAbsoluteLocalImagePath() {+ // `PrismURL.split` must split on the source's RAW characters before+ // any decoding happens: if the path were decoded first, the `%23`+ // here would become a live `#` and everything after it would be+ // stripped as a bogus fragment instead of surviving into the filename.+ let baseURL = URL(fileURLWithPath: "/Users/test/Documents/")+ let result = ImagePathResolver.resolve(+ source: "/Users/a/na%23me.png",+ baseURL: baseURL,+ sourceType: .file+ )+ #expect(result == .localFile(URL(fileURLWithPath: "/Users/a/na#me.png")))+ }++ @Test("Encoded question mark inside an absolute local image path is not mistaken for a query delimiter (T-2066)")+ func percentEncodedQuestionMarkInsideAbsoluteLocalImagePath() {+ // The sibling of `percentEncodedHashInsideAbsoluteLocalImagePath` for+ // the other delimiter `PrismURL.split` recognises. Splitting on the+ // RAW source is what keeps the `%3F` here inside the filename: were+ // the path decoded first, the resulting live `?` would cut the name in+ // two and the tail would be discarded as a query.+ let baseURL = URL(fileURLWithPath: "/Users/test/Documents/")+ let result = ImagePathResolver.resolve(+ source: "/Users/a/na%3Fme.png?real=1",+ baseURL: baseURL,+ sourceType: .file+ )+ // The real query is discarded (filesystem image loading has no use for+ // one); the encoded one survives into the filename.+ #expect(result == .localFile(URL(fileURLWithPath: "/Users/a/na?me.png")))+ }++ @Test("Encoded slash in an absolute local image path decodes to a real subdirectory (T-2066)")+ func percentEncodedSlashInAbsoluteLocalImagePath() {+ // Unlike the remote `.url` branch (T-1850/T-2140), where an escaped+ // `%2F` must stay escaped because it changes which server resource is+ // requested, a local filesystem entry can never contain a literal `/`+ // in its own name. Decoding it here — consistent with the+ // plain-relative branch's existing `decodedPath` — is the only+ // reading that can name a real file.+ let baseURL = URL(fileURLWithPath: "/Users/test/Documents/")+ let result = ImagePathResolver.resolve(+ source: "/Users/a/folder%2Fphoto.png",+ baseURL: baseURL,+ sourceType: .file+ )+ #expect(result == .localFile(URL(fileURLWithPath: "/Users/a/folder/photo.png")))+ }+}
diff --git a/specs/bugfixes/percent-encoded-absolute-local-paths/report.md b/specs/bugfixes/percent-encoded-absolute-local-paths/report.mdnew file mode 100644index 00000000..f4892c33--- /dev/null+++ b/specs/bugfixes/percent-encoded-absolute-local-paths/report.md@@ -0,0 +1,142 @@+# Bugfix Report: Percent-Encoded Absolute Local Paths++**Date:** 2026-09-07+**Status:** Fixed++## Description of the Issue++A markdown link or image whose destination was a percent-encoded root-absolute+local filesystem path resolved to a file named literally after the encoded+text, rather than the file the author meant. `/tmp/my%20file.md` resolved to a+lookup for a file named `my%20file.md`, not `my file.md`.++**Reproduction steps:**+1. Create `/tmp/my file.md` (a local file whose name contains a space).+2. In a local/bundled markdown document, add `[target](/tmp/my%20file.md)` or+ ``.+3. Tap the link / view the image. Prism attempts to open/load a file whose+ name literally contains `%20`, which does not exist, so the link fails and+ the image shows an error placeholder.++**Impact:** Any local/bundled document linking to or embedding a percent-+encoded absolute path (spaces, or any other character requiring escaping)+failed to open/load. This affects both document navigation and media+rendering. Root-relative and plain-relative local paths were unaffected —+only root-absolute (`/…`) destinations in `.file`/`.bundled` context.++## Investigation Summary++- **Symptoms examined:** the reported repro (`URL(fileURLWithPath: "/tmp/my%20file.md").path` prints `/tmp/my%20file.md`, i.e. no decoding happens).+- **Code inspected:**+ - `prism/Services/LinkPathResolver.swift`, `resolveRelativePath`, the `source.hasPrefix("/")` branch's `.file, .bundled` case.+ - `prism/Services/ImagePathResolver.swift`, the equivalent branch.+ - The sibling *plain-relative* branches in both files, a few lines below, which already decode (`let decodedPath = pathPart.removingPercentEncoding ?? pathPart`, carrying a `prism-url-exempt` marker).+ - `prism/Services/PrismURL.swift` (`split`, `nonEmptyFragment`) — the shared, order-sensitive splitter.+ - Closed ticket T-1850 (relative URL normalization changing encoded slash path segments) and its closing comment, which explicitly endorses the plain-relative branch's full decode (including `%2F`) as correct for the *URL→filesystem* direction, distinct from the *URL→URL* direction T-1850/T-2140 fixed.+ - `URLEncodingCorpusTests`/`URLEncodingCorpus.swift`: confirmed the shared corpus only drives `.url` (remote) conversion paths, never the `.file`/`.bundled` filesystem branches, so it could not have caught this and needs no new row.+ - `URLChokepointAdoptionTests.swift`: confirmed the exemption-marker convention (`prism-url-exempt: <reason>`, on the flagged line or the line directly above) that the fix's new `removingPercentEncoding` calls must follow.+- **Hypotheses tested:** whether the fix should preserve `%2F` as a literal escape (as the `.url` branches must) or fully decode it (as the existing plain-relative filesystem branch does). Concluded the latter is correct for a *filesystem* destination: a real file can never contain a literal `/` in its own name, so there is no "structural" reading of an absolute local path that decoding `%2F` could corrupt, unlike a remote HTTP resource where an escaped slash can be semantically distinct from a live separator (e.g. GitLab's `group%2Fproj` API routes).++## Discovered Root Cause++**Defect type:** Missing decoding step / inconsistency between two structurally+identical branches in the same function.++**Why it occurred:** `resolveRelativePath` in both resolvers has two branches+that both end in "build a file URL from a filesystem path": the root-absolute+branch (`source.hasPrefix("/")`) and the plain-relative branch below it. The+plain-relative branch decodes its split path before use because+`appendingPathComponent` does no decoding of its own. The root-absolute branch+was never given the same treatment — it split off query/fragment (fixing+T-861) but passed the still-encoded path straight to+`URL(fileURLWithPath:)`, which also does no decoding.++**Contributing factors:** The two branches are adjacent but not shared code+(tracked separately as T-2210, "Unify the forked relative-path resolution"),+so a fix applied to one did not propagate to the other. No existing test drove+a percent-encoded root-absolute local path, so the divergence went unnoticed.++## Resolution for the Issue++**Changes made:**+- `prism/Services/LinkPathResolver.swift` — root-absolute `.file, .bundled` branch: decode the split path (`pathPart.removingPercentEncoding ?? pathPart`) before constructing `URL(fileURLWithPath:)`, carrying a `prism-url-exempt` marker matching the sibling branch's.+- `prism/Services/ImagePathResolver.swift` — same change in the equivalent branch.+- `prismTests/LinkPathResolverPercentEncodedAbsolutePathTests.swift` (new) — regression tests.+- `prismTests/ImagePathResolverPercentEncodedAbsolutePathTests.swift` (new) — regression tests.+- `CHANGELOG.md` — `[Unreleased] / Fixed` entry.++**Approach rationale:** Mirror the existing, already-reviewed plain-relative+branch's decode exactly, rather than invent a different decoding rule for the+root-absolute branch. `PrismURL.split` still runs on the *raw* source before+any decoding, so a percent-encoded `%23`/`%3F` inside the path is never+mistaken for a live fragment/query delimiter — the split already happened by+the time decoding runs. Decoding `%2F` too (rather than preserving it as a+literal escape) matches the sibling branch and is intentional: unlike the+`.url` branches, where `PrismURL.percentEncoded(_, as: .path)` deliberately+preserves an escaped `/` because it can change which remote resource is+requested (T-1850/T-2140), a local filesystem entry can never contain a+literal `/` in its own name, so decoding it here has no "wrong" destination to+protect against — it is the only reading that can name a real file.++**Alternatives considered:**+- **Preserve `%2F` as a literal escape in the filesystem branches too** (mirroring the `.url` branches' selective encoder) — rejected because it would make the two "build a file URL" branches in the same function decode differently from each other for no functional benefit (no real file can be named with a literal `/`), and it would diverge from the plain-relative branch's already-accepted behaviour without a corresponding ticket asking for that broader change.+- **Add a dedicated `PrismURL.decodedFilesystemPath` helper shared by all four call sites** (both root-absolute and both plain-relative) — considered for consistency, but scoped out as unrelated refactor risk beyond this ticket; the existing plain-relative call sites are untouched, and T-2210 already tracks unifying the forked resolution logic.++## Regression Test++**Test files:**+- `prismTests/LinkPathResolverPercentEncodedAbsolutePathTests.swift`+- `prismTests/ImagePathResolverPercentEncodedAbsolutePathTests.swift`++**What they verify:**+- A percent-encoded absolute local/bundled markdown or image path decodes to the real filename (`%20` → space).+- The fix composes correctly with an existing real fragment (link case).+- An encoded `%23` inside the path is not mistaken for a fragment delimiter after decoding (split-before-decode ordering).+- An encoded `%2F` decodes to a real subdirectory separator, consistent with the sibling plain-relative branch.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+ -destination 'platform=macOS' -configuration Debug -derivedDataPath ./DerivedData \+ -resultBundlePath ./DerivedData/t.xcresult -testPlan prism -only-test-configuration "en (base)" \+ -parallel-testing-worker-count 1 -enableCodeCoverage NO \+ -only-testing:prismTests/LinkPathResolverPercentEncodedAbsolutePathTests \+ -only-testing:prismTests/ImagePathResolverPercentEncodedAbsolutePathTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/LinkPathResolver.swift` | Decode the split root-absolute path before `URL(fileURLWithPath:)` |+| `prism/Services/ImagePathResolver.swift` | Same, for image resolution |+| `prismTests/LinkPathResolverPercentEncodedAbsolutePathTests.swift` | New regression suite |+| `prismTests/ImagePathResolverPercentEncodedAbsolutePathTests.swift` | New regression suite |+| `CHANGELOG.md` | `[Unreleased] / Fixed` entry |++## Verification++**Automated:**+- [x] Regression tests pass (`LinkPathResolverPercentEncodedAbsolutePathTests`, `ImagePathResolverPercentEncodedAbsolutePathTests`)+- [x] Full `LinkPathResolverTests`/`ImagePathResolverTests` suites pass (145 tests)+- [x] `URLEncodingCorpusTests`, `URLChokepointAdoptionTests` (including `noStaleExemptionMarkers`), `PrismURLAbsoluteURLTests`, `PrismURLSplittingFragmentTests`, and the existing query/fragment-leakage suites for both resolvers pass (56 tests)+- [x] `make lint` passes+- [x] `make build-macos` passes+- [ ] Full `make test-quick`/`make test` — **not run**: this machine's test hosts are currently crashing at launch in `_libsecinit_appsandbox`, a known sandbox-container issue tracked separately from this bug. All targeted suites above (201 tests total) were run individually via `xcodebuild test -only-testing:` and verified with `Tools/check-test-results.sh`, which is the mitigation available while that issue stands.++**Manual verification:** Not performed (no manual repro environment available in this session); relies on the regression suite above, which reproduces the exact reported shapes.++## Prevention++**Recommendations to avoid similar bugs:**+- T-2210 ("Unify the forked relative-path resolution shared by ImagePathResolver and LinkPathResolver") would remove this class of divergence structurally — the root-absolute and plain-relative branches doing the same "build a file URL" work independently is exactly how this defect and T-861 both arose.+- Consider extending `URLEncodingCorpusTests`' conversion-path enumeration to cover the `.file`/`.bundled` filesystem branches (currently only `.url` paths are enumerated), so a future asymmetry between root-absolute and plain-relative filesystem decoding is caught mechanically rather than by inspection.++## Related++- T-2066 (this ticket)+- T-861 (query/fragment stripping on absolute local paths — the fix this one is adjacent to)+- T-1850 (closed; established that full decode, including `%2F`, is correct for the URL→filesystem direction)+- T-2140 (the `PrismURL` chokepoint and the `%2F`/`%20` defect classes for the URL→URL direction)+- T-2210 (open; tracks unifying the forked resolution logic these two branches live in)+- T-2214 (open, not addressed here; protocol-relative links resolving against the wrong host)
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 8b947fb7..539b5291 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A local link or image with a percent-encoded absolute path now opens the file it actually names, instead of a file literally named with the percent escapes still in it (T-2066). `LinkPathResolver` and `ImagePathResolver` split query/fragment off a root-absolute `/…` destination before handing the remaining path straight to `URL(fileURLWithPath:)` — which does no decoding of its own — so `[Doc](/Users/me/My%20Doc.md)` or `` resolved to a nonexistent file named `My%20Doc.md`/`My%20Image.png` rather than the one with a space in its name. Both root-absolute branches now decode the split path first, matching the plain-relative branch a few lines below, which already did. The split itself still runs on the raw, undecoded source, so a percent-encoded `%23`/`%3F` inside the path is never mistaken for a live fragment/query delimiter after decoding. An encoded `%2F` is decoded too, consistent with that same plain-relative branch: a real filesystem entry can never contain a literal `/` in its own name, so — unlike the remote `.url` branches, where an escaped slash must stay escaped because it changes which server resource is requested (T-1850/T-2140) — there is no reading of a local path where leaving it encoded names a real file. - Tapping a note in the compact Notes panel or the regular Notes sidebar now scrolls to the block that note is actually anchored to, instead of always the first occurrence of identical content elsewhere in the document (T-1929). Both note UIs navigated by passing the note's bare content-hash block id, which `BlockDOMID` resolves to its first (or first-visible) occurrence by design — the note's own stored heading path, already recorded for note storage/display disambiguation (T-209), was never consulted. Navigation now resolves the note's specific occurrence against that heading path and builds the same verified composite target TOC/search/scroll-restore already use, falling back to the previous first-occurrence behaviour only when a note carries no heading path (legacy notes) or its heading path no longer matches any occurrence (the block was relocated). A table-row or list-item note anchor resolves to its parent block's correct occurrence, since rows and items are not independently scrollable. - An open document now follows its file when you rename or move it in Finder, the Files app, or another file provider (T-1881). The app watches the open file through a file presenter, and that presenter never implemented the half of its contract that deals with the file moving: it kept pointing at where the file used to be, and so did the document. Reload then failed on a file that is perfectly readable, the title kept the old name, images beside the document were looked for in the old folder, and the notes were left filed under a path nothing would ever look up again. The presenter now retargets itself the moment it is told the file moved, and the document follows it — which is what carries the title, the Save destination, Reload, the remembered reading position and the page's image resolution across the move, since all of them are derived from the one property. The notes move with the document, and the record left at the old path is retired rather than stranded there. Moving them is its own operation rather than a reuse of the one Save As runs, because three things a Save As can take for granted are not true of a rename, and each of them lost notes: a renamed file's notes may not be loaded yet (a rename arriving before you have opened the notes pane, or before the document has finished loading, used to be reported as migrated while nothing moved); an old and a new name can be the same file, so renaming `readme.md` to `README.md` used to write the notes and then delete the file it had just written, taking every note with it; and the document is already at its new name by the time the app is told, so a note written in the instant the rename lands used to clear every note already on the document. Trashing an open document is no longer mistaken for a rename either — it arrives as one, measurably, so the notes would have been rewritten under a path inside the Trash and the ones at the real path deleted; the document now stays where it was, which is where Put Back returns it and where its notes are waiting. Two things deliberately do not move: the Recent Files entry still names the path you opened from (that is T-1842 / T-2172 / T-2173), and the security-scoped access the session holds is left alone — it was granted for the file itself and survives the rename, whereas releasing it is the one way to actually lose access to the file. Two files moved in quick succession — a rename followed by a drag into another folder, or an iCloud reorganisation — are followed all the way, with the notes taken from where they actually are rather than from the intermediate location the document only passed through; before this they were left behind at the original path while everything reported success. And if the notes cannot be written to their new location, you are now told so, with the same alert Save As raises, instead of the notes quietly ceasing to be the document's. - `HTMLImageSourceRewriter` no longer re-emits a mediated `src`/`srcset` value with an embedded quote character left unescaped (T-1942). The rewriter always re-emits these attributes double-quoted, but a value could still contain a raw `"`: the `rewrite` closure's `data:` passthrough hands unencoded `data:` URIs back unchanged, and `rewriteSrcset` re-joins a candidate's descriptor half — never passed through `rewrite` — verbatim. Either one could terminate the re-emitted attribute early, splicing the remainder into attribute position (e.g. `srcset='a.png 1x" onerror=… z='`). The value is now escaped for its double-quoted context before being written, so any quote characters it carries round-trip intact instead of breaking out. The escape leaves a character reference the value already carries alone (the `data:` passthrough re-emits the attribute's text as written, references undecoded, and the browser decodes it once), so an SVG `data:` URI whose author correctly wrote `&amp;` for a literal ampersand still renders that ampersand rather than the reference. This is defence in depth rather than a live exploit: the subsequent `HTMLSanitizer` (SwiftSoup) pass is the actual security boundary and already reduced the broken-out remainder to non-allowlisted junk.
The riskiest property of adding a decode is that a caller already decoded. Three paths were traced and none does. Page images: BlockHTMLEmitter.rewriteImageSrc puts the raw src into a URLQueryItem; the setter encodes (%20 → %2520), PrismDocSchemeHandler.route(for:) reads it back through queryItems, decoding exactly once. Lossless round trip, and confirmation the fix is reachable in production rather than only from unit tests. imageActivated: WebDocumentMessageRouter discards the JS-supplied src and looks the block up by DOM id. linkActivated: the href is written by InlineHTMLRenderer as escapeAttribute(visibleText(destination)) — HTML escaping only — and forwarded verbatim by routeDocumentLink.
Decoding now precedes .standardized, so %2E%2E becomes a live .. and collapses. This grants no new reach — a document could already write /a/../b plainly — and the image confinement check (PrismDocSchemeHandler.validateLocalFile) is a component-wise prefix comparison on standardizedFileURL.resolvingSymlinksInPath() applied after resolution, not a string hasPrefix. Worked concretely: /scope/..%2F..%2Fetc/passwd previously stayed one literal component and passed the scope check (then failed to open a nonexistent file); it now collapses to /etc/passwd and is rejected as .outsideScope. %00 truncation only shortens a path, and a shortened prefix of an in-scope path is still in scope. LinkPathResolver has no scope confinement at all, but a root-absolute link already named an arbitrary absolute location before this change.
URLChokepointAdoptionTests registers removingPercentEncoding under the 'hand-rolled percent coding' rule, so the markers are not decoration — without them productionAdoptsTheChokepoint fails. Checked against ProductionSourceScan: placement is the required line-directly-above with no intervening line; the reason string is non-empty and byte-identical to the pre-existing sibling markers; and noStaleExemptionMarkers stays green because the covered line still trips a rule. SwiftLint line_length is not tripped (ignores_comments: true).
T-1850's closing note states verbatim that 'the only surviving removingPercentEncoding on that path is the URL→filesystem branch, which is correct' — both halves of the new comments' claim (remote must not decode %2F; local already decodes and is endorsed) are accurate. T-2140 matches link-handling.md's .urlPathAllowed/GitLab bullet. T-861's retained comment is unchanged and still accurate. T-1813 is unaffected — PrismURL.nonEmptyFragment is still called on both branches.
grep 'hasPrefix("/")' across prism/ returns LinkPathResolver.swift:166 and ImagePathResolver.swift:304 — no third resolver was missed. Every other URL(fileURLWithPath:) / appendingPathComponent in production takes either a string literal (previews, the debug probe) or an app-generated name (SessionFileManager UUIDs, NotesStore/NotesBackupStore url-safe identifiers, InlineNotesShareHelper's sanitized filename), none of which is markdown-authored.
The targeted test run was taken from a git archive export of e144f725, made before the concurrent follow-up commit 279e038a landed on the branch. 204/204 passed. The two tests that run adds are purely additive cases over the same code path, and the commit message records a mutation check (decode-before-split fails all four ordering cases), so the gap is disclosed rather than closed — a single targeted xcodebuild was the budget for this review.