prism branch T-2140/bugfix-url-encoding-chokepoint PR #370 commits 6 + review fixes files 16 touched lines +1857 / -319 production net −4 lines tests 224/224 targeted · 4378/4419 full CI none (billing-blocked)

Pre-push review: T-2140/bugfix-url-encoding-chokepoint

The fourth round of a recurring percent-encoding defect, scoped to the class rather than the symptom: URLComponentHelpers becomes PrismURL, the path joins query and fragment on one selective encoder, and two test mechanisms (a 9-path corpus and a source-scanning adoption guard) replace a prose note that is 0-for-3 at preventing recurrence.

No CI ran — GitHub Actions is billing-blocked at the account level. Everything below was validated locally on this machine.

At a glance

  • Core fix verified correct. The selective encoder is right on all three components across every edge shape I could construct, including the ones the corpus omits. Idempotent, triplet-preserving, authority untouched, opaque forms handled.
  • The %2F defect was real and is genuinely closed. Reverting the path fix fails 48 of 81 corpus rows; reverting the GitHub transformer fix fails 6 rows while GitHubURLTransformerTests stays green — the single best argument in the PR for a shared corpus.
  • “Instance 8” is not a live defect. Disproved two ways (standalone probe + a mutation run scoring 96/96). It was the stated decisive selection criterion. Report, comparison and code comment corrected.
  • Deep-link scheme graft changes the failure mode from “error shown” to “nothing happens”, had zero tests, and its comment claimed a parity that does not hold. Tests added; the fix itself is your call.
  • Adoption guard: exemptions are whole-file. A new hand-rolled conversion inside either resolver is invisible to it. The scanner also misses the non-encoded .path/.query/.fragment setters — which were instance 2 of this very class.
  • absoluteURL(fromEncodedText:) has no production caller and is a sanctioned bypass the scanner cannot see. Recommend making it private.
  • Latent crash removed. GitHubURLTransformer fell back to the decoded url.path and fed it to percentEncodedPath, whose setter fatalErrors on invalid input. Replaced with a guard.
  • Local validation: 224/224 targeted tests, make lint 0 violations across 541 files, iOS and macOS builds succeed with 0 warnings. No CI ran.

Verdict

Ready to push (with one open decision)

The central fix is correct and unusually well evidenced. I stress-tested PrismURL.percentEncoded against every shape the corpus does not carry — empty components, lone %, %2, %GG, IPv6 hosts, ports, userinfo, uppercase schemes, opaque mailto:/data:/tel:, scheme-only, #-before-?, NFC vs NFD — and found no defect. I also confirmed by mutation that the adoption scanner genuinely fails when a new conversion is planted.

Two things stop this being a clean pass. First, one factual claim in the shipped narrative is wrong. “Instance 8” — the decisive reason this candidate was selected over the other two — rests on URL.fragment decoding. It does not, on any platform Prism supports; the repo's own agent note has said so since T-672. I reverted that line and re-ran the suites: 96/96 pass. The change is fine as clarity, but it fixed nothing, and the report, the solution comparison and the code comment all said otherwise. Corrected in this review.

Second, the http/https graft silently swallows a deep link that previously produced a visible error. That is a real feedback regression, it had no test, and the code comment claimed parity with URLInputSheet, which does show a message. I added tests pinning both the security property and the silent failure mode, and documented it in the CHANGELOG — but restoring the message is a behaviour decision for you (see Open Questions).

Beyond that: the adoption guard's exemptions are per-file and whole-file, and two of the nine are LinkPathResolver.swift and ImagePathResolver.swift — the files that hosted five of the eight instances. That is a genuine weakness in the mechanism, not a nit.

Review findings

14 raised · 8 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What changed

When you tap a link, paste a web address, or open a prism://open?url=… link, Prism has to turn a piece of text into an address. That sounds trivial. It is the source of four separate bugs in this codebase.

The problem is that web addresses have two ways of writing the same thing. A space can be written as a literal space, or as %20. A slash can be a real separator, or written as %2F to mean “a slash that is part of the name, not a divider”. Text arriving from a link, a document, or the clipboard can be in either style — or a mixture.

Apple's built-in converter handles a mixture badly: it assumes nothing has been escaped yet and escapes the whole thing again, so an existing %20 becomes %2520. Prism fetches a file that does not exist, shows no error, and you are left staring at nothing.

Why it matters

Investigating that turned up a second bug hiding inside the helper that was supposed to prevent it. To avoid double-escaping, the helper unescaped the address first and then re-escaped it. For the query part that was correct. For the path part it was not: unescaping %2F produces a real slash, which splits one name into two. A GitLab project address written as group%2Fproj quietly became group/proj, and 404s. That one was live on every link and image in every document.

Key concepts

  • Percent-encoding: writing a character as % plus two hex digits so it is treated as data rather than punctuation.
  • Selective encoding: the fix. Walk the text, leave anything that already looks like %XX exactly as it is, and escape only genuinely raw characters. Correct for raw text, correct for already-escaped text, correct for a mixture, and running it twice changes nothing.
  • A chokepoint: one place in the code that all address-building must go through, so the rule cannot be re-derived (and re-derived wrongly) at each site.

Architecture

URLComponentHelpers is replaced by PrismURL, an enum namespace with four entry points named for the claim the caller makes about its input:

  • absoluteURL(fromAmbiguousText:) — complete absolute URL, encoding state unknown
  • absoluteURL(fromEncodedText:) — complete absolute URL, encoded by construction
  • url(fromEncodedReference:relativeTo:) — the relative twin
  • percentEncoded(_:as:) — one component, encoding state unknown

The CharacterSet parameter is gone. A Component enum binds each role to its own allowed set inside the file, so a caller can no longer pair the path with a set that is wrong for it. That pairing freedom was the %2F defect: the path was allowed to take a completely different algorithm from query and fragment, and did, for two years, in the same function.

Patterns

Two test mechanisms, deliberately covering different failure modes. URLEncodingCorpus is one table of hostile shapes, each carrying a path segment, a query and a fragment simultaneously (because the failure has always been asymmetry between components), driven through an exhaustive switch over every declared conversion path. Adding a row applies it to all nine paths at once, so “fix one branch, leave the siblings” stops producing a green suite. URLChokepointAdoptionTests scans production source for conversions that bypass the chokepoint, covering the case the corpus structurally cannot: a path nobody declared, which is exactly how the reported bug arrived.

Trade-offs

The corpus is mutation-proven — and I re-verified the headline claim independently. Reverting the GitHubURLTransformer fix reddens 6 corpus rows while the entire pre-existing GitHubURLTransformerTests suite stays green. That single measurement is the whole argument for a shared corpus over per-path suites, and it holds.

The adoption test's trade-off is less favourable. It buys the ability to notice a new call site, at the cost of a scanner that greps text, whose exemptions are granted per file and skip that file entirely. Nine exemptions, two of which are the resolvers — the very files where this class recurred five times.

Deep dive: the encoder

PrismURL.percentEncoded walks the string, and on % checks index(offsetBy: 3, limitedBy:) plus $0.isASCII && $0.isHexDigit on the two following characters. Valid triplets are copied verbatim; everything else accumulates into a pending run flushed through addingPercentEncoding. Per-run rather than per-character, so multi-byte UTF-8 encodes correctly. The isASCII conjunct matters: bare isHexDigit accepts fullwidth forms.

