mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
Add a library for RIP 4 rad: URIs
The desktop and radicle-explorer render the same comment text, so they have to agree on what a reference means.
This commit is contained in:
@@ -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<string, string> = {
|
||||
[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 } },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user