diff --git a/CHANGES.md b/CHANGES.md index 1236c5b4d..40ce2ae58 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 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 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..4827a54ff 100644 --- a/docs/manual/vocab.md +++ b/docs/manual/vocab.md @@ -113,6 +113,12 @@ 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]. 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/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..7551cd135 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,256 @@ 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", + ]; + 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 authority pct-encoding casing", () => { + deepStrictEqual( + 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", + ); + 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", () => { + deepStrictEqual( + 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", + ); + 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", () => { + 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"), + "ap+ef61://did:key:z6Mkabc/actor", + ); + deepStrictEqual( + canonicalizePortableUri("ap://DID%3Akey%3Az6Mkabc/actor"), + "ap+ef61://did:key:z6Mkabc/actor", + ); +}); + +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/../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", + "at://did:plc:example/record", + "/actor", + "ap://not-a-did/actor", + "ap://did:key:z6Mkabc", + ]; + 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("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, + ); + throws( + () => canonicalizePortableUri("ap://did:key:z6Mkabc/\ud800"), + TypeError, + ); + throws( + () => canonicalizePortableUri("ap://did:key:z6Mkabc/actor#\ud800"), + 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( + "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("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", + ), + ); + 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", + ), + ); + ok( + !arePortableUrisEqual( + "ap://did:key:z6Mkabc/a%zz", + "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", () => { 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..3f9e29cb0 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; @@ -63,6 +64,73 @@ 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 strings with decoded or + * percent-encoded DID authorities. The returned value uses the `ap+ef61:` + * 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. + * + * @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 { + if (typeof input !== "string") { + throw new TypeError("Invalid portable ActivityPub IRI."); + } + 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 + // decodePortableAuthority() reverses the shared percent-encoded authority + // path here rather than the raw did:-prefixed branch. + 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 + // 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}`; +} + +/** + * Checks whether two FEP-ef61 portable ActivityPub URIs identify the same + * portable object. + * + * 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 + */ +export function arePortableUrisEqual( + left: string, + right: string, +): boolean { + 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; + } +} + /** * Checks whether two IRIs have the same origin. */ @@ -143,6 +211,49 @@ function decodePortableAuthority(authority: string): string { return decoded; } +function normalizePercentEncoding(value: string): string { + return value.replace( + PERCENT_ENCODING_PATTERN, + (match) => match.toUpperCase(), + ); +} + +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."); + } + return value.replace( + /%[0-9A-Fa-f]{2}|[^%]+/g, + (match) => { + if (match.startsWith("%")) { + const upper = match.toUpperCase(); + const decoded = String.fromCharCode( + Number.parseInt(upper.slice(1), 16), + ); + return /[A-Za-z0-9._~-]/.test(decoded) ? decoded : upper; + } + try { + return encodeURI(match); + } catch (error) { + if (error instanceof URIError) { + throw new TypeError("Invalid portable ActivityPub IRI component."); + } + throw error; + } + }, + ); +} + function parseAtUri(uri: string): URL { const index = uri.indexOf("/", 5); const authority = index >= 0 ? uri.slice(5, index) : uri.slice(5);