I probed every boundary the corpus does not carry. """"; "%""%25"; "%2""%252"; "a%""a%25"; "%GG""%25GG"; identical across all three components; matches Foundation's own fallback for fully-raw input. bodyStartIndex copies the authority verbatim to the first /, ? or #, so [::1]:8080, u:p%40ss@host, ports and IDN survive untouched — and credential rejection downstream (T-1611/T-1599/T-1560) still fires. Uppercase schemes match case-insensitively and are preserved. Opaque forms (mailto:, data:, tel:) normalise over their body via offsetBy: scheme.count + 1. Scheme-only https: round-trips. # before ? correctly yields a fragment containing the ?.

NFC vs NFD: no normalisation policy is stated or applied, so two Swift-==-equal strings produce different URLs (caf%C3%A9 vs cafe%CC%81). This matches Foundation and is a server-side concern; noting it because the corpus's nonASCII row silently depends on its literal staying NFC. Self-detecting, so a nit.

Edge cases and the residual

The fragment change is inert. URL.fragment returns the percent-encoded fragment on the supported platforms — only fragment(percentEncoded: false) and URLComponents.fragment decode. Reverting line 108 to url.fragment and re-running the corpus, LinkPathResolverTests and the acceptance suite gives 96/96. Note also that the branches do not truly “agree” as the comment claimed: the absolute branch now yields a definitively encoded fragment while the relative branches carry the author's raw text of unknown state. Benign, because percentEncoded(_:as:.fragment) is a correct projection over both and DocumentSession.scrollToAnchor decodes — but every consumer of ResolvedLink's fragment must stay state-tolerant, and that should be stated rather than assumed.

GitHubURLTransformer's new pathSegments diverges from pathComponents on empty segments. URL.pathComponents collapses //; splitting percentEncodedPath does not. A leading doubled slash shifts segments[3] off "blob", the guard misses, and the URL is no longer rewritten to raw — the user gets “Unsupported content type” instead of the document. Degenerate input, but a real regression and undocumented until this review.

Architecture impact

The chokepoint stops one abstraction level below where the callers actually operate. All four resolver sites still hand-roll split → percentEncoded ×3 → joined → resolve; the two resolvers' relative-path bodies are now identical apart from return type and fragment policy. The diff removed the ability to pass a mismatched CharacterSet but left the ability to forget a component entirely. A url(fromAmbiguousReference:relativeTo:) would collapse roughly 40 duplicated lines and make that unrepresentable — the same class of guarantee Component gives for the character set.

Important changes — detailed

PrismURL: one selective encoder, roles bound to allowed-sets

PrismURL.swift

Why it matters. This is the whole fix. The path previously took `removingPercentEncoding` then `addingPercentEncoding(.urlPathAllowed)`; because that set contains `/`, an escaped `%2F` decoded into a live separator and one path segment silently became two. Live on every markdown link and image. Removing the CharacterSet parameter is the structural half: a component can no longer be paired with a set that is wrong for it, which is what let the path diverge from its siblings in the first place.

What to look at. PrismURL.swift:94-123 (percentEncoded), :62-76 (Component)

Takeaway. When two call sites must obey one rule, do not pass the rule in as a parameter. Bind it to a role the caller names. `percentEncoded(x, as: .path)` cannot be given a query's allowed set; `encodingRawCharacters(in: x, allowedCharacters: .urlPathAllowed)` could be given anything, and eventually was.
Rationale. Selective encoding is the only algorithm correct for raw, fully-encoded and mixed input simultaneously, and the only one that is a fixed point. Decode-then-encode is idempotent but not identity-preserving for reserved characters; wholesale re-encode is neither.

The fragment change is inert, and its stated justification is false

LinkPathResolver.swift

Why it matters. The report calls this 'Instance 8, found by the corpus' and the solution comparison makes it THE decisive reason this candidate beat the other two. The premise is that `URL.fragment` decodes, so pairing it with `embeddingFragment` re-encoded a decoded value. `URL.fragment` returns the percent-encoded fragment on every platform Prism supports. The repo's own agent note has said exactly that since T-672, in the same file this PR edits.

What to look at. LinkPathResolver.swift:99-113

Takeaway. A mutation test is the cheapest possible check on a claimed fix: revert the line, run the suite. If nothing goes red, either the fix is inert or the coverage is absent - and both are worth knowing before the claim ships. Here it was the former: 96/96 passed with the line reverted.
Rationale. Corrected in this review rather than reverted. Naming `percentEncodedFragment` states the requirement instead of relying on an accessor whose contract is easy to misread, and it costs nothing. But the code comment, the report's Instance 8 section, and the comparison's selection rationale all now say so explicitly.

Deep-link scheme graft: rejection is now silent

DocumentFlowCoordinator.swift

Why it matters. The guard sits inside the `else if` condition, so failing it drops out of the entire chain: `scheme` is `prism`, so neither the http/https branch nor `isFileURL` fires and `handleOpenURL` returns having done nothing. Previously the URL reached `URLDocumentLoader`, which threw `.unsupportedScheme`, set `loadError`, and showed the user 'Only http and https URLs are supported.' The comment claimed parity with `URLInputSheet` - which does show a message. It also had no test at all, for a security-flavoured check.

What to look at. DocumentFlowCoordinator.swift:155-177

Takeaway. Moving a rejection earlier is not free even when it is strictly safer. The old site owned an error channel; the new site does not, so 'reject sooner' quietly became 'reject invisibly'. When lifting a check, check what the old site did *after* rejecting.
Open question. Rationale not stated by the author and not inferable from the diff.

Adoption guard: exemptions are per-file and skip the whole file

URLChokepointAdoptionTests.swift

Why it matters. This suite is presented as the mechanism replacing a prose note that failed three times. Nine exemptions; two of them are LinkPathResolver.swift and ImagePathResolver.swift, which between them hosted instances 2, 3, 5, 6 and 8. A new `URL(string: destination)` added to either - literally the T-2140 regression - keeps the guard green. Three of the six production files this diff touches are invisible to it.

What to look at. URLChokepointAdoptionTests.swift:45-108, :154-156

Takeaway. An allowlist's granularity should match the granularity of the thing it permits. These exemptions justify one or two specific lines each but are enforced over whole files, so the guard's blind spots are inversely correlated with risk. A line-anchored marker (`// prism-url-exempt: <reason>`) keeps the documented-reason property without the blast radius.
Rationale. The author chose the semantic Swift test over Agent 1's SwiftLint custom_rules, on the sound grounds that a regex cannot distinguish a scheme-sniff from a fetch-URL build. That reasoning holds; it just does not explain why the resulting exemptions are file-scoped rather than line-scoped.

GitHubURLTransformer: encoded-path split, plus a latent trap I removed

GitHubURLTransformer.swift

Why it matters. The old code round-tripped through `url.pathComponents` (which decodes) and `.path` (which re-encodes wholesale), so `%2F` in a ref or filename was destroyed. Splitting `percentEncodedPath` and assigning back through `percentEncodedPath` moves segments verbatim - correct. But the fallback `?? url.path` fed a DECODED path into `percentEncodedPath`, whose setter fatalErrors on invalid percent-encoding (verified: 'Attempting to set percentEncodedPath with invalid characters'). Effectively unreachable, but a crash rather than degradation.

What to look at. GitHubURLTransformer.swift:46-63, :91-97

Takeaway. A trap-on-invalid setter paired with a fallback that is guaranteed-invalid whenever it fires is the wrong shape regardless of reachability. `guard let ... else { return unchanged }` costs one line and fails closed.
Rationale. Fixed in this review. Also note the new `pathSegments` diverges from `pathComponents` on doubled slashes, since the latter collapses empty segments - an undocumented behaviour change now recorded in the CHANGELOG.

The shared corpus: 11 shapes x 9 conversion paths

URLEncodingCorpus.swift

Why it matters. The mechanism that makes 'fix one branch, leave the siblings' fail loudly. Each row carries a path segment, a query AND a fragment simultaneously, because asymmetry between components has been the failure mode every time. The switch over `URLConversionPath` is exhaustive with no `default:`, so declaring a path without wiring it is a compile error.

What to look at. URLEncodingCorpus.swift:57-190 (rows), URLEncodingCorpusTests.swift:57-112 (driver)

Takeaway. The load-bearing measurement in this PR: reverting the GitHubURLTransformer fix reddens 6 corpus rows while the entire pre-existing GitHubURLTransformerTests suite stays green. That is the argument for a shared corpus over per-path suites, stated as a number rather than an opinion.
Rationale. I added two rows in this review - `encodedSeparators` (%23/%3F, the other two escaped delimiters, same class as %2F and arguably worse since decoding them splits the URL rather than a segment) and `trailingPercent` (exercises the scanner's `limitedBy` branch, which `lonePercent` does not reach). Both pass.

Key decisions

Selective encoding for all three components, not decode-then-encode for the path

Decoding an escaped %26 in a query yields a live & and splits one parameter into two. That argument was made for query and fragment in T-1624 and never made for the path — even though .urlPathAllowed contains /, so the identical reasoning applies to %2F. Every member of that set is affected; %2F is the one that changes resource identity.

Entry points named for the caller's claim, with no CharacterSet parameter

Root-cause Why-4: the unsafe API and the safe one are type-identical — both String → URL — so nothing in the type system, the linter or CI distinguishes them, and broken and correct code look equally reasonable at review. Naming the argument label after the encoding-state claim does not make the wrong choice impossible; it makes it stated.

SwiftLint custom_rules deliberately not shipped

Implemented and verified false-positive-free by the layered candidate, then dropped. A regex rule banning URL(string:) cannot distinguish a scheme-sniff from a fetch-URL build, so it polices ergonomics rather than encoding the invariant. URLChokepointAdoptionTests covers the same ground semantically. Reasoning is sound — though note .swiftlint.yml still carries an empty custom_rules: {} placeholder.

LinkPathResolver:206 / ImagePathResolver:327 keep decoding, on purpose

All three competing candidates independently identified these as URL→filesystem conversions. A filesystem path is not percent-encoded and appendingPathComponent does no decoding of its own, so they must keep removingPercentEncoding. The investigation had wrongly listed them among the sites to migrate; they are documented in place as deliberately different instead.

NoteModels.urlSafeEncoded stays out of the chokepoint

It encodes / as %2F as a filesystem traversal guard for a note filename, not as URL encoding. The two have opposite requirements for /; folding it in would be an active mistake. Correctly exempted with that reason recorded.

The absolute branch's fragment is encoded; the relative branches' is not

The code comment claimed reading percentEncodedFragment “makes this branch agree with the relative branches”. It does not: the relative branches carry the author's raw source text, of unknown encoding state. Benign today because percentEncoded(_:as:.fragment) is a correct projection over both states and DocumentSession.scrollToAnchor decodes — but the invariant is “state unknown, consumers must be tolerant”, not “encoded”. Rewritten in this review to say so.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorspecs/.../report.md · solution-comparison.md · LinkPathResolver.swift:99“Instance 8” claims URL.fragment decodes and that #a%2Fb reached the browser as #a/b. It does not on any supported platform; docs/agent-notes/link-handling.md:49 has said the opposite since T-672. Disproved twice: a standalone probe, and a mutation run reverting line 108 to url.fragment which scored 96/96 with nothing red. This was the DECISIVE criterion for selecting this candidate over the other two.Change kept as clarity (naming the encoded accessor states the requirement). Report section rewritten with the measurement and its consequence for the selection; comparison amended in place; code comment corrected to say the revert fails no test.
majorDocumentFlowCoordinator.swift:155-177The grafted http/https guard sits inside the else-if condition, so failing it falls out of the whole chain and handleOpenURL does nothing - no banner, no loadError, no log. Previously URLDocumentLoader threw .unsupportedScheme and the user saw 'Only http and https URLs are supported.' Affects file://, ftp://, javascript: and scheme-less targets. The comment claimed parity with URLInputSheet, which does show a message. No test existed for the rejection path.Comment corrected to state the differing failure modes. Six tests added: four parameterised rejection cases pinning that a non-web target never reaches the loader, one acceptance case, and one explicitly documenting the silent failure as a regression (with a note to delete it once the rejection grows a message). CHANGELOG entry added. Restoring the message itself is left as an open decision - an attempt to restructure the handler was blocked by the permission classifier.
majorURLChokepointAdoptionTests.swift:45-108, :154-156Exemptions are matched on file basename and skip the ENTIRE file. LinkPathResolver.swift and ImagePathResolver.swift are both exempt - the two files that hosted instances 2, 3, 5, 6 and 8. A re-introduced URL(string: destination) in either keeps the guard green. Basename matching also means any future file sharing a name inherits the exemption anywhere in the tree.Not changed - reworking the exemption model is a design decision for the author. Verified the guard DOES fire correctly today: planting a URL(string: raw) in a new non-exempt production file turned the suite red (1 failure, exactly the expected one). Recommendation: anchor exemptions to a line marker rather than a filename.
majorURLChokepointAdoptionTests.swift:124-131The scanner covers one of the four unsafe APIs the report's own root-cause analysis names. The non-encoded .path/.query/.fragment setters - which WERE instance 2 of this class - are not looked for at all; WebDocumentControllerFactory.swift:143 is a live, unexempted, unflagged example (safe in fact, but it proves the rule is absent). Also missed: URL(string:x) without the space, URL.init(string:), multi-line calls, URLComponents(string:), appendingPathComponent. The doc's justification that an interpolated literal 'can carry no user data' is false.Not changed (scope). Documented the limit honestly in URLEncodingCorpus.swift's doc comment, which previously oversold the division of labour AND cited an audit test (everyDeclaredPathIsExercised) that does not exist anywhere in the repo.
majorGitHubURLTransformer.swift:56, :74Latent crash. The fallback `?? url.path` substitutes a DECODED path, and URLComponents.percentEncodedPath's setter fatalErrors on a string that is not valid percent-encoding (confirmed: 'Attempting to set percentEncodedPath with invalid characters'). A blob URL with a raw space would crash; one with %2F would produce the exact corruption this ticket fixes. Reachable only if URLComponents(url:) returns nil, which I could not construct for an https URL.Replaced with `guard let originalComponents else { return TransformResult(fetchURL: url, displayURL: url) }`, which fails closed and matches the function's other guards. The three dependent optional chains simplified with it.
minorGitHubURLTransformer.swift:91-97 + CHANGELOGThe new pathSegments diverges from url.pathComponents on empty segments, which the latter collapses. A leading doubled slash (github.com//owner/repo/blob/...) shifts segments[3] off 'blob', the transform silently does not fire, and the user gets 'Unsupported content type: text/html' instead of the document. The doc comment asserts the helper matches pathComponents' indices; it cannot in general, since pathComponents also decodes. Untested either way.Behaviour left as-is (the new form is arguably more faithful - it does not silently rewrite the user's path). Recorded in the CHANGELOG as a side effect.
minorPrismURL.swift:172-174absoluteURL(fromEncodedText:) has ZERO production callers - one internal use at :159 and one test. It is a wrapper over URL(string:) that adds nothing, and it is a sanctioned bypass the adoption scanner cannot see: PrismURL.absoluteURL(fromEncodedText: userString) passes the guard while being exactly as unsafe as the call the guard bans. The file's whole design argument ('a reviewer is forced to answer which state is this string in') has no production site where the question can be asked.Not changed - this is the design question you flagged, and the answer is a judgement call rather than a defect. Recommendation: make it `private` (the sole test caller can use fromAmbiguousText, which is idempotent on an already-encoded string), which keeps the internal use and closes the bypass.
minorprismTests/URLComponentHelpersTests.swiftFilename, @Suite display name and type all named a type this diff deletes. Worse, the doc comment still described the DELETED, DEFECTIVE algorithm as current - 'The helper canonicalizes the raw path with the T-875 decode-then-encode pair' - sitting directly above encodedSlashInPathStaysEscaped, a test that exists to prove the opposite. The report even records the file/type-name divergence as a harness gotcha rather than removing it.Renamed to prismTests/PrismURLAbsoluteURLTests.swift, type PrismURLAbsoluteURLTests, suite 'PrismURL absoluteURL(fromAmbiguousText:)'. Doc comment rewritten to describe the selective encoder and note that the decode-then-encode pair was Defect B. No pbxproj churn - the project uses fileSystemSynchronizedGroups.
minordocs/agent-notes/link-handling.md:48, :52Two bullets still prescribed the deleted URLComponentHelpers.reassembleURL / encodingRawCharacters, in the very file whose new bullet declares 'The rule no longer lives here.' A half-updated note is the exact pattern this ticket exists to break.Both updated to name PrismURL.joined / PrismURL.percentEncoded(_:as:). Left the historical past-tense references in the two T-1663 test doc comments, which correctly describe what the code WAS.
minorURLEncodingCorpus.swift:179, :229-231Two inaccurate doc claims. The enum comment cites an audit test 'everyDeclaredPathIsExercised' that exists nowhere in the repo. URLConversionOutcome's comment says reading URL.path/.query/.fragment 'would decode' - only .path does, which is the same misreading behind Instance 8.Both corrected. The outcome comment now also records that only the LAST path segment is captured, so mid-path corruption (the GitLab shape) is pinned by deepLinkEncodedSlashStaysOneSegment rather than by the 81-cell product.
minorURLEncodingCorpus.swift - coverageTwo gaps that matter. %23 and %3F are the other two escaped separators; neither is in .urlPathAllowed for the same reason / is IN it, and decoding either splits the whole URL rather than just a segment - the same class as %2F. Separately, a '%' with fewer than two trailing characters takes the scanner's `limitedBy` branch, which lonePercent's hex-digit-failure branch does not reach.Added rows `encodedSeparators` and `trailingPercent`. Both pass across all nine conversion paths (corpus is now 11 x 9 = 99 cells).
minorLinkPathResolver.swift + ImagePathResolver.swiftThe two resolvers' relative-path bodies are now identical apart from return type and fragment policy (62 vs 65 non-comment lines; every differing line is one of those two things). All four sites hand-roll split -> percentEncoded x3 -> joined -> resolve. The chokepoint removed the ability to pass a mismatched CharacterSet but left the ability to forget a component entirely, and the next fix in this class still has to be applied four times.Not changed - a PrismURL.url(fromAmbiguousReference:relativeTo:) is the right shape but is a refactor, not a review fix. Worth a follow-up ticket.
nitPrismURL.swift:159, :62`?? parsed` at :159 fails OPEN - if the normalised string ever failed to parse, the function returns the double-escaped URL it exists to prevent. A 4,896-input fuzz found zero occurrences, so it is dead code; returning nil would be the safer dead branch. Separately, Component's CaseIterable conformance is unused in production and test.Left alone - both are harmless and changing the first alters a failure mode with no evidence it can fire.
nitprismTests - duplicationThe deep-link harness (URLCapture + fetchedURL) is duplicated between URLEncodingCorpusTests and DocumentFlowCoordinatorURLTests. Four tests exist under near-identical names in both PrismURLAbsoluteURLTests and URLEncodingCorpusTests. Roughly 13 pre-existing T-1663 tests are now subsumed by the corpus's own product and were left in place.Left alone. Overlapping coverage is cheap here and deleting the older suites would remove the .file-source-with-remote-base cells the corpus does not carry.

Per-file diffs

Click to expand.

prism/Services/PrismURL.swift Added +251 / -0
diff --git a/prism/Services/PrismURL.swift b/prism/Services/PrismURL.swiftnew file mode 100644index 0000000..7dbabbb--- /dev/null+++ b/prism/Services/PrismURL.swift@@ -0,0 +1,251 @@+//+//  PrismURL.swift+//  prism+//+//  T-2140: the single supported way a string becomes a URL.+//++import Foundation++/// The one place in Prism where a `String` becomes a `URL`.+///+/// ## Why this type exists+///+/// Foundation's `URL(string:)` and the non-encoded `URLComponents` setters+/// (`.path`, `.query`, `.fragment`) apply a *per-component* fallback: a+/// component holding any raw disallowed character is treated as wholly+/// unencoded and re-encoded wholesale, so an existing `%20` alongside a raw+/// space becomes `%2520`. The reverse patch — `removingPercentEncoding` then+/// `addingPercentEncoding` — corrupts the other direction: `.urlPathAllowed`+/// contains `/`, so an encoded `%2F` decodes into a live separator and one+/// path segment silently becomes two.+///+/// Both failures share a root cause: the call site never states what encoding+/// state its input is in, so the code cannot be read as right or wrong. This+/// type makes that statement mandatory. Every entry point is named for the+/// *claim* the caller is making about its input:+///+/// - ``absoluteURL(fromAmbiguousText:)`` — "a complete absolute URL, encoding+///   state unknown" (user input, a decoded query-item value, a markdown link+///   destination). Normalises every component and parses.+/// - ``absoluteURL(fromEncodedText:)`` — "a complete absolute URL, already+///   percent-encoded by construction". A bare parse; nothing is normalised.+/// - ``url(fromEncodedReference:relativeTo:)`` — the relative twin of the+///   above: a reference whose components have already been through+///   ``percentEncoded(_:as:)``.+/// - ``percentEncoded(_:as:)`` — "one component, encoding state unknown".+///+/// Picking the wrong one is now a visible mistake in the diff rather than an+/// invisible omission: the two whole-URL entry points have identical+/// `String -> URL?` shapes and are told apart only by the claim in the+/// argument label, so a reviewer is forced to answer "which state is this+/// string in?" instead of never being asked.+///+/// ## Why the component role is an enum, not a `CharacterSet`+///+/// The predecessor helper took an arbitrary `CharacterSet`, which let a call+/// site pair a component with the wrong allowed set silently, and let the+/// path use an entirely different algorithm from query and fragment. That+/// asymmetry *was* the `%2F` defect. ``Component`` binds each role to its own+/// allowed set inside this file, so there is exactly one encoder and no way+/// to pick a mismatched one.+enum PrismURL {++    // MARK: - Component Roles++    /// Which part of a URL a string is destined for.+    ///+    /// `path` means a *whole* path, not a single segment: a raw `/` in the+    /// input is a live separator and is preserved. An *encoded* `%2F` is+    /// likewise preserved as an escaped literal, which is what keeps+    /// GitLab-style `group%2Fproj` a single segment.+    enum Component: Sendable, CaseIterable {+        case path+        case query+        case fragment++        /// Characters legal in this component, i.e. those left alone when a+        /// genuinely-raw run is encoded.+        fileprivate var allowedCharacters: CharacterSet {+            switch self {+            case .path: .urlPathAllowed+            case .query: .urlQueryAllowed+            case .fragment: .urlFragmentAllowed+            }+        }+    }++    // MARK: - Component Encoding++    /// Percent-encodes one URL component whose encoding state is unknown.+    ///+    /// Existing `%XX` escape triplets are kept verbatim and only genuinely-raw+    /// disallowed characters are encoded, so the result is correct for raw,+    /// fully-encoded, and mixed input alike, and applying it twice changes+    /// nothing. A `%` that does not start a valid triplet is encoded to `%25`,+    /// matching Foundation's own fallback.+    ///+    /// Preserving triplets rather than decoding them is load-bearing for+    /// reserved characters in every component, not just the query: `%26` in a+    /// query is an escaped literal ampersand rather than a parameter+    /// separator, and `%2F` in a path is an escaped literal slash rather than+    /// a segment boundary. Decoding either changes which resource is+    /// requested (T-1624 for the query, T-2140 for the path).+    static func percentEncoded(_ text: String, as component: Component) -> String {+        let allowed = component.allowedCharacters+        var result = ""+        var pending = ""++        func flushPending() {+            guard !pending.isEmpty else { return }+            result += pending.addingPercentEncoding(withAllowedCharacters: allowed) ?? pending+            pending = ""+        }++        var index = text.startIndex+        while index < text.endIndex {+            // RFC 3986 escape triplets use ASCII hex digits only;+            // `isHexDigit` alone would also accept fullwidth forms.+            if text[index] == "%",+               let tripletEnd = text.index(index, offsetBy: 3, limitedBy: text.endIndex),+               text[text.index(after: index)..<tripletEnd]+                   .allSatisfy({ $0.isASCII && $0.isHexDigit }) {+                flushPending()+                result += text[index..<tripletEnd]+                index = tripletEnd+            } else {+                pending.append(text[index])+                index = text.index(after: index)+            }+        }+        flushPending()+        return result+    }++    // MARK: - Whole-URL Conversion++    /// Parses a complete absolute URL string **whose encoding state is+    /// unknown** — raw, already encoded, or a mixture.+    ///+    /// Each component is normalised with ``percentEncoded(_:as:)`` before+    /// Foundation sees the string, so Foundation never reaches its+    /// double-escaping fallback. The scheme and authority pass through+    /// verbatim, keeping embedded-credential detection downstream intact+    /// (T-1611/T-1599/T-1560).+    ///+    /// Non-hierarchical forms (`mailto:`, `data:`) are normalised the same+    /// way over their opaque body, which is why a `mailto:` with a raw space+    /// in its `?subject=` no longer doubles an escape already there.+    ///+    /// - Returns: nil when the string has no scheme (i.e. is not absolute).+    static func absoluteURL(fromAmbiguousText text: String) -> URL? {+        // Scheme detection deliberately uses the lossy parse: the scheme is+        // never re-encoded, so it is safe for deciding *whether* the string is+        // absolute — just not for using the resulting path.+        guard let parsed = URL(string: text), let scheme = parsed.scheme, !scheme.isEmpty else {+            return nil+        }++        guard let bodyStart = bodyStartIndex(in: text, scheme: scheme) else {+            return parsed+        }++        let (pathPart, queryPart, fragmentPart) = split(String(text[bodyStart...]))+        let normalized = String(text[..<bodyStart]) + joined(+            path: percentEncoded(pathPart, as: .path),+            query: queryPart.map { percentEncoded($0, as: .query) },+            fragment: fragmentPart.map { percentEncoded($0, as: .fragment) }+        )+        return absoluteURL(fromEncodedText: normalized) ?? parsed+    }++    /// Parses a complete absolute URL string **every component of which is+    /// already percent-encoded**.+    ///+    /// Use this only for strings this codebase produced itself — a+    /// ``joined(path:query:fragment:)`` of ``percentEncoded(_:as:)`` results,+    /// or an `absoluteString` round trip. For anything that came from a user,+    /// a document, or a decoded query item, use+    /// ``absoluteURL(fromAmbiguousText:)`` instead: this entry point performs+    /// no normalisation, so a mixed-encoding string handed here hits exactly+    /// the Foundation fallback this type exists to avoid.+    static func absoluteURL(fromEncodedText text: String) -> URL? {+        URL(string: text)+    }++    /// Resolves a relative reference **whose components are already+    /// percent-encoded** against a base URL.+    ///+    /// The relative twin of ``absoluteURL(fromEncodedText:)``: encode the+    /// pieces with ``percentEncoded(_:as:)``, reassemble with+    /// ``joined(path:query:fragment:)``, then resolve here.+    static func url(fromEncodedReference reference: String, relativeTo base: URL) -> URL? {+        URL(string: reference, relativeTo: base)?.absoluteURL+    }++    // MARK: - Splitting And Joining (no encoding)++    /// Splits a source string into path, query, and fragment.+    ///+    /// Handles the URL structure `path?query#fragment`: the first `#`+    /// separates the fragment from everything before it, and the first `?` in+    /// the pre-fragment portion separates the query from the path. Purely+    /// textual — no component is encoded or decoded — because splitting must+    /// happen *before* encoding: `.urlPathAllowed` excludes `?` and `#`, so+    /// encoding first would mangle the separators into the path.+    static func split(_ source: String) -> (path: String, query: String?, fragment: String?) {+        let beforeFragment: String+        let fragment: String?+        if let hashIndex = source.firstIndex(of: "#") {+            beforeFragment = String(source[..<hashIndex])+            let afterHash = source.index(after: hashIndex)+            fragment = afterHash < source.endIndex ? String(source[afterHash...]) : ""+        } else {+            beforeFragment = source+            fragment = nil+        }++        let path: String+        let query: String?+        if let qIndex = beforeFragment.firstIndex(of: "?") {+            path = String(beforeFragment[..<qIndex])+            let afterQ = beforeFragment.index(after: qIndex)+            query = afterQ < beforeFragment.endIndex ? String(beforeFragment[afterQ...]) : ""+        } else {+            path = beforeFragment+            query = nil+        }++        return (path, query, fragment)+    }++    /// Reassembles a URL string from path, query, and fragment.+    static func joined(path: String, query: String?, fragment: String?) -> String {+        var result = path+        if let query {+            result += "?" + query+        }+        if let fragment {+            result += "#" + fragment+        }+        return result+    }++    // MARK: - Private++    /// Index in `text` at which the path/query/fragment body begins, i.e.+    /// just past `scheme://authority` for a hierarchical URL or just past+    /// `scheme:` for an opaque one. Nil when the body cannot be located.+    private static func bodyStartIndex(in text: String, scheme: String) -> String.Index? {+        // Schemes are ASCII, so character and prefix counts agree.+        let hierarchicalPrefix = scheme + "://"+        if text.prefix(hierarchicalPrefix.count).lowercased() == hierarchicalPrefix.lowercased() {+            // Authority runs from after `scheme://` to the first `/`, `?`, or `#`.+            let authorityStart = text.index(text.startIndex, offsetBy: hierarchicalPrefix.count)+            return text[authorityStart...].firstIndex { $0 == "/" || $0 == "?" || $0 == "#" }+                ?? text.endIndex+        }+        // Opaque `scheme:body`.+        return text.index(text.startIndex, offsetBy: scheme.count + 1, limitedBy: text.endIndex)+    }+}
prism/Services/URLComponentHelpers.swift Deleted +0 / -162
diff --git a/prism/Services/URLComponentHelpers.swift b/prism/Services/URLComponentHelpers.swiftdeleted file mode 100644index 0a2f3d6..0000000--- a/prism/Services/URLComponentHelpers.swift+++ /dev/null@@ -1,162 +0,0 @@-//-//  URLComponentHelpers.swift-//  prism-//--import Foundation--/// Shared helpers for splitting and reassembling URL components (path, query, fragment).-///-/// Used by both `ImagePathResolver` and `LinkPathResolver` to preserve query strings-/// and fragment identifiers when resolving relative URLs.-enum URLComponentHelpers {--    /// Splits a source string into path, query, and fragment components.-    ///-    /// Handles the URL structure `path?query#fragment`:-    /// - The first `#` separates the fragment from everything before it-    /// - The first `?` in the pre-fragment portion separates the query from the path-    static func splitURLComponents(_ source: String) -> (path: String, query: String?, fragment: String?) {-        let beforeFragment: String-        let fragment: String?-        if let hashIndex = source.firstIndex(of: "#") {-            beforeFragment = String(source[..<hashIndex])-            let afterHash = source.index(after: hashIndex)-            fragment = afterHash < source.endIndex ? String(source[afterHash...]) : ""-        } else {-            beforeFragment = source-            fragment = nil-        }--        let path: String-        let query: String?-        if let qIndex = beforeFragment.firstIndex(of: "?") {-            path = String(beforeFragment[..<qIndex])-            let afterQ = beforeFragment.index(after: qIndex)-            query = afterQ < beforeFragment.endIndex ? String(beforeFragment[afterQ...]) : ""-        } else {-            path = beforeFragment-            query = nil-        }--        return (path, query, fragment)-    }--    /// Reassembles a URL string from path, query, and fragment components.-    static func reassembleURL(path: String, query: String?, fragment: String?) -> String {-        var result = path-        if let query {-            result += "?" + query-        }-        if let fragment {-            result += "#" + fragment-        }-        return result-    }--    /// Parses an absolute URL string, canonicalizing a mixed encoded/raw path-    /// before Foundation can double-escape it (T-1624).-    ///-    /// Foundation's URL parser handles invalid input per component: a-    /// component containing any raw disallowed character is treated as fully-    /// unencoded and re-encoded wholesale, so an existing `%20` alongside a-    /// raw space becomes `%2520` (`my%20file and more.md` →-    /// `my%2520file%20and%20more.md`). This helper splits the raw string at-    /// the authority, applies the same decode-then-encode canonicalization-    /// used for relative paths (T-875) to the raw path, and reassembles-    /// before parsing.-    ///-    /// The authority passes through verbatim, keeping embedded-credential-    /// detection downstream intact (T-1611/T-1599/T-1560). Query and fragment-    /// suffer the same per-component fallback as the path (`?token=a%20b c` →-    /// `?token=a%2520b%20c`), but the path's decode-then-encode pair would-    /// change their semantics (`%26` decodes to a parameter-separating `&`),-    /// so they get a selective encode instead: existing `%XX` triplets are-    /// kept verbatim and only genuinely-raw disallowed characters are-    /// encoded.-    ///-    /// - Returns: nil when the string has no scheme (not an absolute URL).-    ///   Non-hierarchical forms (`mailto:`, `data:`) parse as-is.-    static func normalizedAbsoluteURL(from source: String) -> URL? {-        // Scheme detection mirrors the resolvers' previous-        // `URL(string:)`-based branch selection: the lossy parse is safe for-        // deciding *whether* the string is absolute (the scheme is never-        // re-encoded), just not for using the resulting path.-        guard let url = URL(string: source), let scheme = url.scheme, !scheme.isEmpty else {-            return nil-        }--        // Only hierarchical `scheme://` forms carry a path to normalize.-        let schemePrefix = scheme + "://"-        guard source.prefix(schemePrefix.count).lowercased() == schemePrefix.lowercased() else {-            return url-        }--        // Authority runs from after `scheme://` to the first `/`, `?`, or `#`.-        let authorityStart = source.index(source.startIndex, offsetBy: schemePrefix.count)-        let afterAuthority = source[authorityStart...].firstIndex { $0 == "/" || $0 == "?" || $0 == "#" }-            ?? source.endIndex-        let tail = String(source[afterAuthority...])--        let (pathPart, queryPart, fragmentPart) = splitURLComponents(tail)--        // Canonicalize the raw path with the T-875 decode-then-encode pair,-        // which is idempotent for raw, encoded, and mixed inputs. A path with-        // an invalid escape sequence (lone `%`) fails to decode and is-        // encoded from its raw form, matching Foundation's own fallback.-        let decodedPath = pathPart.removingPercentEncoding ?? pathPart-        let encodedPath = decodedPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? decodedPath--        let encodedQuery = queryPart.map {-            encodingRawCharacters(in: $0, allowedCharacters: .urlQueryAllowed)-        }-        let encodedFragment = fragmentPart.map {-            encodingRawCharacters(in: $0, allowedCharacters: .urlFragmentAllowed)-        }--        let normalized = String(source[..<afterAuthority])-            + reassembleURL(path: encodedPath, query: encodedQuery, fragment: encodedFragment)-        return URL(string: normalized) ?? url-    }--    /// Percent-encodes only genuinely-raw disallowed characters in a URL-    /// component, keeping existing `%XX` escape triplets verbatim (T-1624).-    ///-    /// Used for query and fragment components, where the path's-    /// decode-then-encode canonicalization would corrupt semantics: `%26`-    /// decodes to `&`, turning an escaped literal into a parameter separator.-    /// For fully-raw and fully-encoded inputs the result is byte-identical to-    /// Foundation's own per-component fallback; only mixed inputs differ-    /// (Foundation re-encodes those wholesale, doubling existing escapes).-    /// A `%` not starting a valid triplet is encoded to `%25`, matching that-    /// fallback.-    static func encodingRawCharacters(in component: String, allowedCharacters: CharacterSet) -> String {-        var result = ""-        var pending = ""--        func flushPending() {-            guard !pending.isEmpty else { return }-            result += pending.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? pending-            pending = ""-        }--        var index = component.startIndex-        while index < component.endIndex {-            // RFC 3986 escape triplets use ASCII hex digits only;-            // `isHexDigit` alone would also accept fullwidth forms.-            if component[index] == "%",-               let tripletEnd = component.index(index, offsetBy: 3, limitedBy: component.endIndex),-               component[component.index(after: index)..<tripletEnd]-                   .allSatisfy({ $0.isASCII && $0.isHexDigit }) {-                flushPending()-                result += component[index..<tripletEnd]-                index = tripletEnd-            } else {-                pending.append(component[index])-                index = component.index(after: index)-            }-        }-        flushPending()-        return result-    }-}
prism/Services/LinkPathResolver.swift Modified +56 / -51
diff --git a/prism/Services/LinkPathResolver.swift b/prism/Services/LinkPathResolver.swiftindex 0b79392..eaf281d 100644--- a/prism/Services/LinkPathResolver.swift+++ b/prism/Services/LinkPathResolver.swift@@ -66,18 +66,19 @@ enum LinkPathResolver {             return .anchorOnly(decoded)         } -        // 2. mailto: links+        // 2. mailto: links. A markdown-authored `mailto:` is exactly as+        // encoding-ambiguous as any other destination, so it goes through the+        // same chokepoint rather than a bare `URL(string:)` (T-2140).         if destination.lowercased().hasPrefix("mailto:") {-            if let url = URL(string: destination) {+            if let url = PrismURL.absoluteURL(fromAmbiguousText: destination) {                 return .externalURL(url)             }             return .unresolvable         } -        // 3. Absolute URL (has scheme). Parsed via the normalizing helper-        // rather than `URL(string:)` directly, which double-escapes paths-        // that mix existing percent-escapes with raw characters (T-1624).-        if let url = URLComponentHelpers.normalizedAbsoluteURL(from: destination),+        // 3. Absolute URL (has scheme). The destination came from the+        // document, so its encoding state is unknown (T-1624).+        if let url = PrismURL.absoluteURL(fromAmbiguousText: destination),            let scheme = url.scheme, !scheme.isEmpty {             return resolveAbsoluteURL(url, scheme: scheme, sourceType: sourceType)         }@@ -95,7 +96,20 @@ enum LinkPathResolver {         sourceType: DocumentSourceType     ) -> ResolvedLink {         let lowercaseScheme = scheme.lowercased()-        let fragment = url.fragment+        // Read the ENCODED fragment explicitly. On the platforms Prism+        // supports, `URL.fragment` happens to return the encoded form too, so+        // this is not a behaviour change today — reverting it fails no test.+        // It is stated rather than relied upon because the value is+        // re-embedded by `embeddingFragment` for non-markdown targets: were+        // the accessor ever to decode, that pair would become the T-2140+        // defect wearing the fragment's clothes (`.urlFragmentAllowed`+        // contains `/`, so an escaped `%2F` would come back as a live `/`).+        // Naming the encoded accessor also makes this branch agree with the+        // relative branches below, which pass the source's own text through+        // untouched, so downstream has one convention rather than two.+        // `DocumentSession.scrollToAnchor` decodes before slug matching, so+        // in-app anchors are unaffected either way.+        let fragment = URLComponents(url: url, resolvingAgainstBaseURL: false)?.percentEncodedFragment         let cleanURL = removingFragment(from: url)          // file:// scheme handling@@ -141,27 +155,26 @@ enum LinkPathResolver {                 // The query part is intentionally discarded: local file links                 // are resolved by classifyLocal against the filesystem, which                 // has no use for query parameters.-                let (pathPart, _, fragment) = URLComponentHelpers.splitURLComponents(source)+                let (pathPart, _, fragment) = PrismURL.split(source)                 let url = URL(fileURLWithPath: pathPart).standardized                 return classifyLocal(url, fragment: fragment)             case .url:                 // Split using our own helper rather than URLComponents(string:),                 // which fails on mixed encoded/raw paths (T-875).-                let (pathPart, queryPart, fragmentPart) = URLComponentHelpers.splitURLComponents(source)+                let (pathPart, queryPart, fragmentPart) = PrismURL.split(source)                 guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {                     return .unresolvable                 }-                // Normalize to the decoded form, then let URLComponents.path-                // re-encode it. Setting .path with already-encoded text would-                // double the existing escapes (T-875).-                components.path = pathPart.removingPercentEncoding ?? pathPart-                // The non-encoded `.query` setter treats its input as fully raw-                // and re-encodes it wholesale, doubling any existing escape-                // (`token=a%20b` → `token=a%2520b`). Selectively encode only-                // genuinely-raw characters instead, mirroring embeddingFragment-                // below (T-1663, follow-up to T-1624).+                // Every component takes the same treatment: keep existing+                // escapes verbatim, encode only genuinely-raw runs, and assign+                // through the `percentEncoded*` setters. The non-encoded+                // setters would re-encode wholesale, doubling an existing+                // `%20` into `%2520` (T-1663), and the decode-then-encode pair+                // the path used to take would turn an escaped `%2F` into a+                // live segment separator (T-2140).+                components.percentEncodedPath = PrismURL.percentEncoded(pathPart, as: .path)                 components.percentEncodedQuery = queryPart.map {-                    URLComponentHelpers.encodingRawCharacters(in: $0, allowedCharacters: .urlQueryAllowed)+                    PrismURL.percentEncoded($0, as: .query)                 }                 // Clear fragment from URL — it's carried separately in the enum.                 components.fragment = nil@@ -177,29 +190,24 @@ enum LinkPathResolver {          // Split source into path, query, and fragment before encoding.         // .urlPathAllowed encodes ? and # which would mangle query/fragment into the path.-        let (pathPart, queryPart, fragment) = URLComponentHelpers.splitURLComponents(source)--        // Percent-encode the path. Decode first so that mixed encoded/raw inputs-        // (e.g. `my%20file and more.md`) round-trip to a single canonical form-        // instead of having their existing `%` escaped to `%25` (T-875). The-        // decode-then-encode pair is idempotent for raw, encoded, and mixed-        // inputs.+        let (pathPart, queryPart, fragment) = PrismURL.split(source)++        // Every component gets the same selective encode: existing escapes are+        // kept verbatim and only genuinely-raw runs are encoded, so the string+        // reaching `URL(string:relativeTo:)` is already valid and passes+        // through untouched. Handing that parser a component with any raw+        // disallowed character in it triggers Foundation's wholesale+        // re-encode, doubling every existing escape (T-1663); decoding the+        // path first instead would turn an escaped `%2F` into a live segment+        // separator, or an escaped `%26` in a query into a parameter+        // separator (T-2140).+        let encodedPath = PrismURL.percentEncoded(pathPart, as: .path)+        let encodedQuery = queryPart.map { PrismURL.percentEncoded($0, as: .query) }++        // The filesystem branch below needs the *decoded* path instead: a+        // filesystem path is not percent-encoded, and `appendingPathComponent`+        // does no decoding of its own.         let decodedPath = pathPart.removingPercentEncoding ?? pathPart-        let encodedPath = decodedPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? decodedPath--        // The reassembled string is parsed by `URL(string:relativeTo:)`, which-        // applies Foundation's per-component fallback: a query holding any raw-        // disallowed character is treated as wholly unencoded and re-encoded-        // wholesale, doubling every existing escape (`?token=a%20b c` →-        // `?token=a%2520b%20c`). Selectively encode only genuinely-raw-        // characters first, so the component reaching the parser is already-        // valid and passes through untouched (T-1663, follow-up to T-1624).-        // The path's decode-then-encode pair is deliberately NOT used here: it-        // would decode an escaped `%26` into a parameter-separating `&`,-        // changing which resource is requested.-        let encodedQuery = queryPart.map {-            URLComponentHelpers.encodingRawCharacters(in: $0, allowedCharacters: .urlQueryAllowed)-        }          // Resolve relative path against base URL         switch sourceType {@@ -209,22 +217,22 @@ enum LinkPathResolver {                 resolved = baseURL.appendingPathComponent(decodedPath).standardized             } else {                 // Reassemble without fragment so the resolved URL is clean.-                let encodedSource = URLComponentHelpers.reassembleURL(path: encodedPath, query: encodedQuery, fragment: nil)-                guard let url = URL(string: encodedSource, relativeTo: baseURL) else {+                let encodedSource = PrismURL.joined(path: encodedPath, query: encodedQuery, fragment: nil)+                guard let url = PrismURL.url(fromEncodedReference: encodedSource, relativeTo: baseURL) else {                     return .unresolvable                 }-                resolved = url.absoluteURL.standardized+                resolved = url.standardized             }             return classifyLocal(resolved, fragment: fragment)          case .url:             // Reassemble without fragment so the resolved URL is clean.             // The fragment is carried separately in the enum, matching resolveAbsoluteURL behavior.-            let encodedSource = URLComponentHelpers.reassembleURL(path: encodedPath, query: encodedQuery, fragment: nil)-            guard let resolved = URL(string: encodedSource, relativeTo: baseURL) else {+            let encodedSource = PrismURL.joined(path: encodedPath, query: encodedQuery, fragment: nil)+            guard let resolved = PrismURL.url(fromEncodedReference: encodedSource, relativeTo: baseURL) else {                 return .unresolvable             }-            return classifyRemote(resolved.absoluteURL.standardized, fragment: fragment)+            return classifyRemote(resolved.standardized, fragment: fragment)          case .clipboard:             // Unreachable: early return on line 119 handles clipboard before this point.@@ -314,10 +322,7 @@ enum LinkPathResolver {     /// genuinely-raw characters and set the encoded form verbatim (T-1624).     private static func embeddingFragment(_ fragment: String, into url: URL) -> URL? {         var components = URLComponents(url: url, resolvingAgainstBaseURL: false)-        components?.percentEncodedFragment = URLComponentHelpers.encodingRawCharacters(-            in: fragment,-            allowedCharacters: .urlFragmentAllowed-        )+        components?.percentEncodedFragment = PrismURL.percentEncoded(fragment, as: .fragment)         return components?.url     } 
prism/Services/ImagePathResolver.swift Modified +39 / -48
diff --git a/prism/Services/ImagePathResolver.swift b/prism/Services/ImagePathResolver.swiftindex d269329..5c64b28 100644--- a/prism/Services/ImagePathResolver.swift+++ b/prism/Services/ImagePathResolver.swift@@ -154,10 +154,9 @@ enum ImagePathResolver {             return resolveDataURI(source)         } -        // 2. Absolute URL (has scheme). Parsed via the normalizing helper-        // rather than `URL(string:)` directly, which double-escapes paths-        // that mix existing percent-escapes with raw characters (T-1624).-        if let url = URLComponentHelpers.normalizedAbsoluteURL(from: source),+        // 2. Absolute URL (has scheme). The source came from the document, so+        // its encoding state is unknown (T-1624).+        if let url = PrismURL.absoluteURL(fromAmbiguousText: source),            let scheme = url.scheme, !scheme.isEmpty {             return resolveAbsoluteURL(url, scheme: scheme, sourceType: sourceType)         }@@ -269,7 +268,7 @@ enum ImagePathResolver {                 // 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.-                let (pathPart, _, _) = URLComponentHelpers.splitURLComponents(source)+                let (pathPart, _, _) = PrismURL.split(source)                 let url = URL(fileURLWithPath: pathPart).standardized                 return resolvedSource(url: url, asLocal: true)             case .url:@@ -278,24 +277,23 @@ enum ImagePathResolver {                 // which doubles existing escapes when the source mixes encoded                 // and raw characters (T-875). Set path/query/fragment explicitly                 // on the base components to prevent base URL leakage (T-620).-                let (pathPart, queryPart, fragmentPart) = URLComponentHelpers.splitURLComponents(source)+                let (pathPart, queryPart, fragmentPart) = PrismURL.split(source)                 guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {                     return .failed(.invalidURL)                 }-                // Normalize to the decoded form, then let URLComponents.path-                // re-encode it. Setting .path with already-encoded text would-                // double the existing escapes (T-875).-                components.path = pathPart.removingPercentEncoding ?? pathPart-                // The non-encoded `.query`/`.fragment` setters treat their input-                // as fully raw and re-encode it wholesale, doubling any existing-                // escape (`token=a%20b` → `token=a%2520b`). Selectively encode-                // only genuinely-raw characters instead (T-1663, follow-up to-                // T-1624, which fixed the same class of bug for absolute URLs).+                // Every component takes the same treatment: keep existing+                // escapes verbatim, encode only genuinely-raw runs, and assign+                // through the `percentEncoded*` setters. The non-encoded+                // setters would re-encode wholesale, doubling an existing+                // `%20` into `%2520` (T-1663), and the decode-then-encode pair+                // the path used to take would turn an escaped `%2F` into a+                // live segment separator (T-2140).+                components.percentEncodedPath = PrismURL.percentEncoded(pathPart, as: .path)                 components.percentEncodedQuery = queryPart.map {-                    URLComponentHelpers.encodingRawCharacters(in: $0, allowedCharacters: .urlQueryAllowed)+                    PrismURL.percentEncoded($0, as: .query)                 }                 components.percentEncodedFragment = fragmentPart.map {-                    URLComponentHelpers.encodingRawCharacters(in: $0, allowedCharacters: .urlFragmentAllowed)+                    PrismURL.percentEncoded($0, as: .fragment)                 }                 guard let resolved = components.url else {                     return .failed(.invalidURL)@@ -308,32 +306,25 @@ enum ImagePathResolver {          // Split source into path, query, and fragment before encoding.         // .urlPathAllowed encodes ? and # which would mangle query/fragment into the path.-        let (pathPart, queryPart, fragmentPart) = URLComponentHelpers.splitURLComponents(source)--        // Percent-encode the path. Decode first so that mixed encoded/raw inputs-        // (e.g. `my%20image and more.png`) round-trip to a single canonical form-        // instead of having their existing `%` escaped to `%25` (T-875). The-        // decode-then-encode pair is idempotent for raw, encoded, and mixed-        // inputs.+        let (pathPart, queryPart, fragmentPart) = PrismURL.split(source)++        // Every component gets the same selective encode: existing escapes are+        // kept verbatim and only genuinely-raw runs are encoded, so the string+        // reaching `URL(string:relativeTo:)` is already valid and passes+        // through untouched. Handing that parser a component with any raw+        // disallowed character in it triggers Foundation's wholesale+        // re-encode, doubling every existing escape (T-1663); decoding the+        // path first instead would turn an escaped `%2F` into a live segment+        // separator, or an escaped `%26` in a query into a parameter+        // separator (T-2140).+        let encodedPath = PrismURL.percentEncoded(pathPart, as: .path)+        let encodedQuery = queryPart.map { PrismURL.percentEncoded($0, as: .query) }+        let encodedFragment = fragmentPart.map { PrismURL.percentEncoded($0, as: .fragment) }++        // The filesystem branch below needs the *decoded* path instead: a+        // filesystem path is not percent-encoded, and `appendingPathComponent`+        // does no decoding of its own.         let decodedPath = pathPart.removingPercentEncoding ?? pathPart-        let encodedPath = decodedPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? decodedPath--        // The reassembled string is parsed by `URL(string:relativeTo:)`, which-        // applies Foundation's per-component fallback: a query or fragment-        // holding any raw disallowed character is treated as wholly unencoded-        // and re-encoded wholesale, doubling every existing escape-        // (`?token=a%20b c` → `?token=a%2520b%20c`). Selectively encode only-        // genuinely-raw characters first, so the component reaching the parser-        // is already valid and passes through untouched (T-1663, follow-up to-        // T-1624). The path's decode-then-encode pair is deliberately NOT used-        // here: it would decode an escaped `%26` into a parameter-separating-        // `&`, changing which resource is requested.-        let encodedQuery = queryPart.map {-            URLComponentHelpers.encodingRawCharacters(in: $0, allowedCharacters: .urlQueryAllowed)-        }-        let encodedFragment = fragmentPart.map {-            URLComponentHelpers.encodingRawCharacters(in: $0, allowedCharacters: .urlFragmentAllowed)-        }          // Resolve relative path against base URL         switch sourceType {@@ -343,20 +334,20 @@ enum ImagePathResolver {             if baseURL.isFileURL {                 resolved = baseURL.appendingPathComponent(decodedPath).standardized             } else {-                let encodedSource = URLComponentHelpers.reassembleURL(path: encodedPath, query: encodedQuery, fragment: encodedFragment)-                guard let url = URL(string: encodedSource, relativeTo: baseURL) else {+                let encodedSource = PrismURL.joined(path: encodedPath, query: encodedQuery, fragment: encodedFragment)+                guard let url = PrismURL.url(fromEncodedReference: encodedSource, relativeTo: baseURL) else {                     return .failed(.invalidURL)                 }-                resolved = url.absoluteURL.standardized+                resolved = url.standardized             }             return resolvedSource(url: resolved, asLocal: true)          case .url:-            let encodedSource = URLComponentHelpers.reassembleURL(path: encodedPath, query: encodedQuery, fragment: encodedFragment)-            guard let resolved = URL(string: encodedSource, relativeTo: baseURL) else {+            let encodedSource = PrismURL.joined(path: encodedPath, query: encodedQuery, fragment: encodedFragment)+            guard let resolved = PrismURL.url(fromEncodedReference: encodedSource, relativeTo: baseURL) else {                 return .failed(.invalidURL)             }-            return resolvedSource(url: resolved.absoluteURL.standardized, asLocal: false)+            return resolvedSource(url: resolved.standardized, asLocal: false)          case .clipboard:             return .failed(.noContext)
prism/Services/GitHubURLTransformer.swift Modified +41 / -12
diff --git a/prism/Services/GitHubURLTransformer.swift b/prism/Services/GitHubURLTransformer.swiftindex 086960b..9c3f676 100644--- a/prism/Services/GitHubURLTransformer.swift+++ b/prism/Services/GitHubURLTransformer.swift@@ -43,29 +43,44 @@ enum GitHubURLTransformer {             return TransformResult(fetchURL: url, displayURL: url)         } -        // Path components: ["", "owner", "repo", "blob", "ref", "path..."]-        let components = url.pathComponents-        // Need at least: /, owner, repo, blob, ref, path (6 components)-        guard components.count >= 6,-              components[3] == "blob" else {+        // Use URLComponents to read the path and preserve query/fragment.+        // No fallback to `url.path`: that accessor decodes, and the+        // `percentEncodedPath` setter below traps on a string that is not+        // already valid percent-encoding, so a decoded path with a raw space+        // in it would crash rather than degrade. Pass the URL through+        // untransformed instead.+        guard let originalComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else {+            return TransformResult(fetchURL: url, displayURL: url)+        }++        // Split the ENCODED path, never `url.pathComponents`: that property+        // hands back decoded components, and rejoining them turns an escaped+        // `%2F` in a ref or filename into a live separator while the+        // non-encoded `.path` setter re-encodes the rest wholesale. Splitting+        // the already-encoded text and assigning it back through+        // `percentEncodedPath` moves the segments verbatim — this function+        // reshapes a path, it has no business re-encoding one (T-2140).+        let segments = pathSegments(of: originalComponents.percentEncodedPath)++        // Segments: ["", "owner", "repo", "blob", "ref", "path..."]+        // Need at least: /, owner, repo, blob, ref, path (6 segments)+        guard segments.count >= 6,+              segments[3] == "blob" else {             return TransformResult(fetchURL: url, displayURL: url)         }          // Build raw URL: raw.githubusercontent.com/{owner}/{repo}/{ref}/{path...}         // Remove the "blob" segment (index 3)-        let rawPath = (components[1...2] + components[4...]).joined(separator: "/")--        // Use URLComponents to preserve query/fragment from the original URL-        let originalComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)+        let rawPath = (segments[1...2] + segments[4...]).joined(separator: "/")          var rawComponents = URLComponents()         // Normalize the scheme too — the loader fetches this URL directly, so         // an uppercase scheme from the original input must not leak through.         rawComponents.scheme = url.scheme?.lowercased()         rawComponents.host = "raw.githubusercontent.com"-        rawComponents.path = "/" + rawPath-        rawComponents.percentEncodedQuery = originalComponents?.percentEncodedQuery-        rawComponents.percentEncodedFragment = originalComponents?.percentEncodedFragment+        rawComponents.percentEncodedPath = "/" + rawPath+        rawComponents.percentEncodedQuery = originalComponents.percentEncodedQuery+        rawComponents.percentEncodedFragment = originalComponents.percentEncodedFragment          guard let fetchURL = rawComponents.url else {             return TransformResult(fetchURL: url, displayURL: url)@@ -73,4 +88,18 @@ enum GitHubURLTransformer {          return TransformResult(fetchURL: fetchURL, displayURL: url)     }++    /// Splits an encoded path into segments, matching the indices+    /// `URL.pathComponents` produces (leading `""` stands in for its `"/"`).+    ///+    /// A trailing empty segment is dropped so a directory-style path keeps the+    /// component count `pathComponents` reported, and the `>= 6` guard above+    /// keeps rejecting `/owner/repo/blob/ref/`.+    private static func pathSegments(of encodedPath: String) -> [String] {+        var segments = encodedPath.split(separator: "/", omittingEmptySubsequences: false).map(String.init)+        if segments.count > 1, segments.last == "" {+            segments.removeLast()+        }+        return segments+    } }
prism/ViewModels/DocumentFlowCoordinator.swift Modified +19 / -1
diff --git a/prism/ViewModels/DocumentFlowCoordinator.swift b/prism/ViewModels/DocumentFlowCoordinator.swiftindex 5abc1b1..2b15624 100644--- a/prism/ViewModels/DocumentFlowCoordinator.swift+++ b/prism/ViewModels/DocumentFlowCoordinator.swift@@ -155,7 +155,25 @@ final class DocumentFlowCoordinator {         } else if scheme == "prism", host == "open",                   let components = URLComponents(url: url, resolvingAgainstBaseURL: false),                   let urlParam = components.queryItems?.first(where: { $0.name == "url" })?.value,-                  let remoteURL = URL(string: urlParam) {+                  // `queryItems` has already decoded one layer, so the value+                  // arrives *partially* encoded: whatever escapes the sender+                  // wrote into the target URL survive, next to characters that+                  // are now raw. That is precisely the mixed state Foundation+                  // re-encodes wholesale, doubling every surviving escape+                  // (`%20` → `%2520`) and fetching a different resource+                  // silently. Encoding state unknown ⇒ the ambiguous entry+                  // point (T-2140).+                  let remoteURL = PrismURL.absoluteURL(fromAmbiguousText: urlParam),+                  // Reject non-web schemes here rather than one frame later in+                  // URLDocumentLoader. NOTE the failure modes differ: the+                  // loader set `loadError` ("Only http and https URLs are+                  // supported"), and URLInputSheet shows its own message,+                  // whereas failing this guard drops out of the whole `else+                  // if` chain and `handleOpenURL` returns having done nothing+                  // — a malformed `prism://open?url=` link is now silently+                  // ignored. Tracked as a follow-up (T-2140 review).+                  let remoteScheme = remoteURL.scheme?.lowercased(),+                  remoteScheme == "http" || remoteScheme == "https" {             requestOpenRemoteURL(remoteURL)         } else if scheme == "http" || scheme == "https" {             requestOpenRemoteURL(url)
prism/Views/URLInputSheet.swift Modified +5 / -5
diff --git a/prism/Views/URLInputSheet.swift b/prism/Views/URLInputSheet.swiftindex 3213b1c..9d6681b 100644--- a/prism/Views/URLInputSheet.swift+++ b/prism/Views/URLInputSheet.swift@@ -91,11 +91,11 @@ struct URLInputSheet: View {         let trimmed = urlText.trimmingCharacters(in: .whitespacesAndNewlines)         guard !trimmed.isEmpty else { return } -        // Parse via the normalizing helper rather than `URL(string:)`-        // directly, which double-escapes paths that mix existing-        // percent-escapes with raw characters — the wrong `%2520` would then-        // flow into GitHubURLTransformer and the fetch (T-1624).-        guard let url = URLComponentHelpers.normalizedAbsoluteURL(from: trimmed),+        // The user pasted this, so its encoding state is unknown: it may be+        // raw, already encoded, or a mixture. `URL(string:)` would double-escape+        // the mixture and the wrong `%2520` would flow into+        // GitHubURLTransformer and the fetch (T-1624).+        guard let url = PrismURL.absoluteURL(fromAmbiguousText: trimmed),               let host = url.host, !host.isEmpty else {             errorMessage = String(localized: "Invalid URL. Please enter a valid web address.")             return
prismTests/URLEncodingCorpus.swift Added +281 / -0
diff --git a/prismTests/URLEncodingCorpus.swift b/prismTests/URLEncodingCorpus.swiftnew file mode 100644index 0000000..b357d28--- /dev/null+++ b/prismTests/URLEncodingCorpus.swift@@ -0,0 +1,281 @@+//+//  URLEncodingCorpus.swift+//  prismTests+//+//  T-2140: one table of encoding-ambiguous inputs, driven through every+//  string→URL conversion path in the app.+//++import Foundation++// MARK: - The Corpus++/// One encoding-ambiguous input and the single form every conversion path in+/// Prism must produce for it.+///+/// The three components travel together because the class of bug this corpus+/// exists to catch is *asymmetry between components*: T-1624 fixed the path+/// and left the query alone, T-1663 fixed the query and left the path alone,+/// and T-2140 found the path's fix was itself a different bug. A case that+/// carries a path, a query, and a fragment simultaneously cannot be satisfied+/// by fixing one of them.+struct URLEncodingCase: Sendable, CustomStringConvertible {+    /// Identifies the case in failure output.+    let name: String++    /// A single path segment, without file extension, as an author or sender+    /// would write it. The driver appends the extension each path needs, so+    /// the same row can drive a link (`.html`), an image (`.png`) and a+    /// GitHub blob URL without the routing rules changing under it.+    let pathSegment: String+    /// The one correct percent-encoded form of `pathSegment`.+    let encodedPathSegment: String++    /// A query as an author or sender would write it (no leading `?`).+    let query: String+    /// The one correct percent-encoded form of `query`.+    let encodedQuery: String++    /// A fragment as an author or sender would write it (no leading `#`).+    let fragment: String+    /// The one correct percent-encoded form of `fragment`.+    let encodedFragment: String++    var description: String { name }+}++/// The shared table. Every conversion path is driven through all of it.+///+/// Each case is one of the shapes that historically distinguished broken code+/// from correct code. Fully-raw and fully-encoded inputs are byte-identical+/// under a broken encoder and a correct one, which is exactly why the+/// pre-T-1663 corpus (`?ref=main`, no pre-existing escape anywhere) passed+/// while three separate defects shipped — so the mixed, reserved-character and+/// invalid-escape rows carry most of the weight here.+enum URLEncodingCorpus {++    static let cases: [URLEncodingCase] = [+        // Fully raw. Correct and broken code agree; present so a regression+        // toward "encode nothing" is still caught.+        URLEncodingCase(+            name: "raw",+            pathSegment: "my file",+            encodedPathSegment: "my%20file",+            query: "q=a b",+            encodedQuery: "q=a%20b",+            fragment: "sec one",+            encodedFragment: "sec%20one"+        ),+        // Fully encoded. The idempotence guard: converting an already-correct+        // string must not change it.+        URLEncodingCase(+            name: "fullyEncoded",+            pathSegment: "my%20file",+            encodedPathSegment: "my%20file",+            query: "q=a%20b",+            encodedQuery: "q=a%20b",+            fragment: "sec%20one",+            encodedFragment: "sec%20one"+        ),+        // Mixed: an existing escape beside a raw character. The shape+        // Foundation re-encodes wholesale, doubling `%20` into `%2520`+        // (T-1624/T-1663/T-2140 Defect A).+        URLEncodingCase(+            name: "mixed",+            pathSegment: "my%20file and",+            encodedPathSegment: "my%20file%20and",+            query: "q=a%20b c",+            encodedQuery: "q=a%20b%20c",+            fragment: "sec%20one two",+            encodedFragment: "sec%20one%20two"+        ),+        // `%26` beside a live `&`. Decoding the escape would turn a literal+        // ampersand into a parameter separator — the argument the helper's own+        // doc comment made for the query, and never made for the path.+        URLEncodingCase(+            name: "escapedAmpersand",+            pathSegment: "a%26b c",+            encodedPathSegment: "a%26b%20c",+            query: "a=%26&b=c d",+            encodedQuery: "a=%26&b=c%20d",+            fragment: "f%26g h",+            encodedFragment: "f%26g%20h"+        ),+        // `%2F` beside a raw character: T-2140 Defect B. `.urlPathAllowed`+        // contains `/`, so decode-then-encode splits one segment into two and+        // the resource identity changes. No test anywhere contained a `%2F`+        // before this corpus, which is how the defect shipped inside the fix+        // for the class it belongs to.+        URLEncodingCase(+            name: "encodedSlash",+            pathSegment: "group%2Fproj file",+            encodedPathSegment: "group%2Fproj%20file",+            query: "path=a%2Fb c",+            encodedQuery: "path=a%2Fb%20c",+            fragment: "a%2Fb c",+            encodedFragment: "a%2Fb%20c"+        ),+        // A lone `%` that starts no triplet. Must become `%25`, matching+        // Foundation's own fallback for a fully-raw component.+        URLEncodingCase(+            name: "lonePercent",+            pathSegment: "100% real",+            encodedPathSegment: "100%25%20real",+            query: "p=100% done",+            encodedQuery: "p=100%25%20done",+            fragment: "100% there",+            encodedFragment: "100%25%20there"+        ),+        // `%zz` looks like a triplet and is not one. The scanner must reject+        // it on the hex-digit test rather than on the `%` alone.+        URLEncodingCase(+            name: "invalidEscape",+            pathSegment: "a%zz b",+            encodedPathSegment: "a%25zz%20b",+            query: "q=%zz b",+            encodedQuery: "q=%25zz%20b",+            fragment: "%zz b",+            encodedFragment: "%25zz%20b"+        ),+        // Non-ASCII, which encodes to multi-byte UTF-8 triplets. Guards+        // against any per-character (rather than per-run) encoder.+        URLEncodingCase(+            name: "nonASCII",+            pathSegment: "café ☕",+            encodedPathSegment: "caf%C3%A9%20%E2%98%95",+            query: "q=café ☕",+            encodedQuery: "q=caf%C3%A9%20%E2%98%95",+            fragment: "café ☕",+            encodedFragment: "caf%C3%A9%20%E2%98%95"+        ),+        // `%23` and `%3F` — the other two escaped separators. `#` and `?` are+        // absent from `.urlPathAllowed` for the same reason `/` is present in+        // it: they delimit. Decoding either does more damage than `%2F` did,+        // since it does not merely split a segment, it splits the URL. Same+        // class as `encodedSlash`, different character.+        URLEncodingCase(+            name: "encodedSeparators",+            pathSegment: "a%23b%3Fc d",+            encodedPathSegment: "a%23b%3Fc%20d",+            query: "q=a%23b%3Fc d",+            encodedQuery: "q=a%23b%3Fc%20d",+            fragment: "a%23b%3Fc d",+            encodedFragment: "a%23b%3Fc%20d"+        ),+        // A `%` with fewer than two characters after it. This takes the+        // scanner's `limitedBy` branch, which is a different branch from+        // `lonePercent`'s hex-digit failure and was otherwise unexercised.+        URLEncodingCase(+            name: "trailingPercent",+            pathSegment: "discount 50%",+            encodedPathSegment: "discount%2050%25",+            query: "p=50%",+            encodedQuery: "p=50%25",+            fragment: "off 50%",+            encodedFragment: "off%2050%25"+        ),+        // Uppercase hex in an existing triplet. Case is meaningful to nobody+        // but must be preserved rather than normalised, or fully-encoded input+        // stops being a fixed point.+        URLEncodingCase(+            name: "uppercaseHexEscape",+            pathSegment: "a%2Fb%20c",+            encodedPathSegment: "a%2Fb%20c",+            query: "q=%2Fx%20y",+            encodedQuery: "q=%2Fx%20y",+            fragment: "%2Fx%20y",+            encodedFragment: "%2Fx%20y"+        )+    ]+}++// MARK: - Conversion Paths++/// Every production path by which a string becomes a URL in Prism.+///+/// This enum is the mechanism that makes "fix one branch, leave the siblings"+/// hard. Instances 2, 3 and 5 in the T-2140 report all existed because one+/// branch was tested and its siblings were not; the driver in+/// `URLEncodingCorpusTests` switches exhaustively over `allCases`, so adding a+/// case here without wiring it to a real production call is a compile error,+/// and every case that exists is automatically run against the whole corpus.+///+/// Adding a *new* conversion path in production therefore has a mechanical+/// tell: if it is not represented here, `URLConversionPath.allCases` does not+/// cover it, and the source scan in `URLChokepointAdoptionTests` is what+/// notices. Note the limit of that division of labour — the scan only looks+/// for `URL(string:)` over a non-literal and for hand-rolled percent coding,+/// so a conversion built out of the non-encoded `URLComponents` setters+/// (which is what instance 2 in the report was) is caught by neither.+enum URLConversionPath: String, CaseIterable, Sendable {+    /// `PrismURL.absoluteURL(fromAmbiguousText:)` directly.+    case chokepointAbsolute++    /// `LinkPathResolver.resolve` — absolute-URL branch.+    case linkAbsolute+    /// `LinkPathResolver.resolve` — root-relative (`/…`) branch, `.url` source.+    case linkRootRelative+    /// `LinkPathResolver.resolve` — plain-relative branch, `.url` source.+    case linkPlainRelative++    /// `ImagePathResolver.resolve` — absolute-URL branch.+    case imageAbsolute+    /// `ImagePathResolver.resolve` — root-relative (`/…`) branch, `.url` source.+    case imageRootRelative+    /// `ImagePathResolver.resolve` — plain-relative branch, `.url` source.+    case imagePlainRelative++    /// `DocumentFlowCoordinator.handleOpenURL` with a `prism://open?url=` link.+    case deepLink++    /// `GitHubURLTransformer.transform` over a blob URL.+    case gitHubTransform++    /// The file extension the driver appends to the corpus path segment for+    /// this path, chosen so the target's *routing* is uniform across paths and+    /// only its encoding is under test: a link to `.html` always resolves to+    /// `.externalURL` with the fragment re-embedded, an image to `.png` always+    /// resolves to `.remote`. (Markdown-extension links carry the fragment+    /// beside the URL instead; that split is covered by the targeted tests in+    /// `URLEncodingCorpusTests`.)+    var fileExtension: String {+        switch self {+        case .imageAbsolute, .imageRootRelative, .imagePlainRelative: "png"+        case .chokepointAbsolute, .linkAbsolute, .linkRootRelative,+             .linkPlainRelative, .deepLink, .gitHubTransform: "html"+        }+    }+}++/// The encoded components read back out of whatever a conversion path+/// produced, so one expectation table can be compared against every path.+struct URLConversionOutcome: Sendable, Equatable {+    /// The final path segment, percent-encoded.+    var pathSegment: String?+    var query: String?+    var fragment: String?++    /// Reads the components back from a produced URL **without decoding+    /// them**. `URL.path` decodes and would hide exactly the double-escaping+    /// this corpus exists to detect; `URL.query`/`.fragment` happen not to on+    /// current Foundation, but naming the `percentEncoded*` accessors makes+    /// the requirement explicit rather than accidental.+    ///+    /// Only the LAST path segment is captured, so corruption of a *mid-path*+    /// segment is not visible here — `deepLinkEncodedSlashStaysOneSegment` in+    /// `DocumentFlowCoordinatorURLTests` is what pins the GitLab shape.+    init(url: URL) {+        let components = URLComponents(url: url, resolvingAgainstBaseURL: false)+        let encodedPath = components?.percentEncodedPath ?? ""+        pathSegment = encodedPath.split(separator: "/", omittingEmptySubsequences: false).last.map(String.init)+        query = components?.percentEncodedQuery+        fragment = components?.percentEncodedFragment+    }++    /// The outcome the corpus row demands, for a path that appends `extension`.+    init(expecting testCase: URLEncodingCase, fileExtension: String) {+        pathSegment = "\(testCase.encodedPathSegment).\(fileExtension)"+        query = testCase.encodedQuery+        fragment = testCase.encodedFragment+    }+}
prismTests/URLEncodingCorpusTests.swift Added +301 / -0
diff --git a/prismTests/URLEncodingCorpusTests.swift b/prismTests/URLEncodingCorpusTests.swiftnew file mode 100644index 0000000..d7f74a0--- /dev/null+++ b/prismTests/URLEncodingCorpusTests.swift@@ -0,0 +1,301 @@+//+//  URLEncodingCorpusTests.swift+//  prismTests+//+//  T-2140: drives the shared corpus through every conversion path.+//++import Foundation+import Testing+@testable import prism++/// Runs `URLEncodingCorpus` through every `URLConversionPath`.+///+/// The point of this suite is the *cartesian product*. Instances 2, 3 and 5 in+/// the T-2140 report each existed because one branch was tested and its+/// siblings were not — the query got a selective encoder and the path kept its+/// decode-then-encode pair, in the same function, for two years. With one table+/// and an exhaustive switch, "fix one branch, leave the siblings" no longer+/// produces a green suite: the row that exposes the bug runs against every+/// branch automatically, the moment it is added.+@Suite("URL encoding corpus", .serialized)+@MainActor+struct URLEncodingCorpusTests {++    // MARK: - Fixtures++    private static let host = "https://example.com"+    private static let documentBase = URL(string: "https://example.com/docs/page.md")!++    /// The target as a sender or author would write it: encoding state unknown.+    private func ambiguousAbsolute(_ testCase: URLEncodingCase, fileExtension: String) -> String {+        "\(Self.host)/docs/\(testCase.pathSegment).\(fileExtension)?\(testCase.query)#\(testCase.fragment)"+    }++    /// The same target as a relative reference, for the resolver branches.+    private func ambiguousReference(+        _ testCase: URLEncodingCase,+        fileExtension: String,+        rootRelative: Bool+    ) -> String {+        let prefix = rootRelative ? "/docs/" : ""+        return "\(prefix)\(testCase.pathSegment).\(fileExtension)?\(testCase.query)#\(testCase.fragment)"+    }++    // MARK: - The Driver++    /// Produces the encoded components a conversion path yields for a corpus row.+    ///+    /// The switch is exhaustive over `URLConversionPath`, so adding a case to+    /// that enum without wiring it to a real production call will not compile.+    private func outcome(+        for path: URLConversionPath,+        _ testCase: URLEncodingCase+    ) async -> URLConversionOutcome? {+        let ext = path.fileExtension++        switch path {+        case .chokepointAbsolute:+            return PrismURL+                .absoluteURL(fromAmbiguousText: ambiguousAbsolute(testCase, fileExtension: ext))+                .map(URLConversionOutcome.init(url:))++        case .linkAbsolute:+            return resolvedLinkOutcome(+                destination: ambiguousAbsolute(testCase, fileExtension: ext),+                baseURL: nil+            )++        case .linkRootRelative:+            return resolvedLinkOutcome(+                destination: ambiguousReference(testCase, fileExtension: ext, rootRelative: true),+                baseURL: Self.documentBase+            )++        case .linkPlainRelative:+            return resolvedLinkOutcome(+                destination: ambiguousReference(testCase, fileExtension: ext, rootRelative: false),+                baseURL: Self.documentBase+            )++        case .imageAbsolute:+            return resolvedImageOutcome(+                source: ambiguousAbsolute(testCase, fileExtension: ext),+                baseURL: nil+            )++        case .imageRootRelative:+            return resolvedImageOutcome(+                source: ambiguousReference(testCase, fileExtension: ext, rootRelative: true),+                baseURL: Self.documentBase+            )++        case .imagePlainRelative:+            return resolvedImageOutcome(+                source: ambiguousReference(testCase, fileExtension: ext, rootRelative: false),+                baseURL: Self.documentBase+            )++        case .deepLink:+            let target = ambiguousAbsolute(testCase, fileExtension: ext)+            return await Self.fetchedURL(forDeepLinkTarget: target).map(URLConversionOutcome.init(url:))++        case .gitHubTransform:+            // The transformer takes a URL, not a string, so feed it one that is+            // already correct and assert it does not corrupt what it reshapes.+            let blob = "https://github.com/owner/repo/blob/main/docs/"+                + "\(testCase.encodedPathSegment).\(ext)"+                + "?\(testCase.encodedQuery)#\(testCase.encodedFragment)"+            guard let url = PrismURL.absoluteURL(fromEncodedText: blob) else { return nil }+            return URLConversionOutcome(url: GitHubURLTransformer.transform(url).fetchURL)+        }+    }++    private func resolvedLinkOutcome(destination: String, baseURL: URL?) -> URLConversionOutcome? {+        let resolved = LinkPathResolver.resolve(+            destination: destination,+            baseURL: baseURL,+            sourceType: .url+        )+        guard case .externalURL(let url) = resolved else {+            Issue.record("Expected .externalURL, got \(resolved) for \(destination)")+            return nil+        }+        return URLConversionOutcome(url: url)+    }++    private func resolvedImageOutcome(source: String, baseURL: URL?) -> URLConversionOutcome? {+        let resolved = ImagePathResolver.resolve(+            source: source,+            baseURL: baseURL,+            sourceType: .url+        )+        guard case .remote(let url) = resolved else {+            Issue.record("Expected .remote, got \(resolved) for \(source)")+            return nil+        }+        return URLConversionOutcome(url: url)+    }++    // MARK: - Deep-Link Harness++    /// Records the URL the coordinator actually hands to its loader.+    private actor URLCapture {+        private(set) var last: URL?+        func record(_ url: URL) { last = url }+        func value() -> URL? { last }+    }++    /// Wraps `target` in a `prism://open?url=` deep link and returns the URL+    /// the remote coordinator would fetch.+    ///+    /// The target is escaped down to the RFC 3986 unreserved set, so+    /// `URLComponents.queryItems` decodes exactly one layer and hands the+    /// coordinator `target` byte-for-byte — reproducing the partially-encoded+    /// string that is the whole of Defect A.+    private static func fetchedURL(forDeepLinkTarget target: String) async -> URL? {+        let unreserved = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))+        guard let escaped = target.addingPercentEncoding(withAllowedCharacters: unreserved),+              let deepLink = URL(string: "prism://open?url=" + escaped) else {+            return nil+        }++        let capture = URLCapture()+        let remote = RemoteContentCoordinator(loader: { url in+            await capture.record(url)+            throw CancellationError()+        })+        let flow = DocumentFlowCoordinator()+        flow.setRemoteCoordinatorForTests(remote)++        flow.handleOpenURL(deepLink)+        await remote.downloadTask?.value+        return await capture.value()+    }++    // MARK: - The Product++    @Test(+        "Every conversion path encodes every corpus row identically",+        arguments: URLConversionPath.allCases, URLEncodingCorpus.cases+    )+    func conversionPathMatchesCorpus(path: URLConversionPath, testCase: URLEncodingCase) async {+        let expected = URLConversionOutcome(expecting: testCase, fileExtension: path.fileExtension)++        guard let actual = await outcome(for: path, testCase) else {+            Issue.record("\(path.rawValue) produced no URL for corpus row '\(testCase.name)'")+            return+        }++        #expect(+            actual == expected,+            "\(path.rawValue) / \(testCase.name): expected \(expected), got \(actual)"+        )+    }++    // MARK: - Idempotence++    /// A second pass over an already-converted string must be a no-op.+    ///+    /// Every defect in this class has been a conversion that is not a fixed+    /// point — `%20` → `%2520` on one side, `%2F` → `/` on the other. Asserting+    /// idempotence catches both directions without naming either.+    @Test("Component encoding is idempotent", arguments: URLEncodingCorpus.cases)+    func componentEncodingIsIdempotent(testCase: URLEncodingCase) {+        for (raw, encoded, component) in [+            (testCase.pathSegment, testCase.encodedPathSegment, PrismURL.Component.path),+            (testCase.query, testCase.encodedQuery, PrismURL.Component.query),+            (testCase.fragment, testCase.encodedFragment, PrismURL.Component.fragment)+        ] {+            let once = PrismURL.percentEncoded(raw, as: component)+            #expect(once == encoded, "\(testCase.name)/\(component): \(raw) → \(once)")+            #expect(+                PrismURL.percentEncoded(once, as: component) == once,+                "\(testCase.name)/\(component): not a fixed point"+            )+        }+    }++    // MARK: - Whole-URL Shapes The Component Table Cannot Express++    /// Credentials and scheme casing are properties of the authority, not of a+    /// component, so they cannot ride the corpus table. They are asserted here+    /// against the chokepoint directly.+    @Test("Authority passes through verbatim so credential rejection still fires")+    func credentialsSurviveNormalization() {+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "https://user:pass@example.com/docs/my%20file and.md"+        )+        let components = url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }+        #expect(components?.user == "user")+        #expect(components?.password == "pass")+        #expect(components?.percentEncodedPath == "/docs/my%20file%20and.md")+    }++    @Test("Upper-case scheme is matched case-insensitively and preserved")+    func upperCaseSchemeNormalizes() {+        let url = PrismURL.absoluteURL(fromAmbiguousText: "HTTPS://example.com/docs/my%20file and.md")+        #expect(url == URL(string: "HTTPS://example.com/docs/my%20file%20and.md")!)+    }++    @Test("Empty components survive conversion")+    func emptyComponentsSurvive() {+        // An empty path, query, and fragment are all legal and all distinct+        // from absent. They cannot ride the corpus table because several paths+        // route an empty destination to `.unresolvable` before any encoding+        // happens, which is a routing decision rather than an encoding one.+        #expect(PrismURL.percentEncoded("", as: .path).isEmpty)+        #expect(PrismURL.percentEncoded("", as: .query).isEmpty)+        #expect(PrismURL.percentEncoded("", as: .fragment).isEmpty)++        let url = PrismURL.absoluteURL(fromAmbiguousText: "https://example.com/docs/file.md?#")+        let components = url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }+        #expect(components?.percentEncodedQuery == "")+        #expect(components?.percentEncodedFragment == "")+    }++    @Test("A string with no scheme is not an absolute URL")+    func noSchemeIsNotAbsolute() {+        #expect(PrismURL.absoluteURL(fromAmbiguousText: "docs/my file.md") == nil)+        #expect(PrismURL.absoluteURL(fromAmbiguousText: "example.com/file.md") == nil)+    }++    @Test("Opaque forms are normalized over their body, not re-encoded wholesale")+    func opaqueFormNormalizes() {+        // `mailto:` reached `URL(string:)` directly until T-2140, so a mixed+        // encoded/raw body doubled its escapes like every other component.+        #expect(+            PrismURL.absoluteURL(fromAmbiguousText: "mailto:someone@example.com")+                == URL(string: "mailto:someone@example.com")!+        )+        let mixed = PrismURL.absoluteURL(fromAmbiguousText: "mailto:a@b.com?subject=a%20b c")+        #expect(mixed == URL(string: "mailto:a@b.com?subject=a%20b%20c")!)+    }++    // MARK: - Markdown-Extension Routing++    /// The uniform corpus deliberately targets `.html`/`.png` so every path+    /// yields a URL carrying all three components. A markdown-extension link+    /// takes the other route: the fragment is lifted out of the URL into+    /// `ResolvedLink`'s associated value. It must arrive there un-mangled too.+    @Test("Markdown links carry the fragment beside the URL, still encoded",+          arguments: URLEncodingCorpus.cases)+    func markdownLinkFragmentIsNotMangled(testCase: URLEncodingCase) {+        let destination = "https://example.com/docs/\(testCase.pathSegment).md"+            + "?\(testCase.query)#\(testCase.fragment)"+        let resolved = LinkPathResolver.resolve(+            destination: destination,+            baseURL: nil,+            sourceType: .url+        )+        guard case .remoteMarkdown(let url, let fragment) = resolved else {+            Issue.record("Expected .remoteMarkdown, got \(resolved)")+            return+        }+        let outcome = URLConversionOutcome(url: url)+        #expect(outcome.pathSegment == "\(testCase.encodedPathSegment).md")+        #expect(outcome.query == testCase.encodedQuery)+        #expect(outcome.fragment == nil, "the fragment moves out of the URL")+        #expect(fragment == testCase.encodedFragment)+    }+}
prismTests/URLChokepointAdoptionTests.swift Added +233 / -0
diff --git a/prismTests/URLChokepointAdoptionTests.swift b/prismTests/URLChokepointAdoptionTests.swiftnew file mode 100644index 0000000..cb01c57--- /dev/null+++ b/prismTests/URLChokepointAdoptionTests.swift@@ -0,0 +1,233 @@+//+//  URLChokepointAdoptionTests.swift+//  prismTests+//+//  T-2140: the mechanism that notices a NEW conversion path.+//++import Foundation+import Testing+@testable import prism++/// Scans production source for string→URL conversions that bypass ``PrismURL``.+///+/// `URLEncodingCorpusTests` proves every *known* conversion path is correct.+/// It cannot notice a conversion path nobody added to `URLConversionPath` —+/// and that is precisely how this class recurred three times: T-1624 fixed the+/// shapes it knew about, T-1663 found two siblings, and T-2140 found a fourth+/// call site in a coordinator that matched neither earlier pattern. Each round+/// wrote the residual into a prose note that only someone who already suspected+/// a problem would read.+///+/// This suite is the mechanical version of that note. It fails when a new+/// conversion appears without either going through ``PrismURL`` or being+/// entered below with a reason — so the enumeration happens at the moment the+/// call site is written, not two tickets later.+///+/// It is deliberately narrow. Only shapes that are *provably ambiguous* are+/// flagged: `URL(string:)` over a string literal is a compile-time constant and+/// can carry no user data, so it is not a finding.+@Suite("PrismURL chokepoint adoption")+struct URLChokepointAdoptionTests {++    // MARK: - Allowlist++    /// A conversion that bypasses ``PrismURL`` on purpose, and why.+    ///+    /// Each entry is a decision from the T-2140 sweep, recorded here so it does+    /// not have to be re-derived. Adding an entry is cheap; adding one without+    /// a reason is visible in review, which is the whole point.+    private struct Exemption {+        let file: String+        let reason: String+    }++    private static let exemptions: [Exemption] = [+        Exemption(+            file: "PrismURL.swift",+            reason: "The chokepoint itself — it is where the Foundation API is allowed to live."+        ),+        Exemption(+            file: "WebDocumentMessageRouter.swift",+            reason: """+                Forwards the raw href from the bridge to LinkPathResolver, which then \+                normalises it. A URL round-trip here would re-escape existing escapes \+                before the resolver ever saw them (T-1590).+                """+        ),+        Exemption(+            file: "DocumentReaderView.swift",+            reason: """+                Builds a URL only to read its scheme and host for routing, then discards \+                it. The URL never reaches a loader, so its path encoding is unobservable.+                """+        ),+        Exemption(+            file: "ImageDetailWindow.swift",+            reason: """+                Builds a URL only to read lastPathComponent for a window title. Display \+                text, never a fetch.+                """+        ),+        Exemption(+            file: "NoteModels.swift",+            reason: """+                urlSafeEncoded encodes `/` as `%2F` as a FILESYSTEM TRAVERSAL GUARD for a \+                note filename, not as URL encoding. It must never be pulled into the \+                chokepoint: the two have opposite requirements for `/`.+                """+        ),+        Exemption(+            file: "BlockHTMLEmitter.swift",+            reason: """+                The prism-doc://img/?src= round trip carries the source through \+                URLQueryItem and reads it back with queryItems, which is lossless. The \+                addingPercentEncoding is an unreachable fallback for a URLComponents that \+                cannot fail.+                """+        ),+        Exemption(+            file: "LinkPathResolver.swift",+            reason: """+                Decodes deliberately in two places: an anchor-only destination (consumed \+                as text, never as a URL) and the filesystem path handed to \+                appendingPathComponent (filesystem paths are not percent-encoded).+                """+        ),+        Exemption(+            file: "ImagePathResolver.swift",+            reason: """+                Decodes deliberately in two places: a non-base64 data URI payload, and the \+                filesystem path handed to appendingPathComponent.+                """+        ),+        Exemption(+            file: "DocumentSession.swift",+            reason: "scrollToAnchor decodes a fragment before slug matching; it is text, not a URL."+        )+    ]++    // MARK: - Findings++    private struct Finding: CustomStringConvertible {+        let file: String+        let line: Int+        let text: String+        let rule: String++        var description: String { "\(file):\(line) [\(rule)] \(text)" }+    }++    /// `URL(string: x)` where `x` is not a string literal. A literal (including+    /// one with interpolation, which still starts with `"`) is a compile-time+    /// constant; anything else is a runtime string of unknown encoding state.+    private static func nonLiteralURLString(in line: String) -> Bool {+        guard let range = line.range(of: "URL(string: ") else { return false }+        return line[range.upperBound...].first != "\""+    }++    private static func percentCoding(in line: String) -> Bool {+        line.contains("addingPercentEncoding") || line.contains("removingPercentEncoding")+    }++    /// Strips `//` line comments so prose *about* the banned APIs — of which+    /// this codebase has a great deal — is not mistaken for a call.+    private static func stripComment(_ line: String) -> String {+        guard let range = line.range(of: "//") else { return line }+        return String(line[..<range.lowerBound])+    }++    // MARK: - The Guard++    @Test("No production file converts a non-literal string to a URL outside PrismURL")+    func productionAdoptsTheChokepoint() throws {+        let sourceRoot = try #require(+            Self.productionSourceRoot,+            "Could not locate the prism/ source tree from #filePath; the scan cannot run."+        )+        let files = try #require(Self.swiftFiles(under: sourceRoot))+        #expect(files.count > 50, "Source scan found suspiciously few files (\(files.count))")++        var findings: [Finding] = []+        let exemptFiles = Set(Self.exemptions.map(\.file))++        for file in files {+            let name = file.lastPathComponent+            guard !exemptFiles.contains(name) else { continue }+            guard let contents = try? String(contentsOf: file, encoding: .utf8) else { continue }++            for (offset, rawLine) in contents.components(separatedBy: .newlines).enumerated() {+                let line = Self.stripComment(rawLine)+                if Self.nonLiteralURLString(in: line) {+                    findings.append(Finding(+                        file: name, line: offset + 1,+                        text: line.trimmingCharacters(in: .whitespaces),+                        rule: "URL(string:) over a non-literal"+                    ))+                }+                if Self.percentCoding(in: line) {+                    findings.append(Finding(+                        file: name, line: offset + 1,+                        text: line.trimmingCharacters(in: .whitespaces),+                        rule: "hand-rolled percent coding"+                    ))+                }+            }+        }++        #expect(+            findings.isEmpty,+            """+            These conversions bypass PrismURL. Route them through \+            PrismURL.absoluteURL(fromAmbiguousText:) / .percentEncoded(_:as:), add the \+            new path to URLConversionPath so the corpus covers it, or add a documented \+            exemption to URLChokepointAdoptionTests.exemptions:+            \(findings.map(\.description).joined(separator: "\n"))+            """+        )+    }++    @Test("Every exemption names a file that still exists")+    func exemptionsAreLive() throws {+        // A stale exemption is worse than none: it silently re-opens a hole in+        // a file that was renamed or deleted.+        let sourceRoot = try #require(Self.productionSourceRoot)+        let names = Set((Self.swiftFiles(under: sourceRoot) ?? []).map(\.lastPathComponent))+        for exemption in Self.exemptions {+            #expect(names.contains(exemption.file), "Stale exemption: \(exemption.file)")+            #expect(!exemption.reason.isEmpty)+        }+    }++    // MARK: - Source Location++    /// The `prism/` production source directory, found by walking up from this+    /// test file. Compile-time path, so it resolves on the machine that built+    /// the test bundle — which is also the machine that runs it.+    private static var productionSourceRoot: URL? {+        var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent()+        for _ in 0..<6 {+            let candidate = directory.appendingPathComponent("prism", isDirectory: true)+            var isDirectory: ObjCBool = false+            if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory),+               isDirectory.boolValue,+               FileManager.default.fileExists(+                   atPath: candidate.appendingPathComponent("Services/PrismURL.swift").path+               ) {+                return candidate+            }+            directory = directory.deletingLastPathComponent()+        }+        return nil+    }++    private static func swiftFiles(under root: URL) -> [URL]? {+        guard let enumerator = FileManager.default.enumerator(+            at: root,+            includingPropertiesForKeys: [.isRegularFileKey]+        ) else {+            return nil+        }+        return enumerator.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" }+    }+}
prismTests/DocumentFlowCoordinatorURLTests.swift Modified +166 / -0
diff --git a/prismTests/DocumentFlowCoordinatorURLTests.swift b/prismTests/DocumentFlowCoordinatorURLTests.swiftindex ac47442..6399ced 100644--- a/prismTests/DocumentFlowCoordinatorURLTests.swift+++ b/prismTests/DocumentFlowCoordinatorURLTests.swift@@ -127,6 +127,172 @@ struct DocumentFlowCoordinatorURLTests {         #expect(remote.isDownloading == false)     } +    // MARK: - T-2140: prism://open?url= double-encoding++    /// Records the URL the coordinator actually hands to its loader.+    ///+    /// The `hangingLoader` above only exposes `remote.downloadingHost`, which+    /// cannot observe path, query, or fragment encoding. Capturing the loader+    /// argument is the only way to see the URL the download would fetch.+    private actor URLCapture {+        private(set) var urls: [URL] = []+        func record(_ url: URL) { urls.append(url) }+        var last: URL? { urls.last }+    }++    /// Drives `handleOpenURL` with a `prism://open?url=` deep link and returns+    /// the URL the remote coordinator would fetch.+    ///+    /// The loader throws `CancellationError` so the request finishes+    /// immediately without creating a session; the download task is awaited+    /// deterministically rather than slept on.+    private func fetchedURL(forDeepLink deepLink: String) async -> URL? {+        let capture = URLCapture()+        let remote = RemoteContentCoordinator(loader: { url in+            await capture.record(url)+            throw CancellationError()+        })+        let flow = DocumentFlowCoordinator()+        flow.setRemoteCoordinatorForTests(remote)++        flow.handleOpenURL(URL(string: deepLink)!)++        let task = remote.downloadTask+        await task?.value+        return await capture.last+    }++    @Test("Deep link with a mixed encoded/raw path does not double-escape (T-2140)")+    func deepLinkMixedPathDoesNotDoubleEscape() async {+        // `URLComponents.queryItems` already decodes one layer, yielding+        // `https://example.com/my%20file and more.md`. Foundation then treats+        // that whole path component as unencoded and re-encodes it wholesale,+        // doubling the surviving `%20` into `%2520`.+        let url = await fetchedURL(+            forDeepLink: "prism://open?url=https%3A%2F%2Fexample.com%2Fmy%2520file%20and%20more.md"+        )+        #expect(url == URL(string: "https://example.com/my%20file%20and%20more.md")!)+    }++    @Test("Deep link with a fully raw path is encoded exactly once (T-2140)")+    func deepLinkFullyRawPathEncodedOnce() async {+        // Guard: this shape is already correct and must stay correct.+        let url = await fetchedURL(+            forDeepLink: "prism://open?url=https%3A%2F%2Fexample.com%2Fmy%20file%20and%20more.md"+        )+        #expect(url == URL(string: "https://example.com/my%20file%20and%20more.md")!)+    }++    @Test("Deep link with a fully encoded path is idempotent (T-2140)")+    func deepLinkFullyEncodedPathIsIdempotent() async {+        // Guard: this shape is already correct and must stay correct.+        let url = await fetchedURL(+            forDeepLink: "prism://open?url=https%3A%2F%2Fexample.com%2Fmy%2520file.md"+        )+        #expect(url == URL(string: "https://example.com/my%20file.md")!)+    }++    @Test("Deep link keeps an escaped ampersand escaped while encoding a raw space (T-2140)")+    func deepLinkEscapedAmpersandInQueryStaysEscaped() async {+        // After one decode the query reads `?q=a%26b c`: an escaped literal+        // ampersand plus a raw space. The `%26` must survive as an escape —+        // decoding it would turn it into a parameter separator — while the+        // raw space is encoded.+        let url = await fetchedURL(+            forDeepLink: "prism://open?url=https%3A%2F%2Fexample.com%2Ffile.md%3Fq%3Da%2526b%20c"+        )+        #expect(url == URL(string: "https://example.com/file.md?q=a%26b%20c")!)+    }++    @Test("Deep link keeps an encoded slash as one path segment (T-2140)")+    func deepLinkEncodedSlashStaysOneSegment() async {+        // GitLab identifies a project by its URL-encoded full path, so+        // `group%2Fproj` is a single segment. Guard: correct today, and a fix+        // that canonicalizes the path by decode-then-encode would break it.+        let source = "https://gitlab.com/api/v4/projects/group%2Fproj/repository/files/README.md/raw?ref=main"+        let url = await fetchedURL(+            forDeepLink: "prism://open?url=https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%2Fprojects%2Fgroup%252Fproj%2Frepository%2Ffiles%2FREADME.md%2Fraw%3Fref%3Dmain"+        )+        #expect(url == URL(string: source)!)+    }++    @Test("Deep link keeps an encoded slash alongside a raw space (T-2140)")+    func deepLinkEncodedSlashSurvivesRawSpace() async {+        // Both defects in one shape: the raw space forces Foundation's+        // wholesale re-encode (doubling `%2F` into `%252F`), while decoding+        // the path to avoid that would split `group%2Fproj` into two+        // segments. Only a selective encode gets both right.+        let url = await fetchedURL(+            forDeepLink: "prism://open?url=https%3A%2F%2Fgitlab.com%2Fapi%2Fv4%2Fprojects%2Fgroup%252Fproj%2Frepository%2Ffiles%2Fmy%20file.md%2Fraw%3Fref%3Dmain"+        )+        #expect(url == URL(+            string: "https://gitlab.com/api/v4/projects/group%2Fproj/repository/files/my%20file.md/raw?ref=main"+        )!)+    }++    @Test("Deep link with a mixed encoded/raw fragment does not double-escape (T-2140)")+    func deepLinkMixedFragmentDoesNotDoubleEscape() async {+        let url = await fetchedURL(+            forDeepLink: "prism://open?url=https%3A%2F%2Fexample.com%2Ffile.md%23a%2520b%20c"+        )+        #expect(url == URL(string: "https://example.com/file.md#a%20b%20c")!)+    }++    // MARK: - T-2140: prism://open?url= scheme gate++    /// Runs a deep link and reports whether it reached the loader, plus the+    /// error the user would see.+    private func outcome(forDeepLink deepLink: String) async -> (fetched: URL?, error: String?) {+        let capture = URLCapture()+        let remote = RemoteContentCoordinator(loader: { url in+            await capture.record(url)+            throw CancellationError()+        })+        let flow = DocumentFlowCoordinator()+        flow.setRemoteCoordinatorForTests(remote)++        flow.handleOpenURL(URL(string: deepLink)!)+        await remote.downloadTask?.value+        return (await capture.last, flow.loadError)+    }++    @Test(+        "A deep link naming a non-web scheme never reaches the loader (T-2140)",+        arguments: [+            "prism://open?url=file%3A%2F%2F%2Fetc%2Fpasswd",+            "prism://open?url=ftp%3A%2F%2Fexample.com%2Fpage.md",+            "prism://open?url=javascript%3Aalert(1)",+            // Scheme-less: PrismURL rejects it as not-absolute, where the old+            // `URL(string:)` produced a scheme-less URL and let it through.+            "prism://open?url=example.com%2Fpage.md"+        ]+    )+    func deepLinkRejectsNonWebScheme(deepLink: String) async {+        let result = await outcome(forDeepLink: deepLink)+        #expect(result.fetched == nil, "\(deepLink) must not reach the loader")+    }++    @Test("A deep link naming a web scheme still reaches the loader (T-2140)")+    func deepLinkAcceptsWebScheme() async {+        let result = await outcome(+            forDeepLink: "prism://open?url=https%3A%2F%2Fexample.com%2Fpage.md"+        )+        #expect(result.fetched == URL(string: "https://example.com/page.md")!)+    }++    @Test("A rejected deep link currently reports nothing to the user (T-2140)")+    func deepLinkRejectionIsSilent() async {+        // Documents a REGRESSION in feedback, not a desired behaviour. Before+        // T-2140 the URL reached URLDocumentLoader, which threw+        // `.unsupportedScheme` and set `loadError` to "Only http and https+        // URLs are supported."; the scheme gate in `handleOpenURL` now drops+        // out of the whole `else if` chain instead, so nothing is shown.+        // If this assertion starts failing because the rejection grew a+        // message, that is the fix — delete this test.+        let result = await outcome(forDeepLink: "prism://open?url=file%3A%2F%2F%2Fetc%2Fpasswd")+        #expect(result.error == nil)+    }+     @Test("openFile cancels an in-flight remote download")     func openFileCancelsRemoteDownload() async throws {         let remote = RemoteContentCoordinator(loader: Self.hangingLoader)
prismTests/PrismURLAbsoluteURLTests.swift Renamed +61 / -34
diff --git a/prismTests/PrismURLAbsoluteURLTests.swift b/prismTests/PrismURLAbsoluteURLTests.swiftnew file mode 100644index 0000000..3180a60--- /dev/null+++ b/prismTests/PrismURLAbsoluteURLTests.swift@@ -0,0 +1,188 @@+//+//  PrismURLAbsoluteURLTests.swift+//  prismTests+//+//  Created by Claude on 3/7/2026.+//++import Foundation+import Testing+@testable import prism++/// Tests for `PrismURL.absoluteURL(fromAmbiguousText:)` (T-1624, T-2140).+///+/// Foundation's URL parser re-encodes a path component wholesale when it+/// contains any raw disallowed character, doubling existing escapes+/// (`my%20file and more.md` → `my%2520file%20and%20more.md`). The chokepoint+/// selectively encodes each component — existing `%XX` triplets kept verbatim,+/// only genuinely-raw runs encoded — before Foundation parses the string.+/// (Until T-2140 the path instead took a decode-then-encode pair; that pair+/// was Defect B, and `encodedSlashInPathStaysEscaped` below pins its removal.)+@Suite("PrismURL absoluteURL(fromAmbiguousText:)")+struct PrismURLAbsoluteURLTests {++    @Test("Mixed encoded/raw path normalizes to single-encoded form (T-1624)")+    func mixedPathNormalizes() {+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "https://example.com/docs/my%20file and more.md"+        )+        #expect(url == URL(string: "https://example.com/docs/my%20file%20and%20more.md")!)+    }++    @Test("Fully encoded URL passes through unchanged (idempotence)")+    func fullyEncodedUnchanged() {+        let source = "https://example.com/docs/my%20file.md?token=a%20b#L10"+        let url = PrismURL.absoluteURL(fromAmbiguousText: source)+        #expect(url == URL(string: source)!)+    }++    @Test("Raw-only path is encoded")+    func rawOnlyPathEncoded() {+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "https://example.com/docs/my file.md"+        )+        #expect(url == URL(string: "https://example.com/docs/my%20file.md")!)+    }++    @Test("Query and fragment are preserved alongside a normalized path (T-1624)")+    func queryAndFragmentPreserved() {+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "https://example.com/docs/my%20file and more.md?token=a%20b#L10"+        )+        #expect(url == URL(string: "https://example.com/docs/my%20file%20and%20more.md?token=a%20b#L10")!)+    }++    @Test("Mixed encoded/raw query normalizes without doubling existing escapes (T-1624)")+    func mixedQueryNormalizes() {+        // Foundation's per-component fallback doubles the existing `%20`+        // (`?token=a%20b c` → `?token=a%2520b%20c`), the same defect as the+        // path. The selective encode keeps the triplet and encodes the space.+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "https://example.com/docs/file.md?token=a%20b c"+        )+        #expect(url == URL(string: "https://example.com/docs/file.md?token=a%20b%20c")!)+    }++    @Test("Mixed encoded/raw fragment normalizes without doubling existing escapes (T-1624)")+    func mixedFragmentNormalizes() {+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "https://example.com/docs/file.md#a%20b c"+        )+        #expect(url == URL(string: "https://example.com/docs/file.md#a%20b%20c")!)+    }++    @Test("Mixed path, query, and fragment all normalize together (T-1624)")+    func mixedPathQueryAndFragmentNormalize() {+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "https://example.com/docs/my%20file and.md?token=a%20b c#sec%20one two"+        )+        #expect(url == URL(+            string: "https://example.com/docs/my%20file%20and.md?token=a%20b%20c#sec%20one%20two"+        )!)+    }++    @Test("Query normalization preserves reserved-character semantics (T-1624)")+    func mixedQueryPreservesReservedSemantics() {+        // `%26` must stay an escaped literal ampersand and the raw `&` must+        // stay a parameter separator — the reason query components get a+        // selective encode rather than the path's decode-then-encode pair.+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "https://example.com/docs/file.md?a=%26&b=c d"+        )+        #expect(url == URL(string: "https://example.com/docs/file.md?a=%26&b=c%20d")!)+    }++    @Test("Escaped slash in a path stays escaped (T-2140)")+    func encodedSlashInPathStaysEscaped() {+        // The path-side twin of `mixedQueryPreservesReservedSemantics`. That+        // test records why a query gets a selective encode rather than the+        // decode-then-encode pair: `%26` decodes to `&`, turning an escaped+        // literal into a parameter separator. The identical argument applies+        // to the path — `.urlPathAllowed` contains `/`, so `%2F` decodes to a+        // live separator and one segment silently becomes two. `URL(string:)`+        // gets this shape right on its own; the helper must not make it worse.+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "https://example.com/a%2Fb.md"+        )+        #expect(url == URL(string: "https://example.com/a%2Fb.md")!)+    }++    @Test("GitLab encoded project path survives normalization as one segment (T-2140)")+    func gitLabEncodedProjectPathStaysOneSegment() {+        // GitLab's API identifies a project by its URL-encoded full path, so+        // `group%2Fproj` is a single path segment. Decoding it splits the+        // segment and the request 404s.+        let source = "https://gitlab.com/api/v4/projects/group%2Fproj/repository/files/README.md/raw?ref=main"+        let url = PrismURL.absoluteURL(fromAmbiguousText: source)+        #expect(url == URL(string: source)!)+    }++    @Test("Invalid escape in a query is encoded like Foundation's raw fallback")+    func invalidEscapeInQueryEncoded() {+        // `100%` is not a valid triplet; the lone `%` becomes `%25`, matching+        // what Foundation produces for a fully-raw query of this shape.+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "https://example.com/docs/file.md?p=100% done"+        )+        #expect(url == URL(string: "https://example.com/docs/file.md?p=100%25%20done")!)+    }++    @Test("Upper-case scheme still matches the hierarchical prefix (T-1624)")+    func upperCaseSchemeNormalizes() {+        // The scheme-prefix comparison is case-insensitive; Foundation+        // preserves the scheme's original case in the parsed URL.+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "HTTPS://example.com/docs/my%20file and more.md"+        )+        #expect(url == URL(string: "HTTPS://example.com/docs/my%20file%20and%20more.md")!)+    }++    @Test("String without a scheme returns nil")+    func noSchemeReturnsNil() {+        #expect(PrismURL.absoluteURL(fromAmbiguousText: "docs/my file.md") == nil)+        #expect(PrismURL.absoluteURL(fromAmbiguousText: "example.com/file.md") == nil)+    }++    @Test("Opaque (non-hierarchical) form parses as-is")+    func opaqueFormParsesAsIs() {+        let url = PrismURL.absoluteURL(fromAmbiguousText: "mailto:someone@example.com")+        #expect(url == URL(string: "mailto:someone@example.com")!)+    }++    @Test("Embedded credentials survive normalization for downstream rejection")+    func credentialsPreserved() {+        // The authority passes through verbatim so the credential rejection+        // in the resolvers and loader (T-1611/T-1599/T-1560) still fires.+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "https://user:pass@example.com/docs/my%20file and more.md"+        )+        let components = url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }+        #expect(components?.user == "user")+        #expect(components?.password == "pass")+    }++    @Test("Invalid escape sequence falls back to encoding the raw path")+    func invalidEscapeFallsBackToRaw() {+        // `100%` is not a valid escape; decoding fails, so the raw path is+        // encoded — matching Foundation's own fallback for this shape.+        let url = PrismURL.absoluteURL(+            fromAmbiguousText: "https://example.com/docs/100% real.md"+        )+        #expect(url == URL(string: "https://example.com/docs/100%25%20real.md")!)+    }++    // MARK: - User-Entered URL Chain (T-1624 ticket comment)++    @Test("User-entered GitHub blob URL normalizes and transforms to the correct raw URL (T-1624)")+    func userEnteredGitHubBlobURLChain() {+        // Repro from the ticket comment: URLInputSheet previously parsed the+        // raw text with `URL(string:)`, producing `.../my%2520file...`, and+        // GitHubURLTransformer preserved the wrong `%2520` in the fetch URL.+        let entered = "https://github.com/user/repo/blob/main/docs/my%20file and more.md?token=a%20b#L10"+        let url = PrismURL.absoluteURL(fromAmbiguousText: entered)+        #expect(url == URL(string: "https://github.com/user/repo/blob/main/docs/my%20file%20and%20more.md?token=a%20b#L10")!)++        let result = GitHubURLTransformer.transform(url!)+        #expect(result.fetchURL == URL(string: "https://raw.githubusercontent.com/user/repo/main/docs/my%20file%20and%20more.md?token=a%20b#L10")!)+    }+}
CHANGELOG.md Modified +3 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bc1d51f..38d7812 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -16,9 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CI now runs the test suite instead of only appearing to (T-1983). The per-locale sweep is the only job that can execute tests, and it reported success while executing none: its build failed for want of a signing certificate on the runner, that failure was swallowed, and nothing checked that any test had run. The sweep now signs ad-hoc so the build succeeds without a certificate, every test target hands its result bundle to the zero-test guard — per locale configuration, not once at the end — and any recipe whose failure must be believed carries `$(STRICT)`, because `.SHELLFLAGS` is silently ignored by the GNU Make 3.81 that macOS ships. `make verify-make-guards` asserts all of this on every push. - Copy notes is now the primary notes action (T-1577). On iPhone the document screen's toolbar shows Copy notes instead of Share with Notes, which moved into the notes pane alongside copy; on iPad and Mac the top toolbar shows copy leading the export button. Every copy button appears exactly when the copy output would contain at least one note under the current export settings, each action carries an accessibility label and help text, and an export blocked by the paywall from inside the pane now retries fully — including the author-name prompt and its confirmation toast — after a purchase completes. - The test suite covering notes-action placement was retargeted to the new contracts (T-1577): the T-138 share-button parity tests became the Share-with-Notes placement contract, and visibility/payload tests now assert the shared copy-availability predicate and the single export payload call site. Two device-only checks are recorded in the spec for manual verification.+- A `prism://open?url=…` link naming anything other than a web address is now rejected as soon as it arrives rather than a moment later during the download (T-2140). The address it names must start with `http` or `https`, matching what Prism already requires of an address you type in yourself. One consequence to be aware of: such a link is now ignored in silence, where before it produced the message "Only http and https URLs are supported."  ### Fixed +- A `prism://open?url=…` link now opens the address it names, even when that address mixes already-escaped and unescaped characters (T-2140). `…/my%20file and more.md` was fetched as `…/my%2520file%20and%20more.md` — a different resource, with no error shown — because the address had already been unescaped one layer by the time it was read, and was then escaped a second time in full. Investigating it surfaced a second fault of the same kind, live on every markdown link and image in every document: the escaping used for addresses turned an escaped `%2F` back into a real `/`, splitting one path segment into two. That silently broke any address that identifies something by an escaped path — a GitLab project URL, for instance, which 404s once `group%2Fproj` becomes `group/proj`. Escaped slashes and escaped ampersands now survive every route into the app: typed and pasted addresses, deep links, document links and images, `mailto:` links, and the GitHub blob-to-raw rewrite. One narrow side effect of reworking that rewrite: a GitHub address written with a doubled slash in it (`github.com//owner/repo/blob/…`) is no longer recognised as a file address, so it now reports an unsupported content type instead of opening.+ - In a document opened from a URL, an image or link whose query or anchor was already partly escaped no longer resolves to a corrupted address (T-1663). `/images/logo.png?token=a%20b c` was resolved as `?token=a%2520b%20c`, so the image failed to load and the link opened the wrong page: the already-escaped `%20` was escaped a second time because the whole query was treated as though none of it had been escaped yet. This affected both site-root addresses (starting with `/`) and the far more common document-relative form (`images/logo.png?token=a%20b c`); both are fixed. Query and anchor are now escaped the same way absolute URLs already were (T-1624) — an existing escape is left alone and only genuinely unescaped characters are encoded, so an escaped `%26` stays a literal character instead of decoding into a parameter separator and requesting a different resource. - On iPhone and iPad, Prism now follows the system's light/dark setting the moment it changes, instead of waiting for a restart (T-1698). With appearance set to Auto, switching Dark Mode on or off — by hand in Control Centre, or automatically at sunset — left the app rendering in the previous appearance for the rest of the session; only quitting and reopening it picked the change up, because reopening reads the system setting afresh. The app had been listening for the wrong system signal: one that reports a change to the screen's resolution, not to its appearance, and which therefore never arrived. It now watches the appearance itself. On iPad this also holds across multiple open document windows: the watch follows whichever window is in front, so closing the one it happened to be attached to no longer leaves the remaining windows stuck on the old appearance. Mac was never affected, and the forced Light and Dark modes are unchanged — a system appearance change still leaves them where you set them. 
docs/agent-notes/link-handling.md Modified +6 / -6
diff --git a/docs/agent-notes/link-handling.md b/docs/agent-notes/link-handling.mdindex e7fbcbd..d0bee77 100644--- a/docs/agent-notes/link-handling.md+++ b/docs/agent-notes/link-handling.md@@ -45,13 +45,13 @@ Duplicate headings are disambiguated with GitHub-style suffixes (T-666): the met  - **Absolute path resolution for remote URLs**: When building a URL from `URLComponents` initialized from a base URL, all of `path`, `query`, and `fragment` must be explicitly set from the destination. Otherwise base URL query/fragment leak through. Fixed in T-542 — use `URLComponents(string: source)` to parse the destination, then set all three properties. - **String splitting for URL components is fragile**: `source.components(separatedBy: "#").first` is never nil for a non-empty array, so `??` fallbacks after `.first` are dead code. Always use `URLComponents` for URL parsing.-- **Fragment must be excluded from reassembled URL before resolution**: In `resolveRelativePath`, `URLComponentHelpers.reassembleURL()` must NOT include the fragment. If included, `URL(string:relativeTo:)` embeds the fragment in the resolved URL, causing duplication with the separately-carried `fragment` parameter. The `resolveAbsoluteURL` path strips fragments via `removingFragment(from:)` — relative paths must follow the same pattern by passing `fragment: nil` to `reassembleURL`.+- **Fragment must be excluded from reassembled URL before resolution**: In `resolveRelativePath`, `PrismURL.joined(path:query:fragment:)` must NOT include the fragment. If included, `URL(string:relativeTo:)` embeds the fragment in the resolved URL, causing duplication with the separately-carried `fragment` parameter. The `resolveAbsoluteURL` path strips fragments via `removingFragment(from:)` — relative paths must follow the same pattern by passing `fragment: nil` to `joined`. - **Percent-encoded anchor fragments**: `URL.fragment` does NOT decode percent-encoding. Both `LinkPathResolver` (for `#fragment` links) and `DocumentSession.scrollToAnchor` (as a safety net) call `removingPercentEncoding` to handle encoded non-ASCII characters like `%C3%A9` → `é`. Fixed in T-672.-- **Mixed encoded/raw paths must be normalized**: `addingPercentEncoding` alone doubles existing escapes (`%20` → `%2520`); `pathPart.contains("%")` as a "skip encoding" heuristic was wrong for mixed inputs (T-875). Decode-then-encode the path (`removingPercentEncoding ?? pathPart` then `.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)`) — this pair is idempotent for raw, encoded, and mixed shapes. The same applies to root-relative remote paths: feed the decoded path to `URLComponents.path` so the setter does the encoding (don't pass already-encoded text to `.path`, it doubles).-- **Absolute URLs need the same normalization, before `URL(string:)`** (T-1624): Foundation's parser handles invalid input *per component* — a component containing any raw disallowed character is treated as wholly unencoded and fully re-encoded (`%` → `%25`), while fully-valid components pass through untouched. So `URL(string: "https://example.com/my%20file and more.md")` silently yields `.../my%2520file%20and%20more.md`, and the same doubling hits mixed query/fragment components (`?token=a%20b c` → `?token=a%2520b%20c`). Route markdown/user-controlled absolute URL strings through `URLComponentHelpers.normalizedAbsoluteURL(from:)`, which canonicalizes the raw path (decode-then-encode), selectively encodes query and fragment via `encodingRawCharacters(in:allowedCharacters:)` (existing `%XX` triplets kept verbatim; decode-then-encode on a query would corrupt `%26` vs `&`), and passes the authority through verbatim. Also note `URLComponents.query`'s setter doubles existing escapes (`token=a%20b` → `token=a%2520b`), so never feed an already-encoded query to it. The `fragment` setter has the same wholesale re-encode behaviour; `LinkPathResolver.embeddingFragment(_:into:)` re-embeds fragments via `percentEncodedFragment` + the selective encoder instead.-- **The root-relative AND plain-relative branches were the second and third instances of that same bug** (T-1663). Root-relative: the `.url`-source branch for a leading-`/` source in both resolvers assigned `components.query`/`.fragment` from raw strings and doubled their escapes; both now go through `encodingRawCharacters` + the `percentEncoded*` setters. Plain-relative: `resolveRelativePath`'s non-root-relative tail handed the RAW `queryPart` (and, in `ImagePathResolver`, the raw `fragmentPart`) to `URLComponentHelpers.reassembleURL` and then to `URL(string:relativeTo:)`, which applied the same per-component wholesale re-encode (`images/logo.png?token=a%20b c` against `https://example.com/docs/page.md` → `?token=a%2520b%20c`). Both resolvers now selectively encode once, next to where `encodedPath` is computed, so all four branches of the switch below get the already-valid component. **All three shapes — absolute, root-relative, plain-relative — now agree.**-- **Why the query/fragment fix is a selective encode and never the path's decode-then-encode.** The path uses `removingPercentEncoding` then `addingPercentEncoding` because that pair is idempotent and the path has no reserved delimiters left to protect. A query does: decoding an escaped `%26` yields a live `&`, which splits one parameter into two and requests a *different resource*. `encodingRawCharacters` keeps existing `%XX` triplets verbatim and encodes only genuinely-raw characters, which is byte-identical to Foundation for fully-raw and fully-encoded inputs and correct for mixed ones. `escapedAmpersandInRelativeQueryStaysEscaped` in both `*RelativeQueryFragmentTests` pins this — do not "simplify" those call sites into the path's pair.-- **Why this bug reached its third instance.** T-1624 fixed absolute URLs and recorded the root-relative residual only as a note; T-1663 first fixed only root-relative and recorded the plain-relative residual the same way. The relative form is the *most* common shape in markdown, and it was the last one fixed. When a normalization defect is found in one branch of a resolver, check every branch of that resolver in the same change. The old coverage missed all of this because the T-875 relative tests use a query with no pre-existing escape (`?ref=main`) — a regression test for this class must use a *mixed* encoded/raw component.+- **Mixed encoded/raw components must be selectively encoded** (T-875, corrected by T-2140): `addingPercentEncoding` alone doubles existing escapes (`%20` → `%2520`), and `pathPart.contains("%")` as a "skip encoding" heuristic was wrong for mixed inputs. The remedy is `PrismURL`'s selective encoder, which keeps existing `%XX` triplets verbatim and encodes only genuinely-raw runs. **This note previously prescribed decode-then-encode for the path; that was wrong and was itself a live defect** — see the bullet below.+- **Absolute URLs need the same normalization, before `URL(string:)`** (T-1624): Foundation's parser handles invalid input *per component* — a component containing any raw disallowed character is treated as wholly unencoded and fully re-encoded (`%` → `%25`), while fully-valid components pass through untouched. So `URL(string: "https://example.com/my%20file and more.md")` silently yields `.../my%2520file%20and%20more.md`. Route any string of unknown encoding state through `PrismURL.absoluteURL(fromAmbiguousText:)`. **Do not hand-roll the conversion** — see the mechanism bullet below.+- **The root-relative AND plain-relative branches were the second and third instances of that same bug** (T-1663). Root-relative: the `.url`-source branch for a leading-`/` source in both resolvers assigned `components.query`/`.fragment` from raw strings and doubled their escapes; both now go through `PrismURL.percentEncoded(_:as:)` + the `percentEncoded*` setters. Plain-relative: `resolveRelativePath`'s non-root-relative tail handed the RAW `queryPart` (and, in `ImagePathResolver`, the raw `fragmentPart`) to the reassembly helper (now `PrismURL.joined`) and then to `URL(string:relativeTo:)`, which applied the same per-component wholesale re-encode (`images/logo.png?token=a%20b c` against `https://example.com/docs/page.md` → `?token=a%2520b%20c`). Both resolvers now selectively encode once, next to where `encodedPath` is computed, so all four branches of the switch below get the already-valid component. **All three shapes — absolute, root-relative, plain-relative — now agree.**+- **One selective encoder, all three components** (T-2140). Decoding an escaped `%26` in a query yields a live `&`, splitting one parameter into two and requesting a *different resource*. The same argument applies to the **path**, and for four rounds it was never made there: `.urlPathAllowed` contains `/`, so decode-then-encode turned an escaped `%2F` into a live separator and split one path segment into two — breaking, for instance, any GitLab URL identifying a project as `group%2Fproj`. Path, query and fragment now share the one selective encoder. `escapedAmpersandInRelativeQueryStaysEscaped` and `encodedSlashInPathStaysEscaped` pin the two halves; do not "simplify" either into a decode-then-encode pair.+- **Why this bug reached its eighth instance, and what replaced this note.** T-1624 fixed absolute URLs and recorded the root-relative residual as a note; T-1663 fixed root-relative and recorded the plain-relative residual the same way; T-2140 found four more (the deep-link entry point, the path encoder itself, `mailto:`, the GitHub blob rewrite) plus a fragment instance during implementation. A note is read only by someone who already suspects there is something to read, and this one is now 0-for-3 at preventing recurrence. **The rule no longer lives here.** It lives in `PrismURL` (four entry points named for the encoding-state claim the caller makes, with no `CharacterSet` parameter to get wrong), in `prismTests/URLEncodingCorpus.swift` (one table of hostile shapes driven through every conversion path, so fixing one branch and leaving its siblings is mechanically impossible), and in `prismTests/URLChokepointAdoptionTests.swift` (fails on a new hand-rolled conversion outside `PrismURL` unless the file carries a documented exemption). If you are adding a conversion, add a corpus path — do not add a bullet here. - **Local absolute paths still need component splitting**: The `.file`/`.bundled` branch for destinations starting with `/` currently constructs `URL(fileURLWithPath: source)` directly. That treats `/Users/a/doc.md#heading` as a literal filename (`doc.md%23heading`) instead of preserving `heading` as a fragment. Tracked as T-861.  ## Future work
specs/bugfixes/url-encoding-chokepoint/report.md Added +317 / -0
diff --git a/specs/bugfixes/url-encoding-chokepoint/report.md b/specs/bugfixes/url-encoding-chokepoint/report.mdnew file mode 100644index 0000000..251ccf4--- /dev/null+++ b/specs/bugfixes/url-encoding-chokepoint/report.md@@ -0,0 +1,317 @@+# Bugfix: URL encoding chokepoint (T-2140)++## Summary++`prism://open?url=…` corrupts any target URL whose path, query, or fragment mixes+existing percent-escapes with raw characters. Prism then fetches a different+resource than the sender named, silently — no error, no log.++Investigating it surfaced a second, previously undetected defect **inside the+helper that exists to prevent this class**, live today on every markdown link and+image. The two must be fixed in a specific order; fixing only the reported one+introduces a regression.++## Status++Investigation complete. Failing regression tests committed (`ffc8306`).+Resolution section filled in after implementation.++## Defect A — the reported bug++`DocumentFlowCoordinator.swift:158` passes the `url` query-item value straight to+`URL(string:)`. `URLComponents.queryItems` has already decoded one layer, so the+string arrives *partially* encoded. Foundation, on finding a raw disallowed+character in a component, re-encodes that whole component — doubling every escape+that was already there.++```+link    : prism://open?url=https%3A%2F%2Fexample.com%2Fmy%2520file%20and%20more.md+decoded : https://example.com/my%20file and more.md+actual  : https://example.com/my%2520file%20and%20more.md   ← wrong resource+expected: https://example.com/my%20file%20and%20more.md+```++## Defect B — inside the fix helper++`URLComponentHelpers.swift:107-108` canonicalises the path with+`removingPercentEncoding` → `addingPercentEncoding(.urlPathAllowed)`.++`.urlPathAllowed` contains `/`, so an encoded `%2F` decodes to a live separator+and **one path segment becomes two**:++```+in     : https://example.com/a%2Fb.md+helper : https://example.com/a/b.md      ← wrong; pathComponents ["/", "a", "b.md"]+URL(string:) actually gets this RIGHT+```++Every member of `.urlPathAllowed` is affected (`%2F %3A %40 %21 %24 %26 %27 %28+%29 %2A %2B %2C %3B %3D`); `%2F` is the one that changes resource identity.++This is the *same reasoning error* the file's own doc comment (lines 70-76) warns+about for `%26` in a query — the argument was made for query and fragment and not+for the path. Real-world reachability is concrete, since GitLab encodes project+paths:++```+https://gitlab.com/api/v4/projects/group%2Fproj/repository/files/README.md/raw?ref=main+→ .../projects/group/proj/...   → 404+```++**Ordering constraint:** because `URL(string:)` handles a fully-encoded path+correctly and the helper does not, routing Defect A's call site through today's+helper trades one bug for another. `deepLinkEncodedSlashStaysOneSegment` is green+today and turns red under that naive fix. Defect B must be fixed first or+together.++## Root cause (Five Whys)++1. The deep link fetched the wrong resource → `URL(string:)` on a partially+   encoded string, and Foundation re-encodes wholesale.+2. Why did this site not use the helper? T-1624 was scoped as "the URL the user+   typed and the links in the document". A query-item value in a coordinator+   matched neither pattern. Nobody enumerated the producers.+3. Why no enumeration? Each round fixed the shape in front of it and wrote the+   residual into `docs/agent-notes/link-handling.md` — a passive record read only+   by someone who already suspects there is something to read. It has failed to+   prevent recurrence three consecutive times.+4. Why can a call site be wrong unnoticed? The unsafe API (`URL(string:)`,+   `.query =`, `.path =`, `appendingPathComponent`) is the ergonomic default, the+   safe one is a static helper you must know exists, and **the two are+   type-identical** — both `String` → `URL`. Nothing in the type system, the+   linter, or CI distinguishes them, and broken and correct code look equally+   reasonable at review.+5. Why did tests miss it? The class only differs on *mixed* input. Fully-raw and+   fully-encoded inputs are byte-identical between broken and correct code. The+   pre-T-1663 corpus used `?ref=main` — a query with no pre-existing escape. No+   test anywhere contained a `%2F`, which is why Defect B shipped inside the fix+   and survived T-1624, T-1663, and a pre-push review.++**Root cause.** No single owner of the rule *"a percent-encoding-ambiguous string+becomes a URL exactly one way."* The knowledge lives in prose and an opt-in+helper, so correctness is re-derived per site. Worse, the rule itself was+incomplete — selective encoding for two components, destructive+decode-then-encode for the third — so even perfect adoption still corrupts `%2F`.+**The class recurs because the invariant is a convention rather than a mechanism,+and the convention encoded an incomplete rule.**++## Instances of this class++| # | Ticket | Shape | Where |+|---|--------|-------|-------|+| 1 | T-1624 | absolute string → `URL(string:)` | user-entered + document links |+| 2 | T-1663 | root-relative → `.query`/`.fragment` setters | both resolvers |+| 3 | T-1663 pre-push | plain-relative → `URL(string:relativeTo:)` | both resolvers |+| 4 | **T-2140** | deep-link `?url=` → `URL(string:)` | `DocumentFlowCoordinator:158` |+| 5 | **this** | `%2F` destroyed by the path decode-then-encode | `URLComponentHelpers:107` |+| 6 | **this** | `mailto:` opaque re-encode | `LinkPathResolver:71` |+| 7 | **this** | `pathComponents` → `.path` rebuild | `GitHubURLTransformer:56-66` |++## Sweep++Two exhaustive sweeps over production source (129 hits across `URL(string:`,+`URL(fileURLWithPath:`, `URLComponents(`, the component setters,+`appendingPathComponent`, `addingPercentEncoding`, `removingPercentEncoding`,+`URLQueryItem`, `queryItems`, `absoluteString`), plus a full entry-point map.++**The ingress surface is narrow.** Three targets, no share extension, no+`NSUserActivity`/Handoff/Spotlight/App Intents, no `AppDelegate`. Every system URL+delivery funnels through one `.onOpenURL` at `prismApp.swift:406` →+`handleOpenURL`. macOS drag-and-drop calls the same handler with a `Transferable`+-parsed `URL` (no string conversion). Clipboard content is markdown text only.++**Vulnerable:** `DocumentFlowCoordinator:158`; `URLComponentHelpers:107-108`;+`LinkPathResolver:157, 187-188, 209`; `ImagePathResolver:288, 318-319, 344`;+`LinkPathResolver:71` (`mailto:`); `GitHubURLTransformer:56-66`.++**Fixed:** `LinkPathResolver:80`, `ImagePathResolver:160`, `URLInputSheet:98`+(via `normalizedAbsoluteURL`); the `encodingRawCharacters` sites added by+T-1663 — all of which nonetheless inherit Defect B through the path branch.++**Safe, and worth recording so they are not re-audited:** the `prism-doc://img/?src=`+round trip is lossless (verified byte-exact over 13 hostile shapes — this design+is the template); `WebDocumentMessageRouter:376-379` and `DocumentReaderView:395`+forward the raw href deliberately (T-1590); `DocumentReaderView:385` builds a URL+only for scheme/host matching and discards it; UUID/Int-only components in the+web-rendering factories; compile-time literals in settings and previews;+read-only `URLComponents` for credential/scheme rejection.++**Do not touch:** `NoteModels.urlSafeEncoded` encodes `/` → `%2F` as a+*filesystem traversal guard* for a filename, not URL encoding. It must not be+pulled into any URL chokepoint.++## Regression tests (committed, red)++`total=31 passed=25 failed=6`. Six new tests fail; no pre-existing test regressed.++- `URLComponentHelpersNormalizedAbsoluteURLTests.encodedSlashInPathStaysEscaped` — Defect B+- `…gitLabEncodedProjectPathStaysOneSegment` — Defect B, real-world shape+- `DocumentFlowCoordinatorURLTests.deepLinkMixedPathDoesNotDoubleEscape` — Defect A+- `…deepLinkEscapedAmpersandInQueryStaysEscaped` — the `%26` constraint+- `…deepLinkEncodedSlashSurvivesRawSpace` — **intersection of both defects**; only a+  selective encode satisfies it (wholesale re-encode doubles the `%2F`,+  decode-then-encode splits the segment)+- `…deepLinkMixedFragmentDoesNotDoubleEscape`++Green guards that must stay green: `deepLinkFullyRawPathEncodedOnce`,+`deepLinkFullyEncodedPathIsIdempotent`, `deepLinkEncodedSlashStaysOneSegment`+(the last is the one a naive point fix breaks).++Harness note: the suite *type* is `URLComponentHelpersNormalizedAbsoluteURLTests`,+not the filename — `-only-testing:prismTests/URLComponentHelpersTests` silently+selects zero tests.++## Resolution++The root cause named above is that the invariant was a *convention* policed by+an opt-in helper. The fix attacks that directly rather than fixing the two+defects in place: make the correct conversion the only one a call site can+plausibly reach, and prove it with a corpus shared by every path.++### 1. One encoder, three roles (`prism/Services/PrismURL.swift`)++`PrismURL` replaces `URLComponentHelpers` as the chokepoint. Four entry points,+each named for the **claim the caller makes about its input**:++| Entry point | Claim |+|---|---|+| `absoluteURL(fromAmbiguousText:)` | a complete absolute URL, encoding state unknown |+| `absoluteURL(fromEncodedText:)` | a complete absolute URL, already encoded by construction |+| `url(fromEncodedReference:relativeTo:)` | the relative twin of the above |+| `percentEncoded(_:as:)` | one component, encoding state unknown |++The two whole-URL entries share the `String -> URL?` shape that Why-4 identified+as the problem, and are told apart only by the argument label. That does not make+the wrong choice impossible; it makes it *stated*. A reviewer is now forced to+answer "which encoding state is this string in?" rather than never being asked.++The `CharacterSet` parameter is gone. `Component` binds each role to its allowed+set inside the file, so there is exactly one algorithm — selective encode,+existing `%XX` preserved verbatim — and no way to pair a component with a+mismatched set. **That is Defect B's structural fix**: the path could only take a+different algorithm from query and fragment because the API let it.++`URLComponentHelpers` survives as a nine-line forwarding façade with no+production callers, purely because the committed regression suite names it and+those tests are the acceptance criteria.++### 2. The shared corpus (`prismTests/URLEncodingCorpus.swift`)++One table of nine rows — raw, fully-encoded, mixed, `%26`, `%2F`, lone `%`,+invalid escape, non-ASCII, uppercase hex — each carrying a path segment, a query+and a fragment *simultaneously*, because the failure mode has always been+asymmetry between components. `URLConversionPath` enumerates all nine production+conversion paths and the driver switches exhaustively over it, so the suite runs+the full 9 × 9 cartesian product: 81 assertions per run, and a new row is+automatically applied to every path the moment it is added.++Every path reports its result through one `URLConversionOutcome`, read back with+`percentEncodedPath`/`Query`/`Fragment` — never the decoding accessors, which+would hide the very double-escaping under test.++Verified by reverting each fix in turn:++- Reinstating the path's decode-then-encode pair fails **48 of 81** rows plus the+  idempotence and markdown-routing tests.+- Reinstating `GitHubURLTransformer`'s `pathComponents` rebuild fails **6 rows**+  while the entire pre-existing `GitHubURLTransformerTests` suite stays green —+  the clearest demonstration that per-path suites were never going to catch this.++### 3. The guard against a *new* path (`prismTests/URLChokepointAdoptionTests.swift`)++The corpus covers known paths; it cannot see one nobody declared, which is+exactly how Defect A arrived. A source scan fails on any production+`URL(string:)` over a non-literal, or any hand-rolled percent coding, outside+`PrismURL` — unless the file carries a documented exemption. The nine exemptions+encode this report's sweep so it does not have to be re-derived, and a companion+test fails if an exemption names a file that no longer exists.++### Instance 8 — claimed, but NOT a live defect (corrected at pre-push review)++`LinkPathResolver.resolveAbsoluteURL` read `url.fragment` and+`embeddingFragment` re-encoded it. This was written up as a decode-then-encode+pair — the same defect as instance 5 in a third component, with `#a%2Fb`+reaching the browser as `#a/b`.++**That premise is false on the platforms Prism supports.** `URL.fragment`+returns the *percent-encoded* fragment; only `fragment(percentEncoded: false)`+and `URLComponents.fragment` decode. `docs/agent-notes/link-handling.md` has+said so since T-672 ("`URL.fragment` does NOT decode percent-encoding") and the+claim above contradicted it. Measured two ways at review: a standalone probe+(`#a%2Fb` → `a%2Fb`, `#caf%C3%A9` → `caf%C3%A9`), and a mutation run — reverting+line 108 to `url.fragment` and re-running the corpus, `LinkPathResolverTests`+and the acceptance suite gives **96/96 passing**. No test fails, because there+is no behaviour to fail.++The change is kept, as clarity rather than a fix: naming `percentEncodedFragment`+states the requirement instead of relying on an accessor whose contract is+easy to misread, and makes the absolute branch *read* like the relative+branches. Note the branches still do not genuinely agree — the relative+branches carry the author's raw text, of unknown encoding state, so every+consumer of `ResolvedLink`'s fragment must remain state-tolerant.+`DocumentSession.scrollToAnchor` decodes before slug matching (verified), so+in-app anchors are unaffected either way.++**Consequence for the selection below:** instance 8 was the decisive criterion+for choosing Agent 2 over Agents 1 and 3. It should not have been — the other+two candidates left nothing live here. The selection still stands on the+corpus and the adoption test, which are the parts that address the root cause;+it just does not stand on this.++### Verification++- `URLComponentHelpersNormalizedAbsoluteURLTests` + `DocumentFlowCoordinatorURLTests`:+  `total=31 passed=31 failed=0` (was 25/6). All three green guards still green.+- Corpus + adoption suites: 81 + 9 + 9 + 5 + 2 executions, all green.+- Wider suites (`LinkPathResolverTests`, `ImagePathResolverTests`, the three+  query/fragment suites, `GitHubURLTransformerTests`, `DroppedURLRouterTests`,+  corpus, adoption): `total=190 passed=190 failed=0`.+- Full macOS unit run: `total=4416 passed=4373 failed=4 skipped=37`. The four are+  WebKit/timing suites unrelated to URLs (`RawSourceViewModelTests`,+  `WebScrollabilityReportingTests`, `WebScrollNavigationTests`,+  `WebStateSynchronizerAssemblyTests`); all 71 of their tests pass when re-run in+  isolation.+- `make lint`: 0 violations. `make build-macos` and `make build-ios`: succeed.++## Integration record++Three candidates were implemented independently from the red-test baseline and+compared in `solution-comparison.md`. All three reached the same core fix and all+three corrected one item in the sweep above: `LinkPathResolver:209` /+`ImagePathResolver:344` are URL→**filesystem** conversions and must keep+decoding, since a filesystem path is not percent-encoded. Those two are+documented in place as deliberately different rather than migrated.++The narrowed-API + corpus candidate was selected on correctness — it was the only+one to find instance 8 (above), which the other two leave live.++**Grafted from the layered candidate:** the http/https check lifted into+`handleOpenURL`, so `prism://open?url=file:///…` is rejected at the entry point+rather than a frame later in the loader, matching what `URLInputSheet` already+does for a typed URL.++**Fixed during integration:** the winning candidate had left a+`URLComponentHelpers` forwarding façade in place, purely because its brief forbade+editing the committed tests — leaving two names for one concept. The constraint+was mine and renaming a symbol is not weakening an assertion, so the façade is+deleted and the test's call sites now name `PrismURL` directly.++**Deliberately not shipped:** SwiftLint `custom_rules` (implemented and verified+false-positive-free by the layered candidate). Kiro's objection is sound — a+regex rule cannot distinguish a scheme-sniff from a fetch-URL build, so it+polices ergonomics rather than encoding the invariant. `URLChokepointAdoptionTests`+covers the same ground semantically, with documented exemptions that fail if they+name a deleted file. Running both would double maintenance for marginal gain.++**Post-integration verification:** acceptance + corpus + adoption + the seven+resolver suites, run together: **`total=221 passed=221 failed=0`**, read from the+result bundle. `make lint` clean across 541 files. `make build-macos` and+`make build-ios` both succeed — checked because the API rename touches call sites+app-wide.++## Follow-up++`docs/agent-notes/link-handling.md` should point at `PrismURL`, the corpus and the+adoption test rather than carrying the rule itself. The note has recorded this+residual three times and prevented nothing; the knowledge now lives in the+mechanism.
specs/bugfixes/url-encoding-chokepoint/solution-comparison.md Added +78 / -0
diff --git a/specs/bugfixes/url-encoding-chokepoint/solution-comparison.md b/specs/bugfixes/url-encoding-chokepoint/solution-comparison.mdnew file mode 100644index 0000000..1691da3--- /dev/null+++ b/specs/bugfixes/url-encoding-chokepoint/solution-comparison.md@@ -0,0 +1,78 @@+# Solution Comparison: url-encoding-chokepoint (T-2140)++All three candidates branched from `2803d70` (investigation report + six failing+regression tests). All three passed the acceptance criteria — `total=31 passed=31`+on the committed suites, with the three green guards still green — so the+selection turned on **how much of the class each one actually closed**, not on+whether the ticket's symptom went away.++All three independently reached the same core conclusion and the same core fix:+the path must use the selective, non-decoding encoder already trusted for query+and fragment. All three also correctly identified that+`LinkPathResolver:209` / `ImagePathResolver:344` are URL→**filesystem**+conversions and must keep decoding — the investigation had wrongly listed them+among the sites to migrate.++## Candidates++### Agent 1 — layered chokepoint (`88d23c6`)+- **Files changed:** 16 (`URLComponentHelpers`, both resolvers, `DocumentFlowCoordinator`, `GitHubURLTransformer`, `.swiftlint.yml`, agent note, 3 test files)+- **Lines changed:** +304 / −90+- **Tests:** 31/31 acceptance; 180/180 named suites; 297/297 21-suite blast radius; lint clean+- **Approach:** unify the encoder, migrate call sites, then police the rule with two SwiftLint `custom_rules` plus 15 justified inline exemptions.+- **Unique strengths:** added an **opaque branch** to the helper, which is what makes `mailto:` fixable at all (Kiro correctly observed the old helper returned opaque forms verbatim, but treated that as a limit rather than something to change). Explicit empty-segment guard in `GitHubURLTransformer`, closing the trailing-slash edge Kiro flagged as unresolved. Lifted the http/https check to `handleOpenURL`. Documented a genuine regex trap in the lint config (written naively, the rule matches everything because `\s*` backtracks to zero and the lookahead trivially passes).+- **Weakness:** leaves instance 8 live. Did not complete a full suite run (abandoned at 55 minutes).++### Agent 2 — narrowed API + shared corpus (`7fed38e`) — **SELECTED**+- **Files changed:** 10 production + test (`PrismURL` replacing `URLComponentHelpers`, both resolvers, `DocumentFlowCoordinator`, `GitHubURLTransformer`, corpus + driver + adoption tests)+- **Lines changed:** +1183 / −264 overall; **production is net −14** (+251/−265), because three copies of the same decode-and-encode block collapsed into one call each+- **Tests:** 31/31 acceptance; 190/190 wider suites + corpus + adoption; full macOS run 4416 with 4 failures, all WebKit/timing suites unrelated to URLs and green on isolated re-run; lint clean; both platform builds succeed+- **Approach:** make the correct conversion the only reachable one — four entry points named for *the claim the caller makes about its input*, with the `CharacterSet` parameter removed so a component can no longer be paired with a mismatched allowed-set. Then prove it with a 9×9 corpus driven through every conversion path, plus an adoption test that scans production source.+- **Unique strengths:** **the only candidate that found and fixed instance 8** — `resolveAbsoluteURL` read the decoding `url.fragment` and `embeddingFragment` re-encoded it, so `#a%2Fb` reached the browser as `#a/b`. Corpus is mutation-proven: reverting the path fix fails 48 of 81 rows; reverting the `GitHubURLTransformer` fix fails 6 rows **while the entire pre-existing `GitHubURLTransformerTests` suite stays green** — that one measurement is the whole argument for a shared corpus. The adoption test covers the case the corpus structurally cannot: a *new* conversion path, which is exactly how Defect A arrived.+- **Weaknesses:** renames `URLComponentHelpers` → `PrismURL` and leaves a forwarding façade, so two names briefly denote one concept (self-flagged; an artefact of the "don't edit committed tests" constraint). Largest raw diff. Missing Agent 1's http/https lift.++### Agent 3 — Kiro, independent perspective (`9f3505f`)+- **Files changed:** 6+- **Lines changed:** +195 / −46+- **Tests:** 31/31 acceptance; 186/186 including added `%2F` coverage; lint clean+- **Approach:** unify the encoder, migrate the producers, and stop — explicitly rejecting both lint rules and a wrapper type.+- **Unique strengths:** the smallest change that fixes the reported defect plus Defect B, and the sharpest critique of the alternatives. Its objection to regex lint is correct and was decisive in trimming the winner: a rule banning `URL(string:)` **cannot distinguish a scheme-sniff from a fetch-URL build**, so it polices ergonomics with false positives rather than encoding the invariant.+- **Weaknesses:** leaves instance 8 and `mailto:` unfixed, and flags the `GitHubURLTransformer` trailing-slash difference as an unresolved behavioural change rather than handling it. Its stated reason for skipping `mailto:` — that routing through the helper "changes nothing" — was true of the helper *as it stood*, and Agent 1 disproved it by adding the opaque branch.++## Selected: Agent 2++**Reason.** *(Amended at pre-push review: the instance-8 argument below does not+hold. `URL.fragment` does not decode on the supported platforms, so nothing was+live and reverting that line fails no test — see the corrected section in+`report.md`. The selection stands on the corpus and the adoption test, which are+the parts that attack the root cause, not on instance 8.)*++Correctness is the first criterion and Agent 2 closes strictly more of+the class: it is the only candidate to find and fix instance 8, which the other+two leave live in production on every markdown link with an encoded fragment. Its+mechanism also addresses the actual root cause rather than the symptom — the+corpus makes "fix one branch, leave its siblings" mechanically impossible (proven+by the `GitHubURLTransformer` mutation, which the existing suite could not catch),+and the adoption test covers new conversion paths, which is precisely how this+defect arrived. Its raw diff is the largest, but its *production* diff is net+negative, so the size is test infrastructure — the thing this class has been+missing for four rounds.++Kiro's objection to regex lint is sound, so **Agent 1's SwiftLint rules were+deliberately not grafted**: Agent 2's adoption test covers the same ground+semantically, with documented exemptions instead of false positives, and running+both would double the maintenance for marginal gain.++### Grafted from Agent 1++- The http/https scheme check lifted to `handleOpenURL`, so+  `prism://open?url=file:///…` is rejected at the entry point rather than one+  frame later in the loader — matching what `URLInputSheet` already does.++### Fixed during integration++- The `URLComponentHelpers` → `PrismURL` façade was removed and the two+  references in the committed test file renamed. The façade existed only because+  the implementation brief forbade editing committed tests; renaming a symbol is+  not weakening an assertion, so the constraint was lifted at integration rather+  than shipping two names for one concept.

Things to double-check

Should the deep-link rejection show a message again?

This is the one open decision. Three options: (a) set loadError = URLDocumentLoader.LoadError.unsupportedScheme.errorDescription on the rejection path, which restores the exact previous message (that error is thrown for scheme-less input too, so the wording still fits) and reuses the channel RemoteContentCoordinator already uses; (b) drop the graft entirely and let the loader reject as before, since it already did so with feedback; (c) accept the silence as the price of rejecting at the boundary.

I would take (a). Note I attempted it and the permission classifier blocked the edit to handleOpenURL, so the branch currently ships (c) with a test pinning it and a CHANGELOG line naming it. deepLinkRejectionIsSilent is written to be deleted when the message comes back.

Is absoluteURL(fromEncodedText:) worth keeping?

You flagged this yourself and I think the answer is no, as public API. It has no production caller, and it is the one hole in the mechanism the adoption scanner cannot see: a call to it passes the guard while being byte-for-byte as unsafe as the URL(string:) the guard bans. Making it private keeps the internal use at :159, closes the bypass, and lets you delete roughly 15 lines of doc comment justifying a risk that then no longer exists. The single test caller builds an already-encoded string and can use fromAmbiguousText, which is idempotent on it.

Exemption granularity in the adoption guard

The guard works — I proved it fires by planting a violation. But whole-file exemptions for the two resolvers mean it is blind exactly where this class recurred. A line-anchored marker (// prism-url-exempt: <reason>, checked on the same line) keeps the documented-reason property that makes this better than a lint rule, without exempting 200 lines to permit two. Cheap to change now; awkward once the list grows.

Four hand-rolled copies of the same assembly sequence

split → percentEncoded ×3 → joined → resolve appears at LinkPathResolver.swift:189-231, :160-176, ImagePathResolver.swift:309-350 and :280-296. The chokepoint sits one level below where callers actually operate. A url(fromAmbiguousReference:relativeTo:) would remove ~40 duplicated lines and make “forgot to encode the fragment” unrepresentable — the same guarantee Component gives for the character set. Worth a follow-up ticket rather than this PR.

No CI has run on this branch

GitHub Actions is billing-blocked at the account level, so nothing has been validated remotely. Local results, all on this machine and all read from the result bundle via Tools/check-test-results.sh with the count checked non-zero:

  • 224/224 on the targeted set — corpus, adoption, deep link, acceptance, both resolvers, the three query/fragment suites, GitHub transformer, dropped-URL router.
  • 4378/4419 on the full prismTests target (37 skipped). Two failures, both WebKit/timing: WebScrollNavigationTests and WebScrollabilityReportingTests. Re-run in isolation they are 25/25 green — pre-existing flakes of the family the bugfix report already names, with no relationship to URL handling.
  • make lint: 0 violations across 541 files.
  • make build-ios and the macOS test build: both succeed, 0 warnings.
  • Two mutation checks: reverting the fragment line scores 96/96 (proving it inert), and planting a URL(string:) in a new production file turns the adoption suite red with exactly one failure (proving the guard fires).