From 2b3123164fd11a8f2d9d78863a57eeb4fc8c50c1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 21:08:42 +0900 Subject: [PATCH 01/12] Compare portable ActivityPub URIs Add vocab-runtime helpers for canonicalizing and comparing FEP-ef61 portable URI identifiers. The comparison form accepts both ap: and ap+ef61:, decodes DID authorities, strips query hints, and preserves path and fragment identity. Document the helper behavior and cover decoded, encoded, query, fragment, casing, and rejection cases in the URL runtime tests. Closes https://github.com/fedify-dev/fedify/issues/828 Assisted-by: Codex:gpt-5.5 --- CHANGES.md | 8 ++ docs/manual/vocab.md | 4 + packages/vocab-runtime/src/mod.ts | 2 + packages/vocab-runtime/src/url.test.ts | 102 +++++++++++++++++++++++++ packages/vocab-runtime/src/url.ts | 34 +++++++++ 5 files changed, 150 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 1236c5b4d..f7647289e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -92,6 +92,12 @@ To be released. ### @fedify/vocab-runtime + - Added `canonicalizePortableUri()` and `arePortableUrisEqual()` for + comparing [FEP-ef61] portable ActivityPub URIs. The helpers accept `ap:` + and `ap+ef61:` values with decoded or percent-encoded DID authorities, + normalize them to `ap+ef61:`, and ignore query hints such as `gateways` + during comparison. [[#828], [#924]] + - Added the [FEP-7aa9] JSON-LD context to the preloaded context registry so FEP-7aa9 documents can be compacted and expanded without fetching the context remotely. [[#810], [#914]] @@ -108,8 +114,10 @@ To be released. error pages surface as document loading failures with the response URL and content type, rather than generic JSON parser crashes. [[#912], [#913]] +[#828]: https://github.com/fedify-dev/fedify/issues/828 [#912]: https://github.com/fedify-dev/fedify/issues/912 [#913]: https://github.com/fedify-dev/fedify/pull/913 +[#924]: https://github.com/fedify-dev/fedify/pull/924 Version 2.3.1 diff --git a/docs/manual/vocab.md b/docs/manual/vocab.md index c5899c3d2..e76468698 100644 --- a/docs/manual/vocab.md +++ b/docs/manual/vocab.md @@ -113,6 +113,10 @@ Both the `ap:` and `ap+ef61:` schemes are accepted, whether the DID authority is decoded (e.g., `ap+ef61://did:key:.../actor`) or percent-encoded. Fedify stores these IRIs as `URL` objects with a URL-safe authority internally, and serializes them as canonical `ap+ef61:` IRIs with the decoded DID authority. +When comparing portable object IDs, use `canonicalizePortableUri()` or +`arePortableUrisEqual()` from `@fedify/vocab-runtime`; these helpers remove +query hints such as `gateways` according to [FEP-ef61]. Serialization keeps +those query hints intact. > [!TIP] > You can instantiate an object from a JSON-LD document by calling the diff --git a/packages/vocab-runtime/src/mod.ts b/packages/vocab-runtime/src/mod.ts index 171855d2b..4dec5ad2a 100644 --- a/packages/vocab-runtime/src/mod.ts +++ b/packages/vocab-runtime/src/mod.ts @@ -54,6 +54,8 @@ export { type PropertyPreprocessorContext, } from "./preprocessor.ts"; export { + arePortableUrisEqual, + canonicalizePortableUri, expandIPv6Address, formatIri, haveSameIriOrigin, diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index 84c421e9d..614657851 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -1,6 +1,8 @@ import { deepStrictEqual, ok, rejects, throws } from "node:assert"; import { test } from "node:test"; import { + arePortableUrisEqual, + canonicalizePortableUri, expandIPv6Address, formatIri, haveSameIriOrigin, @@ -184,6 +186,106 @@ test("formatIri() emits canonical portable ActivityPub URI syntax", () => { deepStrictEqual(formatIri("/actor"), "/actor"); }); +test("canonicalizePortableUri() emits comparison forms", () => { + const cases = [ + "ap://did:key:z6Mkabc/actor", + "ap://did%3Akey%3Az6Mkabc/actor", + "ap://did:key:z6Mkabc/actor?gateways=https%3A%2F%2Fa.example", + "ap+ef61://did:key:z6Mkabc/actor", + "ap+ef61://did%3Akey%3Az6Mkabc/actor?gateways=https%3A%2F%2Fa.example", + new URL( + "ap+ef61://did%3Akey%3Az6Mkabc/actor?gateways=https%3A%2F%2Fa.example", + ), + ]; + for (const iri of cases) { + deepStrictEqual( + canonicalizePortableUri(iri), + "ap+ef61://did:key:z6Mkabc/actor", + ); + } +}); + +test("canonicalizePortableUri() preserves paths and fragments", () => { + deepStrictEqual( + canonicalizePortableUri( + "ap://did:key:z6Mkabc/objects/1/attachments/2?gateways=https%3A%2F%2Fa.example#image", + ), + "ap+ef61://did:key:z6Mkabc/objects/1/attachments/2#image", + ); + deepStrictEqual( + canonicalizePortableUri("ap://did:key:z6Mkabc/objects/1#reply"), + "ap+ef61://did:key:z6Mkabc/objects/1#reply", + ); +}); + +test("canonicalizePortableUri() preserves DID-internal pct-encoded characters", () => { + deepStrictEqual( + canonicalizePortableUri("ap://did:example:abc%2Fdef/actor?x=1"), + "ap+ef61://did:example:abc%2Fdef/actor", + ); + deepStrictEqual( + canonicalizePortableUri("ap://did%3Aweb%3Aexample.com%253A3000/u/1"), + "ap+ef61://did:web:example.com%3A3000/u/1", + ); +}); + +test("canonicalizePortableUri() normalizes DID scheme casing", () => { + deepStrictEqual( + canonicalizePortableUri("ap://DID:key:z6Mkabc/actor"), + "ap+ef61://did:key:z6Mkabc/actor", + ); + deepStrictEqual( + canonicalizePortableUri("ap://DID%3Akey%3Az6Mkabc/actor"), + "ap+ef61://did:key:z6Mkabc/actor", + ); +}); + +test("canonicalizePortableUri() rejects non-portable URIs", () => { + const cases = [ + "https://example.com/actor", + "at://did:plc:example/record", + "/actor", + "ap://not-a-did/actor", + "ap://did:key:z6Mkabc", + ]; + for (const iri of cases) { + throws(() => canonicalizePortableUri(iri), TypeError); + } +}); + +test("arePortableUrisEqual() compares canonical portable URI forms", () => { + ok(arePortableUrisEqual( + "ap://did:key:z6Mkabc/actor", + "ap+ef61://did%3Akey%3Az6Mkabc/actor?gateways=https%3A%2F%2Fa.example", + )); + ok(arePortableUrisEqual( + new URL("ap://did%3Akey%3Az6Mkabc/actor?gateways=https%3A%2F%2Fa.example"), + "ap+ef61://did:key:z6Mkabc/actor", + )); + ok(arePortableUrisEqual( + "ap://DID:key:z6Mkabc/actor", + "ap://did:key:z6Mkabc/actor", + )); + ok( + !arePortableUrisEqual( + "ap://did:key:z6Mkabc/actor", + "ap://did:key:z6Mkdef/actor", + ), + ); + ok( + !arePortableUrisEqual( + "ap://did:key:z6Mkabc/actor", + "ap://did:key:z6Mkabc/outbox", + ), + ); + ok( + !arePortableUrisEqual( + "ap://did:key:z6Mkabc/actor#one", + "ap://did:key:z6Mkabc/actor#two", + ), + ); +}); + test("formatIri() preserves DID authority pct-encoded delimiters", () => { const parsed = parseIri("ap://did:example:abc%2Fdef/actor"); deepStrictEqual( diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index 4211b67ef..bd395a106 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -63,6 +63,40 @@ export function formatIri(iri: string | URL): string { return `ap+ef61://${authority}${parsed.pathname}${parsed.search}${parsed.hash}`; } +/** + * Canonicalizes a FEP-ef61 portable ActivityPub URI for comparison. + * + * This accepts both `ap:` and `ap+ef61:` URI values with decoded or + * percent-encoded DID authorities. The returned value uses the `ap+ef61:` + * scheme, a decoded DID authority, and no query component. + * + * @since 2.4.0 + */ +export function canonicalizePortableUri(input: string | URL): string { + const parsed = parsePortableIri(input instanceof URL ? input.href : input); + if (parsed == null) { + throw new TypeError("Invalid portable ActivityPub IRI."); + } + const authority = decodePortableAuthority(parsed.host).replace( + DID_SCHEME_PATTERN, + "did:", + ); + return `ap+ef61://${authority}${parsed.pathname}${parsed.hash}`; +} + +/** + * Checks whether two FEP-ef61 portable ActivityPub URIs identify the same + * portable object. + * + * @since 2.4.0 + */ +export function arePortableUrisEqual( + left: string | URL, + right: string | URL, +): boolean { + return canonicalizePortableUri(left) === canonicalizePortableUri(right); +} + /** * Checks whether two IRIs have the same origin. */ From e8b53a3a9a2e9db768399cdebdb9be0facc169fa Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 21:22:58 +0900 Subject: [PATCH 02/12] Preserve portable URI path identity Keep the comparison canonicalizer from using URL-normalized path components, because FEP-ef61 treats portable URI paths as opaque object identity strings. Also normalize percent-encoding case in DID authorities so equivalent authority spellings compare consistently. https://github.com/fedify-dev/fedify/pull/924#discussion_r3536332810 https://github.com/fedify-dev/fedify/pull/924#discussion_r3536348180 https://github.com/fedify-dev/fedify/pull/924#discussion_r3536352418 Assisted-by: Codex:gpt-5.5 --- packages/vocab-runtime/src/url.test.ts | 38 ++++++++++++++++++++++++++ packages/vocab-runtime/src/url.ts | 27 ++++++++++++++---- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index 614657851..bc7ab2d63 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -229,6 +229,21 @@ test("canonicalizePortableUri() preserves DID-internal pct-encoded characters", ); }); +test("canonicalizePortableUri() normalizes authority pct-encoding casing", () => { + deepStrictEqual( + canonicalizePortableUri("ap://did:example:abc%2fdef/actor"), + "ap+ef61://did:example:abc%2Fdef/actor", + ); + deepStrictEqual( + canonicalizePortableUri("ap://did%3Aexample%3Aabc%252fdef/actor"), + "ap+ef61://did:example:abc%2Fdef/actor", + ); + ok(arePortableUrisEqual( + "ap://did:example:abc%2fdef/actor", + "ap://did:example:abc%2Fdef/actor", + )); +}); + test("canonicalizePortableUri() normalizes DID scheme casing", () => { deepStrictEqual( canonicalizePortableUri("ap://DID:key:z6Mkabc/actor"), @@ -240,6 +255,29 @@ test("canonicalizePortableUri() normalizes DID scheme casing", () => { ); }); +test("canonicalizePortableUri() preserves opaque path segments", () => { + deepStrictEqual( + canonicalizePortableUri("ap://did:key:z6Mkabc/a/../b?gateways=x"), + "ap+ef61://did:key:z6Mkabc/a/../b", + ); + deepStrictEqual( + canonicalizePortableUri("ap://did:key:z6Mkabc/a/%2e%2e/b"), + "ap+ef61://did:key:z6Mkabc/a/%2e%2e/b", + ); + ok( + !arePortableUrisEqual( + "ap://did:key:z6Mkabc/a/../b", + "ap://did:key:z6Mkabc/b", + ), + ); + ok( + !arePortableUrisEqual( + "ap://did:key:z6Mkabc/a/%2e%2e/b", + "ap://did:key:z6Mkabc/b", + ), + ); +}); + test("canonicalizePortableUri() rejects non-portable URIs", () => { const cases = [ "https://example.com/actor", diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index bd395a106..0c1689168 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -12,6 +12,7 @@ export class UrlError extends Error { const PORTABLE_IRI_PATTERN = /^(ap|ap\+ef61):\/\/([^/?#]*)([^?#]*)(\?[^#]*)?(#.*)?$/i; const INVALID_PERCENT_ENCODING_PATTERN = /%(?![0-9A-Fa-f]{2})/; +const PERCENT_ENCODING_PATTERN = /%[0-9A-Fa-f]{2}/g; const DID_SCHEME_PATTERN = /^did:/i; const DID_PATTERN = /^did:[a-z0-9]+:[-A-Za-z0-9._%]+(?::[-A-Za-z0-9._%]+)*$/i; @@ -73,15 +74,24 @@ export function formatIri(iri: string | URL): string { * @since 2.4.0 */ export function canonicalizePortableUri(input: string | URL): string { - const parsed = parsePortableIri(input instanceof URL ? input.href : input); + const iri = input instanceof URL ? input.href : input; + const parsed = parsePortableIri(iri); if (parsed == null) { throw new TypeError("Invalid portable ActivityPub IRI."); } - const authority = decodePortableAuthority(parsed.host).replace( - DID_SCHEME_PATTERN, - "did:", + const match = iri.match(PORTABLE_IRI_PATTERN); + if (match == null) { + throw new TypeError("Invalid portable ActivityPub IRI."); + } + // parsePortableIri() validates the value but returns a URL, which normalizes + // opaque path segments. Use the raw match for path and fragment comparison. + // parsed.host is the encodeURIComponent() output from parsePortableIri(), so + // decodePortableAuthority() reverses the shared percent-encoded authority + // path here rather than the raw did:-prefixed branch. + const authority = normalizePercentEncoding( + decodePortableAuthority(parsed.host).replace(DID_SCHEME_PATTERN, "did:"), ); - return `ap+ef61://${authority}${parsed.pathname}${parsed.hash}`; + return `ap+ef61://${authority}${match[3]}${match[5] ?? ""}`; } /** @@ -177,6 +187,13 @@ function decodePortableAuthority(authority: string): string { return decoded; } +function normalizePercentEncoding(value: string): string { + return value.replace( + PERCENT_ENCODING_PATTERN, + (match) => match.toUpperCase(), + ); +} + function parseAtUri(uri: string): URL { const index = uri.indexOf("/", 5); const authority = index >= 0 ? uri.slice(5, index) : uri.slice(5); From 5a6b24ac8c9e63467dd5149c7c805b977dd3dbb9 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 21:41:48 +0900 Subject: [PATCH 03/12] Require raw portable URI strings Make the portable URI comparison helpers accept raw strings only, since URL objects may already have normalized opaque path segments before the helpers can compare identity. Keep the redundant pattern check out of the validated path and document the string-only comparison contract. https://github.com/fedify-dev/fedify/pull/924#discussion_r3536387585 https://github.com/fedify-dev/fedify/pull/924#discussion_r3536418333 https://github.com/fedify-dev/fedify/pull/924#discussion_r3536425894 https://github.com/fedify-dev/fedify/pull/924#discussion_r3536425896 Assisted-by: Codex:gpt-5.5 --- CHANGES.md | 8 ++++---- docs/manual/vocab.md | 6 ++++-- packages/vocab-runtime/src/url.test.ts | 14 +++++++------- packages/vocab-runtime/src/url.ts | 21 +++++++++++---------- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f7647289e..40ce2ae58 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -93,10 +93,10 @@ To be released. ### @fedify/vocab-runtime - Added `canonicalizePortableUri()` and `arePortableUrisEqual()` for - comparing [FEP-ef61] portable ActivityPub URIs. The helpers accept `ap:` - and `ap+ef61:` values with decoded or percent-encoded DID authorities, - normalize them to `ap+ef61:`, and ignore query hints such as `gateways` - during comparison. [[#828], [#924]] + comparing [FEP-ef61] portable ActivityPub URI strings. The helpers accept + `ap:` and `ap+ef61:` values with decoded or percent-encoded DID + authorities, normalize them to `ap+ef61:`, and ignore query hints such as + `gateways` during comparison. [[#828], [#924]] - Added the [FEP-7aa9] JSON-LD context to the preloaded context registry so FEP-7aa9 documents can be compacted and expanded without fetching the diff --git a/docs/manual/vocab.md b/docs/manual/vocab.md index e76468698..4827a54ff 100644 --- a/docs/manual/vocab.md +++ b/docs/manual/vocab.md @@ -115,8 +115,10 @@ stores these IRIs as `URL` objects with a URL-safe authority internally, and serializes them as canonical `ap+ef61:` IRIs with the decoded DID authority. When comparing portable object IDs, use `canonicalizePortableUri()` or `arePortableUrisEqual()` from `@fedify/vocab-runtime`; these helpers remove -query hints such as `gateways` according to [FEP-ef61]. Serialization keeps -those query hints intact. +query hints such as `gateways` according to [FEP-ef61]. Pass raw URI strings +to these comparison helpers, because JavaScript `URL` objects normalize opaque +path segments before Fedify can compare them. Serialization keeps those query +hints intact. > [!TIP] > You can instantiate an object from a JSON-LD document by calling the diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index bc7ab2d63..8b801347e 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -193,9 +193,6 @@ test("canonicalizePortableUri() emits comparison forms", () => { "ap://did:key:z6Mkabc/actor?gateways=https%3A%2F%2Fa.example", "ap+ef61://did:key:z6Mkabc/actor", "ap+ef61://did%3Akey%3Az6Mkabc/actor?gateways=https%3A%2F%2Fa.example", - new URL( - "ap+ef61://did%3Akey%3Az6Mkabc/actor?gateways=https%3A%2F%2Fa.example", - ), ]; for (const iri of cases) { deepStrictEqual( @@ -289,6 +286,13 @@ test("canonicalizePortableUri() rejects non-portable URIs", () => { for (const iri of cases) { throws(() => canonicalizePortableUri(iri), TypeError); } + throws( + () => + canonicalizePortableUri( + new URL("ap+ef61://did%3Akey%3Az6Mkabc/actor") as unknown as string, + ), + TypeError, + ); }); test("arePortableUrisEqual() compares canonical portable URI forms", () => { @@ -296,10 +300,6 @@ test("arePortableUrisEqual() compares canonical portable URI forms", () => { "ap://did:key:z6Mkabc/actor", "ap+ef61://did%3Akey%3Az6Mkabc/actor?gateways=https%3A%2F%2Fa.example", )); - ok(arePortableUrisEqual( - new URL("ap://did%3Akey%3Az6Mkabc/actor?gateways=https%3A%2F%2Fa.example"), - "ap+ef61://did:key:z6Mkabc/actor", - )); ok(arePortableUrisEqual( "ap://DID:key:z6Mkabc/actor", "ap://did:key:z6Mkabc/actor", diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index 0c1689168..2f1d112c1 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -67,22 +67,23 @@ export function formatIri(iri: string | URL): string { /** * Canonicalizes a FEP-ef61 portable ActivityPub URI for comparison. * - * This accepts both `ap:` and `ap+ef61:` URI values with decoded or + * This accepts both `ap:` and `ap+ef61:` URI strings with decoded or * percent-encoded DID authorities. The returned value uses the `ap+ef61:` - * scheme, a decoded DID authority, and no query component. + * scheme, a decoded DID authority, and no query component. Pass the raw URI + * string, not a `URL` object, because JavaScript `URL` normalizes opaque path + * segments before Fedify can compare them. * * @since 2.4.0 */ -export function canonicalizePortableUri(input: string | URL): string { - const iri = input instanceof URL ? input.href : input; - const parsed = parsePortableIri(iri); - if (parsed == null) { +export function canonicalizePortableUri(input: string): string { + if (typeof input !== "string") { throw new TypeError("Invalid portable ActivityPub IRI."); } - const match = iri.match(PORTABLE_IRI_PATTERN); - if (match == null) { + const parsed = parsePortableIri(input); + if (parsed == null) { throw new TypeError("Invalid portable ActivityPub IRI."); } + const match = input.match(PORTABLE_IRI_PATTERN)!; // parsePortableIri() validates the value but returns a URL, which normalizes // opaque path segments. Use the raw match for path and fragment comparison. // parsed.host is the encodeURIComponent() output from parsePortableIri(), so @@ -101,8 +102,8 @@ export function canonicalizePortableUri(input: string | URL): string { * @since 2.4.0 */ export function arePortableUrisEqual( - left: string | URL, - right: string | URL, + left: string, + right: string, ): boolean { return canonicalizePortableUri(left) === canonicalizePortableUri(right); } From 935905e179307eb890d63b2316fb78a53233d7c3 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 21:52:26 +0900 Subject: [PATCH 04/12] Document portable URI comparison details Clarify that portable URI comparison normalizes percent-encoding case only for the DID authority. Path and fragment components remain byte-for-byte from the raw match because FEP-ef61 treats portable object paths as opaque identity strings. https://github.com/fedify-dev/fedify/pull/924#pullrequestreview-4645216713 Assisted-by: Codex:gpt-5.5 --- packages/vocab-runtime/src/url.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index 2f1d112c1..d5324ac2e 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -89,6 +89,9 @@ export function canonicalizePortableUri(input: string): string { // parsed.host is the encodeURIComponent() output from parsePortableIri(), so // decodePortableAuthority() reverses the shared percent-encoded authority // path here rather than the raw did:-prefixed branch. + // Only the authority gets percent-encoding case normalization. The path and + // fragment stay byte-for-byte from the raw match so their pct-encoding case + // remains part of the opaque portable object identity. const authority = normalizePercentEncoding( decodePortableAuthority(parsed.host).replace(DID_SCHEME_PATTERN, "did:"), ); From 1df7565ea36bdde2ecf5ebf38323f20d11a11741 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 21:57:02 +0900 Subject: [PATCH 05/12] Compare portable URI strings defensively Normalize percent-escape hex casing in preserved path and fragment components, while still reading those components from the raw URI string so URL dot-segment normalization cannot collapse portable object identity. Also make arePortableUrisEqual() fall back to direct string comparison for non-portable strings. Portable-looking inputs still use the strict portable URI parser, so malformed portable authorities continue to fail instead of being silently accepted. https://github.com/fedify-dev/fedify/pull/924#discussion_r3536506801 https://github.com/fedify-dev/fedify/pull/924#discussion_r3536506819 https://github.com/fedify-dev/fedify/pull/924#discussion_r3536532618 Assisted-by: Codex:gpt-5.5 --- packages/vocab-runtime/src/url.test.ts | 36 +++++++++++++++++++++++++- packages/vocab-runtime/src/url.ts | 16 +++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index 8b801347e..585997ee9 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -241,6 +241,17 @@ test("canonicalizePortableUri() normalizes authority pct-encoding casing", () => )); }); +test("canonicalizePortableUri() normalizes path and fragment pct-encoding casing", () => { + deepStrictEqual( + canonicalizePortableUri("ap://did:key:z6Mkabc/actor%2fprofile#part%2ftwo"), + "ap+ef61://did:key:z6Mkabc/actor%2Fprofile#part%2Ftwo", + ); + ok(arePortableUrisEqual( + "ap://did:key:z6Mkabc/actor%2fprofile#part%2ftwo", + "ap://did:key:z6Mkabc/actor%2Fprofile#part%2Ftwo", + )); +}); + test("canonicalizePortableUri() normalizes DID scheme casing", () => { deepStrictEqual( canonicalizePortableUri("ap://DID:key:z6Mkabc/actor"), @@ -259,7 +270,7 @@ test("canonicalizePortableUri() preserves opaque path segments", () => { ); deepStrictEqual( canonicalizePortableUri("ap://did:key:z6Mkabc/a/%2e%2e/b"), - "ap+ef61://did:key:z6Mkabc/a/%2e%2e/b", + "ap+ef61://did:key:z6Mkabc/a/%2E%2E/b", ); ok( !arePortableUrisEqual( @@ -324,6 +335,29 @@ test("arePortableUrisEqual() compares canonical portable URI forms", () => { ); }); +test("arePortableUrisEqual() handles non-portable URI strings", () => { + ok(arePortableUrisEqual( + "https://example.com/actor", + "https://example.com/actor", + )); + ok( + !arePortableUrisEqual( + "https://example.com/actor", + "https://example.com/outbox", + ), + ); + ok( + !arePortableUrisEqual( + "ap://did:key:z6Mkabc/actor", + "https://example.com/actor", + ), + ); + throws( + () => arePortableUrisEqual("ap://not-a-did/actor", "ap://not-a-did/actor"), + TypeError, + ); +}); + test("formatIri() preserves DID authority pct-encoded delimiters", () => { const parsed = parseIri("ap://did:example:abc%2Fdef/actor"); deepStrictEqual( diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index d5324ac2e..932ed30c4 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -89,13 +89,15 @@ export function canonicalizePortableUri(input: string): string { // parsed.host is the encodeURIComponent() output from parsePortableIri(), so // decodePortableAuthority() reverses the shared percent-encoded authority // path here rather than the raw did:-prefixed branch. - // Only the authority gets percent-encoding case normalization. The path and - // fragment stay byte-for-byte from the raw match so their pct-encoding case - // remains part of the opaque portable object identity. const authority = normalizePercentEncoding( decodePortableAuthority(parsed.host).replace(DID_SCHEME_PATTERN, "did:"), ); - return `ap+ef61://${authority}${match[3]}${match[5] ?? ""}`; + // Keep path and fragment text from the raw match to avoid URL dot-segment + // normalization, but still normalize percent-escape hex casing per URI + // comparison rules. + const path = normalizePercentEncoding(match[3]); + const fragment = match[5] == null ? "" : normalizePercentEncoding(match[5]); + return `ap+ef61://${authority}${path}${fragment}`; } /** @@ -108,6 +110,12 @@ export function arePortableUrisEqual( left: string, right: string, ): boolean { + if ( + typeof left !== "string" || typeof right !== "string" || + !PORTABLE_IRI_PATTERN.test(left) || !PORTABLE_IRI_PATTERN.test(right) + ) { + return left === right; + } return canonicalizePortableUri(left) === canonicalizePortableUri(right); } From 00573e5dd3494e506efd052725cb5580057fb815 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 22:14:38 +0900 Subject: [PATCH 06/12] Document portable URI comparison API Clarify the public helper contracts for portable URI canonicalization and comparison, including the strict fallback used for non-portable inputs and the parser errors that remain visible for malformed portable URI strings. Add regression coverage for multiple percent-encoded triplets in one URI segment so normalization is checked across every escape match. https://github.com/fedify-dev/fedify/pull/924#pullrequestreview-4645414758 Assisted-by: Codex:gpt-5.5 --- packages/vocab-runtime/src/url.test.ts | 8 ++++++++ packages/vocab-runtime/src/url.ts | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index 585997ee9..509f2cd86 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -231,6 +231,10 @@ test("canonicalizePortableUri() normalizes authority pct-encoding casing", () => canonicalizePortableUri("ap://did:example:abc%2fdef/actor"), "ap+ef61://did:example:abc%2Fdef/actor", ); + deepStrictEqual( + canonicalizePortableUri("ap://did:example:abc%2fdef%3abar/actor"), + "ap+ef61://did:example:abc%2Fdef%3Abar/actor", + ); deepStrictEqual( canonicalizePortableUri("ap://did%3Aexample%3Aabc%252fdef/actor"), "ap+ef61://did:example:abc%2Fdef/actor", @@ -246,6 +250,10 @@ test("canonicalizePortableUri() normalizes path and fragment pct-encoding casing canonicalizePortableUri("ap://did:key:z6Mkabc/actor%2fprofile#part%2ftwo"), "ap+ef61://did:key:z6Mkabc/actor%2Fprofile#part%2Ftwo", ); + deepStrictEqual( + canonicalizePortableUri("ap://did:key:z6Mkabc/actor%2fprofile%3aimage"), + "ap+ef61://did:key:z6Mkabc/actor%2Fprofile%3Aimage", + ); ok(arePortableUrisEqual( "ap://did:key:z6Mkabc/actor%2fprofile#part%2ftwo", "ap://did:key:z6Mkabc/actor%2Fprofile#part%2Ftwo", diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index 932ed30c4..bafc5d9b8 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -73,6 +73,9 @@ export function formatIri(iri: string | URL): string { * string, not a `URL` object, because JavaScript `URL` normalizes opaque path * segments before Fedify can compare them. * + * @param input The raw portable ActivityPub URI string to canonicalize. + * @returns The canonical portable ActivityPub URI string. + * @throws {TypeError} If the input is not a valid portable ActivityPub IRI. * @since 2.4.0 */ export function canonicalizePortableUri(input: string): string { @@ -104,6 +107,10 @@ export function canonicalizePortableUri(input: string): string { * Checks whether two FEP-ef61 portable ActivityPub URIs identify the same * portable object. * + * Non-string or non-portable inputs are compared by strict string equality. + * Portable URI inputs are compared through {@link canonicalizePortableUri}, + * which can throw a `TypeError` for malformed portable authorities or paths. + * * @since 2.4.0 */ export function arePortableUrisEqual( From 8c6cc4c25c1a4b2ad341c56be5debf9a5f9307ac Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 22:16:47 +0900 Subject: [PATCH 07/12] Harden portable URI equality checks Make the equality helper robust for malformed portable URI strings while keeping canonicalization strict. Identical strings can still compare equal, but malformed or mixed portable inputs no longer escape as TypeError from the comparison path. Encode raw path and fragment characters before comparing canonical portable URI forms, while continuing to preserve dot segments from the original raw string and normalize existing percent escapes. https://github.com/fedify-dev/fedify/pull/924#discussion_r3536618062 https://github.com/fedify-dev/fedify/pull/924#discussion_r3536618068 https://github.com/fedify-dev/fedify/pull/924#discussion_r3536640443 Assisted-by: Codex:gpt-5.5 --- packages/vocab-runtime/src/url.test.ts | 26 ++++++++++++++-- packages/vocab-runtime/src/url.ts | 41 ++++++++++++++++++-------- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index 509f2cd86..ae463731b 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -260,6 +260,17 @@ test("canonicalizePortableUri() normalizes path and fragment pct-encoding casing )); }); +test("canonicalizePortableUri() encodes raw path and fragment characters", () => { + deepStrictEqual( + canonicalizePortableUri("ap://did:key:z6Mkabc/\u00e9#\u00e9"), + "ap+ef61://did:key:z6Mkabc/%C3%A9#%C3%A9", + ); + ok(arePortableUrisEqual( + "ap://did:key:z6Mkabc/\u00e9#\u00e9", + "ap://did:key:z6Mkabc/%C3%A9#%C3%A9", + )); +}); + test("canonicalizePortableUri() normalizes DID scheme casing", () => { deepStrictEqual( canonicalizePortableUri("ap://DID:key:z6Mkabc/actor"), @@ -360,9 +371,18 @@ test("arePortableUrisEqual() handles non-portable URI strings", () => { "https://example.com/actor", ), ); - throws( - () => arePortableUrisEqual("ap://not-a-did/actor", "ap://not-a-did/actor"), - TypeError, + ok(arePortableUrisEqual("ap://not-a-did/actor", "ap://not-a-did/actor")); + ok( + !arePortableUrisEqual( + "ap://not-a-did/actor", + "ap://not-a-did/outbox", + ), + ); + ok( + !arePortableUrisEqual( + "ap://not-a-did/actor", + "ap://did:key:z6Mkabc/actor", + ), ); }); diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index bafc5d9b8..1864a6c05 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -96,10 +96,10 @@ export function canonicalizePortableUri(input: string): string { decodePortableAuthority(parsed.host).replace(DID_SCHEME_PATTERN, "did:"), ); // Keep path and fragment text from the raw match to avoid URL dot-segment - // normalization, but still normalize percent-escape hex casing per URI - // comparison rules. - const path = normalizePercentEncoding(match[3]); - const fragment = match[5] == null ? "" : normalizePercentEncoding(match[5]); + // normalization, but still encode raw characters and normalize + // percent-escape hex casing per URI comparison rules. + const path = normalizePortableComponent(match[3]); + const fragment = match[5] == null ? "" : normalizePortableComponent(match[5]); return `ap+ef61://${authority}${path}${fragment}`; } @@ -107,9 +107,10 @@ export function canonicalizePortableUri(input: string): string { * Checks whether two FEP-ef61 portable ActivityPub URIs identify the same * portable object. * - * Non-string or non-portable inputs are compared by strict string equality. - * Portable URI inputs are compared through {@link canonicalizePortableUri}, - * which can throw a `TypeError` for malformed portable authorities or paths. + * Non-string inputs return `false`. Non-portable URI strings use strict string + * equality. Portable URI strings are compared through + * {@link canonicalizePortableUri}; malformed portable URI strings return + * `false` unless they are exactly equal. * * @since 2.4.0 */ @@ -117,13 +118,17 @@ export function arePortableUrisEqual( left: string, right: string, ): boolean { - if ( - typeof left !== "string" || typeof right !== "string" || - !PORTABLE_IRI_PATTERN.test(left) || !PORTABLE_IRI_PATTERN.test(right) - ) { - return left === right; + if (typeof left !== "string" || typeof right !== "string") return false; + if (left === right) return true; + if (!PORTABLE_IRI_PATTERN.test(left) || !PORTABLE_IRI_PATTERN.test(right)) { + return false; + } + try { + return canonicalizePortableUri(left) === canonicalizePortableUri(right); + } catch (error) { + if (error instanceof TypeError) return false; + throw error; } - return canonicalizePortableUri(left) === canonicalizePortableUri(right); } /** @@ -213,6 +218,16 @@ function normalizePercentEncoding(value: string): string { ); } +function normalizePortableComponent(value: string): string { + return value.replace( + /%[0-9A-Fa-f]{2}|[^%]+|%/g, + (match) => + match.startsWith("%") && match.length === 3 + ? match.toUpperCase() + : encodeURI(match), + ); +} + function parseAtUri(uri: string): URL { const index = uri.indexOf("/", 5); const authority = index >= 0 ? uri.slice(5, index) : uri.slice(5); From 8478f3b7eab51bb9955098819e683e5c2a7a4444 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 22:34:27 +0900 Subject: [PATCH 08/12] Reject invalid portable URI escapes Validate path and fragment percent escapes before canonicalizing portable URI components. This keeps malformed inputs from being normalized into the same canonical form as a different valid URI string during equality checks. https://github.com/fedify-dev/fedify/pull/924#discussion_r3536829463 Assisted-by: Codex:gpt-5.5 --- packages/vocab-runtime/src/url.test.ts | 14 ++++++++++++++ packages/vocab-runtime/src/url.ts | 3 +++ 2 files changed, 17 insertions(+) diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index ae463731b..aa0f2056c 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -325,6 +325,14 @@ test("canonicalizePortableUri() rejects non-portable URIs", () => { ); }); +test("canonicalizePortableUri() rejects invalid path and fragment pct-encoding", () => { + throws(() => canonicalizePortableUri("ap://did:key:z6Mkabc/a%zz"), TypeError); + throws( + () => canonicalizePortableUri("ap://did:key:z6Mkabc/actor#part%zz"), + TypeError, + ); +}); + test("arePortableUrisEqual() compares canonical portable URI forms", () => { ok(arePortableUrisEqual( "ap://did:key:z6Mkabc/actor", @@ -384,6 +392,12 @@ test("arePortableUrisEqual() handles non-portable URI strings", () => { "ap://did:key:z6Mkabc/actor", ), ); + ok( + !arePortableUrisEqual( + "ap://did:key:z6Mkabc/a%zz", + "ap://did:key:z6Mkabc/a%25zz", + ), + ); }); test("formatIri() preserves DID authority pct-encoded delimiters", () => { diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index 1864a6c05..5a7d25b3a 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -219,6 +219,9 @@ function normalizePercentEncoding(value: string): string { } function normalizePortableComponent(value: string): string { + if (INVALID_PERCENT_ENCODING_PATTERN.test(value)) { + throw new TypeError("Invalid portable ActivityPub IRI component."); + } return value.replace( /%[0-9A-Fa-f]{2}|[^%]+|%/g, (match) => From ca7b4eec40ba411d0faece4e2d10177dcbab4cb1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 22:42:02 +0900 Subject: [PATCH 09/12] Decode unreserved portable DID escapes Normalize over-encoded unreserved characters in portable DID authorities so URL-safe spellings compare equal to their decoded form. Reserved DID-internal escapes such as encoded slashes and colons remain percent-encoded to preserve existing authority identity semantics. https://github.com/fedify-dev/fedify/pull/924#discussion_r3536908354 Assisted-by: Codex:gpt-5.5 --- packages/vocab-runtime/src/url.test.ts | 8 ++++++++ packages/vocab-runtime/src/url.ts | 12 +++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index aa0f2056c..1e94440ba 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -239,10 +239,18 @@ test("canonicalizePortableUri() normalizes authority pct-encoding casing", () => canonicalizePortableUri("ap://did%3Aexample%3Aabc%252fdef/actor"), "ap+ef61://did:example:abc%2Fdef/actor", ); + deepStrictEqual( + canonicalizePortableUri("ap://did%3Akey%3Az6Mk%2Dabc/actor"), + "ap+ef61://did:key:z6Mk-abc/actor", + ); ok(arePortableUrisEqual( "ap://did:example:abc%2fdef/actor", "ap://did:example:abc%2Fdef/actor", )); + ok(arePortableUrisEqual( + "ap://did%3Akey%3Az6Mk%2Dabc/actor", + "ap://did:key:z6Mk-abc/actor", + )); }); test("canonicalizePortableUri() normalizes path and fragment pct-encoding casing", () => { diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index 5a7d25b3a..569ffbfb5 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -92,7 +92,7 @@ export function canonicalizePortableUri(input: string): string { // parsed.host is the encodeURIComponent() output from parsePortableIri(), so // decodePortableAuthority() reverses the shared percent-encoded authority // path here rather than the raw did:-prefixed branch. - const authority = normalizePercentEncoding( + const authority = normalizePortableAuthority( decodePortableAuthority(parsed.host).replace(DID_SCHEME_PATTERN, "did:"), ); // Keep path and fragment text from the raw match to avoid URL dot-segment @@ -218,6 +218,16 @@ function normalizePercentEncoding(value: string): string { ); } +function normalizePortableAuthority(authority: string): string { + return normalizePercentEncoding(authority).replace( + PERCENT_ENCODING_PATTERN, + (match) => { + const decoded = String.fromCharCode(Number.parseInt(match.slice(1), 16)); + return /[A-Za-z0-9._~-]/.test(decoded) ? decoded : match; + }, + ); +} + function normalizePortableComponent(value: string): string { if (INVALID_PERCENT_ENCODING_PATTERN.test(value)) { throw new TypeError("Invalid portable ActivityPub IRI component."); From db0b49c6e9c97f9cafff87b785c4d867bed702df Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 22:48:30 +0900 Subject: [PATCH 10/12] Decode unreserved portable path escapes Normalize percent-encoded unreserved characters in portable URI paths and fragments so escaped and decoded spellings compare equal. Reserved escapes remain percent-encoded, and raw dot segments are still preserved instead of being collapsed through URL pathname normalization. https://github.com/fedify-dev/fedify/pull/924#discussion_r3536944709 https://github.com/fedify-dev/fedify/pull/924#discussion_r3536950887 Assisted-by: Codex:gpt-5.5 --- packages/vocab-runtime/src/url.test.ts | 10 +++++++++- packages/vocab-runtime/src/url.ts | 14 ++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index 1e94440ba..1d39eda97 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -262,10 +262,18 @@ test("canonicalizePortableUri() normalizes path and fragment pct-encoding casing canonicalizePortableUri("ap://did:key:z6Mkabc/actor%2fprofile%3aimage"), "ap+ef61://did:key:z6Mkabc/actor%2Fprofile%3Aimage", ); + deepStrictEqual( + canonicalizePortableUri("ap://did:key:z6Mkabc/actor%2Dprofile#part%7Etwo"), + "ap+ef61://did:key:z6Mkabc/actor-profile#part~two", + ); ok(arePortableUrisEqual( "ap://did:key:z6Mkabc/actor%2fprofile#part%2ftwo", "ap://did:key:z6Mkabc/actor%2Fprofile#part%2Ftwo", )); + ok(arePortableUrisEqual( + "ap://did:key:z6Mkabc/actor%2Dprofile#part%7Etwo", + "ap://did:key:z6Mkabc/actor-profile#part~two", + )); }); test("canonicalizePortableUri() encodes raw path and fragment characters", () => { @@ -297,7 +305,7 @@ test("canonicalizePortableUri() preserves opaque path segments", () => { ); deepStrictEqual( canonicalizePortableUri("ap://did:key:z6Mkabc/a/%2e%2e/b"), - "ap+ef61://did:key:z6Mkabc/a/%2E%2E/b", + "ap+ef61://did:key:z6Mkabc/a/../b", ); ok( !arePortableUrisEqual( diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index 569ffbfb5..bcca27b48 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -234,10 +234,16 @@ function normalizePortableComponent(value: string): string { } return value.replace( /%[0-9A-Fa-f]{2}|[^%]+|%/g, - (match) => - match.startsWith("%") && match.length === 3 - ? match.toUpperCase() - : encodeURI(match), + (match) => { + if (match.startsWith("%") && match.length === 3) { + const upper = match.toUpperCase(); + const decoded = String.fromCharCode( + Number.parseInt(upper.slice(1), 16), + ); + return /[A-Za-z0-9._~-]/.test(decoded) ? decoded : upper; + } + return encodeURI(match); + }, ); } From faeab95d5e67bdedc8f4cde1e3b1e8972b262bd7 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 22:57:46 +0900 Subject: [PATCH 11/12] Simplify portable component matching Remove the redundant bare-percent branch from portable component normalization. Invalid percent escapes are already rejected before matching, so percent matches are always complete escape triplets. https://github.com/fedify-dev/fedify/pull/924#discussion_r3536997043 Assisted-by: Codex:gpt-5.5 --- packages/vocab-runtime/src/url.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index bcca27b48..e340eaa62 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -233,9 +233,9 @@ function normalizePortableComponent(value: string): string { throw new TypeError("Invalid portable ActivityPub IRI component."); } return value.replace( - /%[0-9A-Fa-f]{2}|[^%]+|%/g, + /%[0-9A-Fa-f]{2}|[^%]+/g, (match) => { - if (match.startsWith("%") && match.length === 3) { + if (match.startsWith("%")) { const upper = match.toUpperCase(); const decoded = String.fromCharCode( Number.parseInt(upper.slice(1), 16), From bb4c816dfea1ac42f3c13443fad0a89f1f8f63bf Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 23:12:26 +0900 Subject: [PATCH 12/12] Treat malformed portable components as invalid Raw portable URI paths and fragments can contain unpaired UTF-16 surrogates when they arrive from decoded JSON strings. Convert the URIError from encodeURI() into the same TypeError used for other invalid portable components so canonicalization and comparison keep their public contracts. https://github.com/fedify-dev/fedify/pull/924#discussion_r3537084397 Assisted-by: Codex:gpt-5.5 --- packages/vocab-runtime/src/url.test.ts | 20 ++++++++++++++++++++ packages/vocab-runtime/src/url.ts | 9 ++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/vocab-runtime/src/url.test.ts b/packages/vocab-runtime/src/url.test.ts index 1d39eda97..7551cd135 100644 --- a/packages/vocab-runtime/src/url.test.ts +++ b/packages/vocab-runtime/src/url.test.ts @@ -347,6 +347,14 @@ test("canonicalizePortableUri() rejects invalid path and fragment pct-encoding", () => canonicalizePortableUri("ap://did:key:z6Mkabc/actor#part%zz"), TypeError, ); + throws( + () => canonicalizePortableUri("ap://did:key:z6Mkabc/\ud800"), + TypeError, + ); + throws( + () => canonicalizePortableUri("ap://did:key:z6Mkabc/actor#\ud800"), + TypeError, + ); }); test("arePortableUrisEqual() compares canonical portable URI forms", () => { @@ -414,6 +422,18 @@ test("arePortableUrisEqual() handles non-portable URI strings", () => { "ap://did:key:z6Mkabc/a%25zz", ), ); + ok( + !arePortableUrisEqual( + "ap://did:key:z6Mkabc/\ud800", + "ap://did:key:z6Mkabc/%EF%BF%BD", + ), + ); + ok( + !arePortableUrisEqual( + "ap://did:key:z6Mkabc/actor#\ud800", + "ap://did:key:z6Mkabc/actor#%EF%BF%BD", + ), + ); }); test("formatIri() preserves DID authority pct-encoded delimiters", () => { diff --git a/packages/vocab-runtime/src/url.ts b/packages/vocab-runtime/src/url.ts index e340eaa62..3f9e29cb0 100644 --- a/packages/vocab-runtime/src/url.ts +++ b/packages/vocab-runtime/src/url.ts @@ -242,7 +242,14 @@ function normalizePortableComponent(value: string): string { ); return /[A-Za-z0-9._~-]/.test(decoded) ? decoded : upper; } - return encodeURI(match); + try { + return encodeURI(match); + } catch (error) { + if (error instanceof URIError) { + throw new TypeError("Invalid portable ActivityPub IRI component."); + } + throw error; + } }, ); }