Skip to content

fix: make DatabaseId's escaping survive a System.Uri round trip (#599) - #603

Merged
jeremydmiller merged 1 commit into
mainfrom
gh599/uri-safe-database-id
Aug 2, 2026
Merged

fix: make DatabaseId's escaping survive a System.Uri round trip (#599)#603
jeremydmiller merged 1 commit into
mainfrom
gh599/uri-safe-database-id

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Fixes #599.

The defect

DatabaseId.ToString() escaped . as %2E so TryParse could find the Server/Name separator. But the only consumer of that string interpolates it into a System.Uri (Wolverine's EventSubscriptionAgentFamily.UriFor) and parses it back out of uri.Segments — and . is unreserved in RFC 3986, so Uri canonicalisation decodes %2E straight back to ..

Confirmed on .NET 10:

input survives new Uri(...)?
%2E no — decoded to .
%25 yes
~ yes
! $ , ; ' * ( ) + = yes

So the escaping delivered none of the disambiguation it was written for, and the separator search fell back on LastIndexOf('.') — right only while the database name holds no dots, which is why nothing failed loudly.

The two spellings

Uri.ToString() decodes; System.Text.Json serialises a Uri through OriginalString, which does not. And Identity used literal dots while ToString() used %2E. One agent identity therefore reached a client spelled two ways depending on how it travelled, and joins between the two silently missed — CritterWatch#878, where agent health rendered as "unknown".

The fix

Escape . as ! — a sub-delimiter, which Uri preserves verbatim.

new DatabaseId("server.with.dots", "name.with.dots")

  before -> server%2Ewith%2Edots.name%2Ewith%2Edots
            (Uri decodes to server.with.dots.name.with.dots -- ambiguous)

  after  -> server!with!dots.name!with!dots
            (survives the round trip verbatim)

Full escape alphabet, in application order — ! and ~ are the escape characters, so a literal one is escaped before anything else can produce one:

%%25 unchanged; %25 already survived
!!! new
~!~ new
/~ unchanged
.! was %2E

Identity now returns the same escaped spelling as ToString(), so there is exactly one form of one identity.

Also closes a pre-existing hole: ~ was the / escape but a literal ~ was never escaped, so new DatabaseId("a~b", "c") came back out as a/b.

Backwards compatibility

Persisted agent URIs keep parsing. %2E is still decoded on the way in (before the %25 pass — a value holding the literal text %2E is written %252E, which contains no %2E of its own, so it survives that pass and decodes correctly in the next), and a bare ~ is still read as /.

The one thing that changes for an old value is a segment containing a literal !: written before this change it was left alone, and it now reads back as ..

Tests

survives_a_system_uri_round_trip is the check that was missing — it builds an agent-shaped URI, asserts ToString()/AbsoluteUri/OriginalString all agree, and parses the id back out of uri.Segments. Verified it fails on the old implementation (along with identity_and_to_string_are_the_same_spelling, round_trips_a_literal_bang and round_trips_a_literal_tilde — 7 failures total) and passes on the new one. Full CoreTests suite green.

Downstream

The CritterWatch side was already worked around by canonicalising to the decoded form; with one spelling now coming out of JasperFx that workaround can be retired. Worth a look at whether Wolverine's EventSubscriptionAgentFamily wants the Segments index revisited at the same time.

🤖 Generated with Claude Code

DatabaseId.ToString() escaped '.' as "%2E" so TryParse could find the Server/Name
separator. But the only consumer of that string interpolates it into a System.Uri and
parses it back out of uri.Segments, and '.' is unreserved in RFC 3986 -- so Uri
canonicalisation decodes "%2E" straight back to '.'. The escaping delivered none of the
disambiguation it was written for, and the separator search fell back on LastIndexOf('.'),
which happens to be right only while the database *name* holds no dots.

Worse, it left two publicly-reachable spellings of one identity: Uri.ToString() decodes,
System.Text.Json serialises a Uri through OriginalString which does not, and Identity used
literal dots while ToString() used "%2E". A single agent identity reached a client spelled
two ways depending on how it travelled, and joins between the two silently missed. That is
CritterWatch#878.

Escape '.' as '!' instead. '!' is a sub-delimiter, which Uri preserves verbatim -- verified
on .NET 10, along with '~', "%25" and the rest of the sub-delims. Identity now returns the
same escaped spelling as ToString(), so there is exactly one form to reason about.

Also closes a pre-existing hole: '~' was the '/' escape but a literal '~' was never
escaped, so new DatabaseId("a~b", "c") came back out as "a/b".

Persisted agent URIs keep parsing: "%2E" is still decoded on the way in, and a bare '~' is
still read as '/'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DatabaseId.ToString()'s %2E escaping is normalised away by System.Uri, so it yields two spellings and no disambiguation

1 participant