diff --git a/src/lib/radUri.ts b/src/lib/radUri.ts new file mode 100644 index 000000000..5ef400e7f --- /dev/null +++ b/src/lib/radUri.ts @@ -0,0 +1,428 @@ +import bs58 from "bs58"; + +export const issueType = "xyz.radicle.issue"; +export const patchType = "xyz.radicle.patch"; +export const releaseType = "dev.radicle.artifact"; + +const explorerCobPaths: Record = { + [issueType]: "issues", + [patchType]: "patches", + [releaseType]: "releases", +}; + +export interface RadAuthority { + node: string; + address?: string; +} + +export type RadResource = + | { type: "commit"; ref: string } + | { type: "tag"; ref: string } + | { type: "tree"; oid: string } + | { type: "blob"; oid: string } + | { type: "cob"; typeName: string; oid?: string }; + +export interface QueryParam { + param: string; + value?: string; +} + +export interface RadUri { + authority?: RadAuthority; + repo: string; + namespace?: string; + resource?: RadResource; + query?: QueryParam[]; + fragment?: string; +} + +export type RadReference = + { type: "uri"; uri: RadUri } | { type: "did"; node: string }; + +const base58 = "[1-9A-HJ-NP-Za-km-z]"; +const repoIdPattern = new RegExp(`^z${base58}{27,28}$`); +const nodeIdPattern = new RegExp(`^z6Mk${base58}{44}$`); +const oidPattern = /^[0-9a-fA-F]{40}$/; +const gitRefPattern = /^[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/; +const cobTypePattern = + /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*(?:\.[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)+$/; +const hostPattern = + /^(?:\[[0-9A-Fa-f:.vV]+\]|(?:[A-Za-z0-9._~!$&'()*+,;=-]|%[0-9A-Fa-f]{2})*)$/; +const portPattern = /^[0-9]*$/; +const queryOrFragmentPattern = + /^(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*$/; + +function decodesTo(id: string, check: (bytes: Uint8Array) => boolean) { + try { + return check(bs58.decode(id.slice(1))); + } catch { + return false; + } +} + +export function isRepoId(id: string): boolean { + return ( + repoIdPattern.test(id) && decodesTo(id, bytes => bytes.byteLength === 20) + ); +} + +export function isNodeId(id: string): boolean { + return ( + nodeIdPattern.test(id) && + decodesTo( + id, + bytes => + bytes.byteLength === 34 && bytes[0] === 0xed && bytes[1] === 0x01, + ) + ); +} + +export function isOid(oid: string): boolean { + return oidPattern.test(oid); +} + +function parseAuthority(input: string): RadAuthority | undefined { + const at = input.indexOf("@"); + const node = at === -1 ? input : input.slice(0, at); + if (!isNodeId(node)) return undefined; + if (at === -1) return { node }; + + const address = input.slice(at + 1); + const colon = address.lastIndexOf(":"); + if (colon === -1) return undefined; + if ( + !hostPattern.test(address.slice(0, colon)) || + !portPattern.test(address.slice(colon + 1)) + ) { + return undefined; + } + + return { node, address }; +} + +function parseResource(segments: string[]): RadResource | undefined | null { + if (segments.length === 0) return undefined; + + const [kind, ...rest] = segments; + switch (kind.toLowerCase()) { + case "commit": + case "tag": { + const ref = rest.join("/"); + if (!gitRefPattern.test(ref)) return null; + return { + type: kind.toLowerCase() as "commit" | "tag", + ref: isOid(ref) ? ref.toLowerCase() : ref, + }; + } + case "tree": + case "blob": + if (rest.length !== 1 || !isOid(rest[0])) return null; + return { + type: kind.toLowerCase() as "tree" | "blob", + oid: rest[0].toLowerCase(), + }; + case "cob": { + const [typeName, oid, ...extra] = rest; + if (extra.length > 0 || !typeName || !cobTypePattern.test(typeName)) { + return null; + } + if (oid === undefined) return { type: "cob", typeName }; + if (!isOid(oid)) return null; + return { type: "cob", typeName, oid: oid.toLowerCase() }; + } + default: + return null; + } +} + +function parseQuery(input: string): QueryParam[] { + return input.split("&").map(pair => { + const equals = pair.indexOf("="); + return equals === -1 + ? { param: pair } + : { param: pair.slice(0, equals), value: pair.slice(equals + 1) }; + }); +} + +export function parseRadUri(input: string): RadUri | undefined { + if (input.slice(0, 4).toLowerCase() !== "rad:") return undefined; + let rest = input.slice(4); + + let fragment: string | undefined; + const hash = rest.indexOf("#"); + if (hash !== -1) { + fragment = rest.slice(hash + 1); + rest = rest.slice(0, hash); + if (!queryOrFragmentPattern.test(fragment)) return undefined; + } + + let query: QueryParam[] | undefined; + const question = rest.indexOf("?"); + if (question !== -1) { + const raw = rest.slice(question + 1); + rest = rest.slice(0, question); + if (!queryOrFragmentPattern.test(raw)) return undefined; + query = parseQuery(raw); + } + + let authority: RadAuthority | undefined; + let legacy = false; + if (rest.startsWith("//")) { + rest = rest.slice(2); + if (rest.startsWith("/")) { + rest = rest.slice(1); + } else { + const slash = rest.indexOf("/"); + const head = slash === -1 ? rest : rest.slice(0, slash); + if (isRepoId(head)) { + legacy = true; + } else { + authority = parseAuthority(head); + if (!authority || slash === -1) return undefined; + rest = rest.slice(slash + 1); + } + } + } + + const segments = rest.split("/"); + const repo = segments.shift() ?? ""; + if (!isRepoId(repo)) return undefined; + + let namespace: string | undefined; + if (segments.length > 0 && nodeIdPattern.test(segments[0])) { + namespace = segments.shift(); + if (!namespace || !isNodeId(namespace)) return undefined; + } + + const resource = parseResource(segments); + if (resource === null) return undefined; + if (legacy && (resource || query || fragment !== undefined)) { + return undefined; + } + + return { authority, repo, namespace, resource, query, fragment }; +} + +function formatResource(resource: RadResource): string { + switch (resource.type) { + case "commit": + case "tag": + return `/${resource.type}/${resource.ref}`; + case "tree": + case "blob": + return `/${resource.type}/${resource.oid}`; + case "cob": + return resource.oid === undefined + ? `/cob/${resource.typeName}` + : `/cob/${resource.typeName}/${resource.oid}`; + } +} + +export function formatRadUri( + uri: RadUri, + options: { forceAuthority?: boolean } = {}, +): string { + let result = "rad:"; + if (uri.authority) { + const { node, address } = uri.authority; + result += `//${node}${address === undefined ? "" : `@${address}`}/`; + } else if (options.forceAuthority) { + result += "///"; + } + result += uri.repo; + if (uri.namespace) result += `/${uri.namespace}`; + if (uri.resource) result += formatResource(uri.resource); + if (uri.query && uri.query.length > 0) { + result += `?${uri.query + .map(({ param, value }) => + value === undefined ? param : `${param}=${value}`, + ) + .join("&")}`; + } + if (uri.fragment !== undefined) result += `#${uri.fragment}`; + + return result; +} + +export function parseDid(input: string): string | undefined { + if (!input.startsWith("did:key:")) return undefined; + const node = input.slice(8); + + return isNodeId(node) ? node : undefined; +} + +export function formatDid(node: string): string { + return `did:key:${node}`; +} + +export function parseReference(input: string): RadReference | undefined { + const node = parseDid(input); + if (node) return { type: "did", node }; + const uri = parseRadUri(input); + + return uri ? { type: "uri", uri } : undefined; +} + +export function formatReference(reference: RadReference): string { + return reference.type === "did" + ? formatDid(reference.node) + : formatRadUri(reference.uri); +} + +function queryPath(uri: RadUri): string | undefined { + return uri.query?.find( + ({ param, value }) => + (param === "path" || param === "tree" || param === "blob") && + value !== undefined && + value !== "", + )?.value; +} + +function explorerRevision(ref: string): string | undefined { + if (!ref.startsWith("refs/")) return ref; + for (const prefix of ["refs/heads/", "refs/tags/"]) { + if (ref.startsWith(prefix)) return ref.slice(prefix.length); + } + + return undefined; +} + +function explorerResourcePath(uri: RadUri): string | undefined { + const { resource } = uri; + const remote = uri.namespace ? `/remotes/${uri.namespace}` : ""; + if (!resource) return remote; + + switch (resource.type) { + case "commit": + case "tag": { + const revision = explorerRevision(resource.ref); + if (revision === undefined) return undefined; + const path = queryPath(uri); + if (path !== undefined) return `${remote}/tree/${revision}/${path}`; + if (resource.type === "commit" && isOid(revision)) { + return `/commits/${revision}`; + } + return `${remote}/tree/${revision}`; + } + case "tree": + case "blob": + return undefined; + case "cob": { + const kind = explorerCobPaths[resource.typeName]; + if (!kind) return undefined; + return resource.oid === undefined + ? `/${kind}` + : `/${kind}/${resource.oid}`; + } + } +} + +export function explorerUrl( + reference: RadReference, + base: string, + host: string, +): string | undefined { + const node = `${base.replace(/\/+$/, "")}/nodes/${host}`; + if (reference.type === "did") { + return `${node}/users/${formatDid(reference.node)}`; + } + + const path = explorerResourcePath(reference.uri); + if (path === undefined) return undefined; + + return `${node}/rad:${reference.uri.repo}${path}`; +} + +function decodeSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +export function parseExplorerUrl(href: string): RadReference | undefined { + let url: URL; + try { + url = new URL(href); + } catch { + return undefined; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return undefined; + + const segments = url.pathname + .split("/") + .filter(segment => segment !== "") + .map(decodeSegment); + + const users = segments.indexOf("users"); + if (users !== -1 && users === segments.length - 2) { + const node = parseDid(segments[users + 1]); + return node ? { type: "did", node } : undefined; + } + + const index = segments.findIndex(segment => segment.startsWith("rad:")); + if (index === -1) return undefined; + + const repo = segments[index].slice(4); + if (!isRepoId(repo)) return undefined; + + let tail = segments.slice(index + 1); + let namespace: string | undefined; + if (tail[0] === "remotes") { + namespace = tail[1]; + if (namespace === undefined || !isNodeId(namespace)) return undefined; + tail = tail.slice(2); + } + + const [kind, ...rest] = tail; + if (kind === undefined || (kind === "tree" && rest.length === 0)) { + return { type: "uri", uri: { repo, namespace } }; + } + + if (kind === "tree") { + const [revision, ...path] = rest; + if (isOid(revision)) { + return { + type: "uri", + uri: { + repo, + namespace, + resource: { type: "commit", ref: revision.toLowerCase() }, + query: + path.length > 0 + ? [{ param: "path", value: path.join("/") }] + : undefined, + }, + }; + } + if (path.length > 0 || !gitRefPattern.test(revision)) return undefined; + return { + type: "uri", + uri: { repo, namespace, resource: { type: "commit", ref: revision } }, + }; + } + + const [raw, ...extra] = rest; + if ((extra.length > 0 && kind !== "patches") || !raw || !isOid(raw)) { + return undefined; + } + const oid = raw.toLowerCase(); + + if (kind === "commits") { + return { + type: "uri", + uri: { repo, resource: { type: "commit", ref: oid } }, + }; + } + + const typeName = Object.keys(explorerCobPaths).find( + key => explorerCobPaths[key] === kind, + ); + if (!typeName) return undefined; + + return { + type: "uri", + uri: { repo, resource: { type: "cob", typeName, oid } }, + }; +} diff --git a/tests/unit/radUri.test.ts b/tests/unit/radUri.test.ts new file mode 100644 index 000000000..c1863cc6c --- /dev/null +++ b/tests/unit/radUri.test.ts @@ -0,0 +1,489 @@ +import { describe, expect, test } from "vitest"; + +import type { RadUri } from "@app/lib/radUri"; +import { + explorerUrl, + formatRadUri, + issueType, + parseExplorerUrl, + parseRadUri, + parseReference, + patchType, + releaseType, +} from "@app/lib/radUri"; + +// The RIP 4 fixture `zk1arLZ…` decodes to 21 bytes, so like the +// `radicle-uri` crate these cases use `z21arLZ…`, one character apart. +const repo00 = "z3gqcJUbbbbbbbbbbbbbbbCSGazv5"; +const repo01 = "z21arLZbbbbbbbbbbbbbbb26us45E"; +const node01 = "z6MksFbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbS9wzpT"; +const node02 = "z6Mku8bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbo9XVAx"; +const seed = "seed.example.com:8776"; +const oid = "e9cf5263ffffffffffffffffffffffffffffffff"; + +describe("RIP 4 examples", () => { + const cases: [string, string, RadUri][] = [ + ["tc00a", `rad:${repo00}`, { repo: repo00 }], + ["tc00b", `rad://${repo00}`, { repo: repo00 }], + ["tc00c", `rad:///${repo00}`, { repo: repo00 }], + ["tc01a", `rad:${repo01}/${node01}`, { repo: repo01, namespace: node01 }], + ["tc01b", `rad://${repo01}/${node01}`, { repo: repo01, namespace: node01 }], + [ + "tc01c", + `rad:///${repo01}/${node01}`, + { repo: repo01, namespace: node01 }, + ], + [ + "tc02", + `rad://${node01}/${repo01}`, + { authority: { node: node01 }, repo: repo01 }, + ], + [ + "tc03", + `rad://${node01}/${repo01}/${node01}`, + { authority: { node: node01 }, repo: repo01, namespace: node01 }, + ], + [ + "tc04", + `rad://${node01}@${seed}/${repo01}`, + { authority: { node: node01, address: seed }, repo: repo01 }, + ], + [ + "tc05", + `rad://${node01}@${seed}/${repo01}/${node02}`, + { + authority: { node: node01, address: seed }, + repo: repo01, + namespace: node02, + }, + ], + [ + "tc10a", + `rad:${repo00}/commit/72db6dffffffffffffffffffffffffffffffffff`, + { + repo: repo00, + resource: { + type: "commit", + ref: "72db6dffffffffffffffffffffffffffffffffff", + }, + }, + ], + [ + "tc10b", + `rad:///${repo00}/commit/72db6dffffffffffffffffffffffffffffffffff`, + { + repo: repo00, + resource: { + type: "commit", + ref: "72db6dffffffffffffffffffffffffffffffffff", + }, + }, + ], + [ + "tc12", + `rad://${node01}/${repo01}/commit/${oid}`, + { + authority: { node: node01 }, + repo: repo01, + resource: { type: "commit", ref: oid }, + }, + ], + [ + "tc13", + `rad://${node01}@${seed}/${repo01}/${node02}/commit/72db6dffffffffffffffffffffffffffffffffff`, + { + authority: { node: node01, address: seed }, + repo: repo01, + namespace: node02, + resource: { + type: "commit", + ref: "72db6dffffffffffffffffffffffffffffffffff", + }, + }, + ], + [ + "tc30", + `rad:${repo01}/cob/org.example/${oid}`, + { + repo: repo01, + resource: { type: "cob", typeName: "org.example", oid }, + }, + ], + [ + "tc31", + `rad://${node01}/${repo01}/cob/org.example/${oid}`, + { + authority: { node: node01 }, + repo: repo01, + resource: { type: "cob", typeName: "org.example", oid }, + }, + ], + [ + "tc32", + `rad:${repo01}/cob/org.example`, + { repo: repo01, resource: { type: "cob", typeName: "org.example" } }, + ], + [ + "tc40a", + `rad:${repo01}/tree/3eb47e9fffffffffffffffffffffffffffffffff?path=src`, + { + repo: repo01, + resource: { + type: "tree", + oid: "3eb47e9fffffffffffffffffffffffffffffffff", + }, + query: [{ param: "path", value: "src" }], + }, + ], + [ + "tc41a", + `rad:${repo01}/commit/master?blob=README.md`, + { + repo: repo01, + resource: { type: "commit", ref: "master" }, + query: [{ param: "blob", value: "README.md" }], + }, + ], + [ + "tc42a", + `rad:${repo01}/commit/baz?path=foo/doc`, + { + repo: repo01, + resource: { type: "commit", ref: "baz" }, + query: [{ param: "path", value: "foo/doc" }], + }, + ], + [ + "tc43a", + `rad:${repo01}/commit/baz/foo?path=doc&path=src`, + { + repo: repo01, + resource: { type: "commit", ref: "baz/foo" }, + query: [ + { param: "path", value: "doc" }, + { param: "path", value: "src" }, + ], + }, + ], + [ + "tc44", + `rad:///${repo01}/commit/refs/notes/commit?blob=${oid}`, + { + repo: repo01, + resource: { type: "commit", ref: "refs/notes/commit" }, + query: [{ param: "blob", value: oid }], + }, + ], + ]; + + test.each(cases)("%s parses", (_, input, expected) => { + expect(parseRadUri(input)).toEqual(expected); + }); +}); + +describe("parseRadUri", () => { + test("is case-insensitive in the scheme and resource type", () => { + expect(parseRadUri(`RAD:${repo00}/COMMIT/master`)).toEqual({ + repo: repo00, + resource: { type: "commit", ref: "master" }, + }); + }); + + test.each([ + [ + `cob/${issueType}/${oid.toUpperCase()}`, + { type: "cob", typeName: issueType, oid }, + ], + [`tree/${oid.toUpperCase()}`, { type: "tree", oid }], + [`commit/${oid.toUpperCase()}`, { type: "commit", ref: oid }], + ])("lowercases the oid in %s", (resource, expected) => { + expect(parseRadUri(`rad:${repo00}/${resource}`)?.resource).toEqual( + expected, + ); + }); + + test("parses an IPv6 authority", () => { + expect(parseRadUri(`rad://${node01}@[::1]:8776/${repo00}`)).toEqual({ + authority: { node: node01, address: "[::1]:8776" }, + repo: repo00, + }); + }); + + test("parses a namespace followed by a resource", () => { + expect(parseRadUri(`rad:${repo00}/${node01}/commit/master`)).toEqual({ + repo: repo00, + namespace: node01, + resource: { type: "commit", ref: "master" }, + }); + }); + + test("reads a question mark after the fragment as part of it", () => { + expect(parseRadUri(`rad:${repo00}#a?b`)).toEqual({ + repo: repo00, + fragment: "a?b", + }); + }); +}); + +describe("rejects", () => { + test.each([ + ["no scheme", repo00], + ["another scheme", `https:${repo00}`], + ["no repo", "rad:"], + ["empty authority without a repo", "rad:///"], + ["an absolute path without authority", `rad:/${repo00}`], + ["a non-base58 repo id", "rad:z3gqcJUbbbbbbbbbbbbbbbCSGazv0"], + ["a repo id of the wrong length", "rad:zk1arLZbbbbbbbbbbbbbbb26us45E"], + ["an abbreviated tree oid", `rad:${repo00}/tree/abc123`], + ["a blob that is not an oid", `rad:${repo00}/blob/not-a-sha1`], + ["an abbreviated cob oid", `rad:${repo00}/cob/${issueType}/e9cf5263`], + ["an unknown resource type", `rad:${repo00}/issues/${oid}`], + [ + "an invalid authority node", + `rad://z6Mk11111111111111111111111111111111111111111111/${repo00}`, + ], + [ + "an invalid namespace", + `rad:${repo00}/z6Mk11111111111111111111111111111111111111111111`, + ], + ["an address without a port", `rad://${node01}@seed.example.com/${repo00}`], + ["trailing input", `rad:${repo00}/commit/master trailing`], + ["a trailing slash", `rad:${repo00}/`], + ["a resource on the legacy form", `rad://${repo00}/commit/master`], + ["a query on the legacy form", `rad://${repo00}?x`], + ["an invalid host", `rad://${node01}@seed example.com:8776/${repo00}`], + ["an authority without a repo", `rad://${node01}`], + ["invalid query characters", `rad:${repo00}?a b`], + ["invalid fragment characters", `rad:${repo00}#a b`], + ["a tree with extra segments", `rad:${repo00}/tree/${oid}/src`], + ["a cob with extra segments", `rad:${repo00}/cob/${issueType}/${oid}/x`], + ["a cob type without a dot", `rad:${repo00}/cob/issue/${oid}`], + ])("%s", (_, input) => { + expect(parseRadUri(input)).toBeUndefined(); + }); +}); + +describe("formatRadUri", () => { + test.each([ + `rad:${repo00}`, + `rad:${repo00}/${node01}`, + `rad://${node01}/${repo00}`, + `rad://${node01}@${seed}/${repo00}/${node02}/commit/master?path=src#L10`, + `rad:${repo00}/tag/v1.0`, + `rad:${repo00}/blob/${oid}`, + `rad:${repo00}/cob/${patchType}/${oid}`, + `rad:${repo00}?raw&path=src`, + `rad:${repo00}/commit/master#section-1`, + `rad:${repo00}?`, + ])("round-trips %s", input => { + const uri = parseRadUri(input); + expect(uri).toBeDefined(); + expect(formatRadUri(uri!)).toBe(input); + }); + + test("drops an empty authority", () => { + expect(formatRadUri(parseRadUri(`rad:///${repo00}/commit/master`)!)).toBe( + `rad:${repo00}/commit/master`, + ); + }); + + test("writes an empty authority when forced", () => { + expect(formatRadUri({ repo: repo00 }, { forceAuthority: true })).toBe( + `rad:///${repo00}`, + ); + }); +}); + +describe("parseReference", () => { + test("parses a DID", () => { + expect(parseReference(`did:key:${node01}`)).toEqual({ + type: "did", + node: node01, + }); + }); + + test("rejects a DID of another method", () => { + expect(parseReference(`did:web:${node01}`)).toBeUndefined(); + }); + + test("rejects an invalid DID", () => { + expect( + parseReference( + "did:key:z6Mk11111111111111111111111111111111111111111111", + ), + ).toBeUndefined(); + }); +}); + +describe("explorer URLs", () => { + const template = "https://app.radicle.xyz/"; + const host = "seed.radicle.xyz"; + const base = `https://app.radicle.xyz/nodes/${host}`; + + test.each([ + [`rad:${repo00}`, `${base}/rad:${repo00}`], + [ + `rad:${repo00}/cob/${issueType}/${oid}`, + `${base}/rad:${repo00}/issues/${oid}`, + ], + [ + `rad:${repo00}/cob/${patchType}/${oid}`, + `${base}/rad:${repo00}/patches/${oid}`, + ], + [ + `rad:${repo00}/cob/${releaseType}/${oid}`, + `${base}/rad:${repo00}/releases/${oid}`, + ], + [`rad:${repo00}/commit/${oid}`, `${base}/rad:${repo00}/commits/${oid}`], + [`rad:${repo00}/${node01}`, `${base}/rad:${repo00}/remotes/${node01}`], + [`did:key:${node01}`, `${base}/users/did:key:${node01}`], + ])("maps %s both ways", (reference, url) => { + const parsed = parseReference(reference)!; + expect(explorerUrl(parsed, template, host)).toBe(url); + expect(parseExplorerUrl(url)).toEqual(parsed); + }); + + test("maps a branch to its tree", () => { + expect( + explorerUrl( + parseReference(`rad:${repo00}/commit/master`)!, + template, + host, + ), + ).toBe(`${base}/rad:${repo00}/tree/master`); + }); + + test.each([ + [`commit/master?path=src/lib`, `/tree/master/src/lib`], + [`commit/${oid}?blob=README.md`, `/tree/${oid}/README.md`], + [`tag/v1.0?tree=docs`, `/tree/v1.0/docs`], + [`${node01}/commit/master`, `/remotes/${node01}/tree/master`], + [`${node01}/commit/${oid}`, `/commits/${oid}`], + ])("maps %s", (resource, path) => { + expect( + explorerUrl(parseReference(`rad:${repo00}/${resource}`)!, template, host), + ).toBe(`${base}/rad:${repo00}${path}`); + }); + + test("has no page for an unknown cob type", () => { + expect( + explorerUrl( + parseReference(`rad:${repo00}/cob/org.example/${oid}`)!, + template, + host, + ), + ).toBeUndefined(); + }); + + test("decodes percent-encoded ids", () => { + expect(parseExplorerUrl(`${base}/users/did%3Akey%3A${node01}`)).toEqual({ + type: "did", + node: node01, + }); + }); + + test("rejects an abbreviated oid", () => { + expect( + parseExplorerUrl(`${base}/rad:${repo00}/issues/e9cf5263`), + ).toBeUndefined(); + }); +}); + +describe("explorer URL edge cases", () => { + const base = "https://app.radicle.xyz"; + const node = `${base}/nodes/seed.radicle.xyz`; + + test.each([ + [`tree/${oid}`, undefined], + [`blob/${oid}`, undefined], + ["commit/master?path=", `${node}/rad:${repo00}/tree/master`], + [`cob/${issueType}`, `${node}/rad:${repo00}/issues`], + ["commit/refs/heads/main", `${node}/rad:${repo00}/tree/main`], + ["tag/refs/tags/v1.0", `${node}/rad:${repo00}/tree/v1.0`], + ["commit/refs/notes/commit", undefined], + ])("maps %s", (resource, url) => { + expect( + explorerUrl( + parseReference(`rad:${repo00}/${resource}`)!, + base, + "seed.radicle.xyz", + ), + ).toBe(url); + }); + + test.each([ + ["a non-web scheme", `ftp://app.radicle.xyz/nodes/h/rad:${repo00}`], + ["an invalid repo id", `${node}/rad:z3gqcJUbbbbbbbbbbbbbbbCSGazv0`], + ["extra segments", `${node}/rad:${repo00}/issues/${oid}/x`], + ["an unknown section", `${node}/rad:${repo00}/wiki/${oid}`], + ["users before the end", `${node}/users/did:key:${node01}/x`], + ["a branch followed by a path", `${node}/rad:${repo00}/tree/feature/x`], + ["a history page", `${node}/rad:${repo00}/history/main`], + ["an issue with extra segments", `${node}/rad:${repo00}/issues/${oid}/x`], + [ + "an invalid remote", + `${node}/rad:${repo00}/remotes/z6Mk11111111111111111111111111111111111111111111`, + ], + ])("parseExplorerUrl rejects %s", (_, url) => { + expect(parseExplorerUrl(url)).toBeUndefined(); + }); + + test("parseExplorerUrl lowercases the oid", () => { + expect( + parseExplorerUrl(`${node}/rad:${repo00}/commits/${oid.toUpperCase()}`), + ).toEqual({ + type: "uri", + uri: { repo: repo00, resource: { type: "commit", ref: oid } }, + }); + }); + + test.each([ + [ + "a patch revision", + `${node}/rad:${repo00}/patches/${oid}/${oid}?tab=changes`, + { repo: repo00, resource: { type: "cob", typeName: patchType, oid } }, + ], + [ + "an issue under a remote", + `${node}/rad:${repo00}/remotes/${node01}/issues/${oid}`, + { repo: repo00, resource: { type: "cob", typeName: issueType, oid } }, + ], + [ + "a remote's tree", + `${node}/rad:${repo00}/remotes/${node01}/tree`, + { repo: repo00, namespace: node01 }, + ], + [ + "a branch", + `${node}/rad:${repo00}/tree/main`, + { repo: repo00, resource: { type: "commit", ref: "main" } }, + ], + [ + "a remote's branch", + `${node}/rad:${repo00}/remotes/${node01}/tree/main`, + { + repo: repo00, + namespace: node01, + resource: { type: "commit", ref: "main" }, + }, + ], + [ + "a path at a commit", + `${node}/rad:${repo00}/tree/${oid}/src/lib`, + { + repo: repo00, + resource: { type: "commit", ref: oid }, + query: [{ param: "path", value: "src/lib" }], + }, + ], + ])("parseExplorerUrl reads %s", (_, url, uri) => { + expect(parseExplorerUrl(url)).toEqual({ type: "uri", uri }); + }); + + test("maps a path at a commit both ways", () => { + const url = `${node}/rad:${repo00}/tree/${oid}/src/lib`; + expect(explorerUrl(parseExplorerUrl(url)!, base, "seed.radicle.xyz")).toBe( + url, + ); + }); +});