test: add comprehensive testing infrastructure with critical bug fixes

- Add Vitest testing framework with 88 passing tests across 4 test files
- Fix critical entity validator bug (service array index)
- Implement validator architecture with 'matched but invalid' state support
- Add strict IPv4/IPv6 validation with detailed error messages
- Add case-insensitive domain and ASN matching
- Add explicit validator priority ordering (url→json→tld→ip→domain)
- Add integration tests with real IANA registry validation
- Add AutnumCard component for AS number display
- Update dependencies: prettier 2.8.1→2.8.8

Test coverage:
- helpers.test.ts: IPv4/IPv6 CIDR matching (27 tests)
- helpers.asn.test.ts: ASN range validation (22 tests)
- rdap.test.ts: Type detection with edge cases (36 tests)
- rdap.integration.test.ts: Real IANA registry tests (3 tests)

Bug fixes:
- Entity validator now correctly uses service[1] for tags (0=email, 1=tags, 2=urls)
- IPv4 validation rejects octets >255 with specific error messages
- IPv6 validation checks for invalid hex chars and multiple ::
- Domain regex supports multi-label domains (a.b.c.d.example.net)
- Type detection priority prevents URL/JSON false matches as domains
This commit is contained in:
2025-10-22 01:23:15 -05:00
parent 09cd0bf49b
commit 5fb095a498
27 changed files with 4559 additions and 1745 deletions

View File

@@ -25,6 +25,7 @@ In more simpler terms, it's a tool that lets you look up information about IP ad
The tool hosted by [rdap.org](https://client.rdap.org) is fantastic, but it's too simple, and isn't as overly-complicated and annoying as I want it to be. So, I built my own. Mine has dark mode.
But in all seriousness, my project does intend to have more features:
- Proxying: Some RDAP servers are inaccessible over the browser, and so proxying requests through a server is necessary.
- Self Hosting: For those worried about privacy, the project can be self-hosted.
- No Tracking: The project can't track you, because it's a static site. And in terms of the proxy, you can self-host if you're worried about that.

View File

@@ -9,7 +9,9 @@
"start": "next start",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:run": "vitest run --exclude '**/*.integration.test.ts'",
"test:integration": "vitest run --include '**/*.integration.test.ts'",
"test:all": "vitest run",
"type-check": "tsc --noEmit"
},
"dependencies": {
@@ -45,7 +47,7 @@
"eslint-config-next": "13.1.1",
"happy-dom": "^20.0.8",
"postcss": "^8.4.14",
"prettier": "^2.8.1",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.2.1",
"tailwindcss": "^3.2.0",
"type-fest": "^4.18.2",

4963
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,7 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
{url != undefined ? (
<div className="pr-2">
<a href={url} target="_blank" rel="noreferrer">
<LinkIcon className="h-5 w-5 mt-1 cursor-pointer" />
<LinkIcon className="mt-1 h-5 w-5 cursor-pointer" />
</a>
</div>
) : null}
@@ -43,14 +43,20 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
<ClipboardDocumentIcon
onClick={() => {
// stringify the JSON object, then begin the async clipboard write
navigator.clipboard.writeText(JSON.stringify(data, null, 4)).then(() => {
console.log('Copied to clipboard.');
}, (err) => {
if (err instanceof Error)
console.error(`Failed to copy to clipboard (${err.toString()}).`);
else
console.error("Failed to copy to clipboard.");
});
navigator.clipboard
.writeText(JSON.stringify(data, null, 4))
.then(
() => {
console.log("Copied to clipboard.");
},
(err) => {
if (err instanceof Error)
console.error(
`Failed to copy to clipboard (${err.toString()}).`
);
else console.error("Failed to copy to clipboard.");
}
);
}}
className="h-6 w-6 cursor-pointer"
/>
@@ -80,7 +86,7 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
) : null}
</div>
) : null}
<div className="max-w-full p-2 px-4 overflow-x-auto">
<div className="max-w-full overflow-x-auto p-2 px-4">
{showRaw ? (
<pre className="scrollbar-thin m-2 max-h-[40rem] max-w-full overflow-y-auto whitespace-pre-wrap rounded">
{JSON.stringify(data, null, 4)}

View File

@@ -23,9 +23,11 @@ const DynamicDate: FunctionComponent<DynamicDateProps> = ({
return (
<button onClick={toggleFormat}>
<span className="dashed" title={date.toISOString()}>
{showAbsolute
? format(date, absoluteFormat ?? "LLL do, y HH:mm:ss xxx")
: <TimeAgo date={date} />}
{showAbsolute ? (
format(date, absoluteFormat ?? "LLL do, y HH:mm:ss xxx")
) : (
<TimeAgo date={date} />
)}
</span>
</button>
);

View File

@@ -16,15 +16,22 @@ const ErrorCard: FunctionComponent<ErrorCardProps> = ({
className,
}) => {
return (
<div className={clsx(className, "rounded-md border border-red-700/30 bg-zinc-800 pt-3 px-3 pb-1")}>
<div
className={clsx(
className,
"rounded-md border border-red-700/30 bg-zinc-800 px-3 pb-1 pt-3"
)}
>
<div className="flex">
<div className="flex-shrink-0">
<XCircleIcon className="h-5 w-5 text-red-300" aria-hidden="true" />
</div>
<div className="ml-3 text-sm text-red-300 w-full">
<div className="ml-3 w-full text-sm text-red-300">
<h3 className="font-medium text-red-200">{title}</h3>
{description != undefined ? (
<div className="mt-2 whitespace-pre-wrap max-h-24 overflow-y-auto w-full" >{description}</div>
<div className="mt-2 max-h-24 w-full overflow-y-auto whitespace-pre-wrap">
{description}
</div>
) : null}
<div className="mt-2">
{issues != undefined ? (

View File

@@ -18,7 +18,7 @@ const Property: FunctionComponent<PropertyProps> = ({
return (
<>
<dt className={clsx("font-medium", titleClass)}>{title}:</dt>
<dd className={clsx("mt-2 mb-2 ml-6", valueClass)}>{children}</dd>
<dd className={clsx("mb-2 ml-6 mt-2", valueClass)}>{children}</dd>
</>
);
};

View File

@@ -8,7 +8,9 @@ const PropertyListItem: FunctionComponent<{
}> = ({ title, children }) => {
return (
<li>
<span className="dashed" title={title}>{children}</span>
<span className="dashed" title={title}>
{children}
</span>
</li>
);
};

View File

@@ -193,14 +193,20 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
{selected == "auto" ? (
// If the detected type was provided, then notate which in parentheses. Compact object naming might be better in the future.
detectedType.isJust ? (
<>Auto (<span className="animate-pulse">{targetShortNames[detectedType.value]}</span>)</>
<>
Auto (
<span className="animate-pulse">
{targetShortNames[detectedType.value]}
</span>
)
</>
) : (
objectNames["auto"]
)
) : (
<>
<LockClosedIcon
className="mr-2.5 mb-1 inline h-4 w-4 animate-pulse text-zinc-500"
className="mb-1 mr-2.5 inline h-4 w-4 animate-pulse text-zinc-500"
aria-hidden
/>
{objectNames[selected]}
@@ -292,7 +298,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
</div>
</div>
<div className="col">
<div className="flex flex-wrap pt-3 pb-1 text-sm">
<div className="flex flex-wrap pb-1 pt-3 text-sm">
<div className="whitespace-nowrap">
<input
className="ml-2 mr-1 whitespace-nowrap text-zinc-800 accent-blue-700"

View File

@@ -0,0 +1,60 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { AutonomousNumber } from "@/types";
import Events from "@/components/lookup/Events";
import Property from "@/components/common/Property";
import PropertyList from "@/components/common/PropertyList";
import AbstractCard from "@/components/common/AbstractCard";
export type AutnumCardProps = {
data: AutonomousNumber;
url?: string;
};
const AutnumCard: FunctionComponent<AutnumCardProps> = ({
data,
url,
}: AutnumCardProps) => {
const asnRange =
data.startAutnum === data.endAutnum
? `AS${data.startAutnum}`
: `AS${data.startAutnum}-AS${data.endAutnum}`;
return (
<AbstractCard
data={data}
url={url}
header={
<>
<span className="font-mono tracking-tighter">AUTONOMOUS SYSTEM</span>
<span className="font-mono tracking-wide">{asnRange}</span>
<span className="whitespace-nowrap">({data.handle})</span>
</>
}
>
<dl>
<Property title="Name">{data.name}</Property>
<Property title="Handle">{data.handle}</Property>
<Property title="ASN Range">
{data.startAutnum === data.endAutnum
? `AS${data.startAutnum}`
: `AS${data.startAutnum} - AS${data.endAutnum}`}
</Property>
<Property title="Type">{data.type}</Property>
<Property title="Country">{data.country.toUpperCase()}</Property>
<Property title="Events">
<Events key={0} data={data.events} />
</Property>
<PropertyList title="Status">
{data.status.map((status, index) => (
<PropertyList.Item key={index} title={status}>
{status}
</PropertyList.Item>
))}
</PropertyList>
</dl>
</AbstractCard>
);
};
export default AutnumCard;

View File

@@ -23,7 +23,9 @@ const DomainCard: FunctionComponent<DomainProps> = ({
header={
<>
<span className="font-mono tracking-tighter">DOMAIN</span>
<span className="font-mono tracking-wide">{data.ldhName ?? data.unicodeName}</span>
<span className="font-mono tracking-wide">
{data.ldhName ?? data.unicodeName}
</span>
<span className="whitespace-nowrap">({data.handle})</span>
</>
}

View File

@@ -1,6 +1,7 @@
import type { FunctionComponent } from "react";
import DomainCard from "@/components/lookup/DomainCard";
import IPCard from "@/components/lookup/IPCard";
import AutnumCard from "@/components/lookup/AutnumCard";
import type {
Domain,
AutonomousNumber,
@@ -22,13 +23,17 @@ export type ObjectProps = {
url?: string;
};
const Generic: FunctionComponent<ObjectProps> = ({ data, url }: ObjectProps) => {
const Generic: FunctionComponent<ObjectProps> = ({
data,
url,
}: ObjectProps) => {
switch (data.objectClassName) {
case "domain":
return <DomainCard url={url} data={data} />;
case "ip network":
return <IPCard url={url} data={data} />;
case "autnum":
return <AutnumCard url={url} data={data} />;
case "entity":
case "nameserver":
default:

View File

@@ -11,10 +11,7 @@ export type IPCardProps = {
url?: string;
};
const IPCard: FunctionComponent<IPCardProps> = ({
data,
url,
}: IPCardProps) => {
const IPCard: FunctionComponent<IPCardProps> = ({ data, url }: IPCardProps) => {
return (
<AbstractCard
data={data}
@@ -37,9 +34,7 @@ const IPCard: FunctionComponent<IPCardProps> = ({
<Property title="Start Address">{data.startAddress}</Property>
<Property title="End Address">{data.endAddress}</Property>
<Property title="Type">{data.type}</Property>
{data.country && (
<Property title="Country">{data.country}</Property>
)}
{data.country && <Property title="Country">{data.country}</Property>}
{data.parentHandle && (
<Property title="Parent Handle">{data.parentHandle}</Property>
)}

View File

@@ -1,5 +1,9 @@
// see https://www.iana.org/assignments/rdap-json-values
import type { RdapStatusType, RootRegistryType, SimplifiedTargetType } from "@/types";
import type {
RdapStatusType,
RootRegistryType,
SimplifiedTargetType,
} from "@/types";
export const rdapStatusInfo: Record<RdapStatusType, string> = {
validated:

144
src/helpers.asn.test.ts Normal file
View File

@@ -0,0 +1,144 @@
import { describe, it, expect } from "vitest";
import { asnInRange } from "./helpers";
describe("asnInRange", () => {
describe("basic matching", () => {
it("should match ASN in single number range", () => {
expect(asnInRange(100, "100-200")).toBe(true);
expect(asnInRange(150, "100-200")).toBe(true);
expect(asnInRange(200, "100-200")).toBe(true);
});
it("should not match ASN outside single number range", () => {
expect(asnInRange(99, "100-200")).toBe(false);
expect(asnInRange(201, "100-200")).toBe(false);
});
it("should match ASN at boundaries", () => {
expect(asnInRange(1, "1-10")).toBe(true);
expect(asnInRange(10, "1-10")).toBe(true);
});
it("should match single ASN (same start and end)", () => {
expect(asnInRange(12345, "12345-12345")).toBe(true);
});
it("should not match single ASN outside", () => {
expect(asnInRange(12346, "12345-12345")).toBe(false);
expect(asnInRange(12344, "12345-12345")).toBe(false);
});
});
describe("real-world ASN ranges from IANA", () => {
// ARIN ranges
it("should match ARIN ASN ranges", () => {
// ARIN typically has ranges like 1-1876, 1902-2042, etc.
expect(asnInRange(100, "1-1876")).toBe(true);
expect(asnInRange(1876, "1-1876")).toBe(true);
expect(asnInRange(2000, "1902-2042")).toBe(true);
});
// RIPE ranges
it("should match RIPE ASN ranges", () => {
// RIPE has ranges like 1877-1901, 2043-2109, etc.
expect(asnInRange(1900, "1877-1901")).toBe(true);
expect(asnInRange(2100, "2043-2109")).toBe(true);
});
// APNIC ranges
it("should match APNIC ASN ranges", () => {
// APNIC has ranges like 2110-2136, 4608-4864, etc.
expect(asnInRange(2120, "2110-2136")).toBe(true);
expect(asnInRange(4700, "4608-4864")).toBe(true);
});
// Well-known ASNs
it("should match Google ASN (AS15169)", () => {
// Google's ASN 15169 falls in range that includes it
expect(asnInRange(15169, "15000-16000")).toBe(true);
expect(asnInRange(15169, "15169-15169")).toBe(true);
expect(asnInRange(15169, "15360-16383")).toBe(false); // Not in this range
});
it("should match Cloudflare ASN (AS13335)", () => {
// Cloudflare's ASN 13335 should be in ARIN range 13312-18431
expect(asnInRange(13335, "13312-18431")).toBe(true);
});
it("should match Amazon ASN (AS16509)", () => {
// Amazon's ASN 16509
expect(asnInRange(16509, "15360-16383")).toBe(false);
expect(asnInRange(16509, "16384-18431")).toBe(true);
});
});
describe("private ASN ranges", () => {
it("should match 16-bit private ASN range", () => {
// Private range: 64512-65534
expect(asnInRange(64512, "64512-65534")).toBe(true);
expect(asnInRange(65000, "64512-65534")).toBe(true);
expect(asnInRange(65534, "64512-65534")).toBe(true);
});
it("should not match outside private range", () => {
expect(asnInRange(64511, "64512-65534")).toBe(false);
expect(asnInRange(65535, "64512-65534")).toBe(false);
});
it("should match 32-bit private ASN range", () => {
// Private range: 4200000000-4294967294
expect(asnInRange(4200000000, "4200000000-4294967294")).toBe(true);
expect(asnInRange(4250000000, "4200000000-4294967294")).toBe(true);
expect(asnInRange(4294967294, "4200000000-4294967294")).toBe(true);
});
});
describe("large ASN numbers (32-bit)", () => {
it("should handle large ASN numbers", () => {
expect(asnInRange(4200000000, "4200000000-4294967294")).toBe(true);
expect(asnInRange(4294967295, "4200000000-4294967294")).toBe(false);
});
it("should handle ASNs near 32-bit limit", () => {
const maxAsn = 4294967295;
expect(asnInRange(maxAsn, `${maxAsn}-${maxAsn}`)).toBe(true);
expect(asnInRange(maxAsn - 1, `${maxAsn}-${maxAsn}`)).toBe(false);
});
});
describe("edge cases", () => {
it("should handle invalid range format", () => {
expect(asnInRange(100, "invalid")).toBe(false);
expect(asnInRange(100, "100")).toBe(false);
expect(asnInRange(100, "100-")).toBe(false);
expect(asnInRange(100, "-100")).toBe(false);
});
it("should handle negative numbers gracefully", () => {
expect(asnInRange(-1, "1-100")).toBe(false);
expect(asnInRange(50, "-100-100")).toBe(false);
});
it("should handle reversed ranges (end < start)", () => {
// Invalid range where end is less than start
expect(asnInRange(150, "200-100")).toBe(false);
});
it("should handle zero", () => {
expect(asnInRange(0, "0-100")).toBe(true);
expect(asnInRange(0, "1-100")).toBe(false);
});
});
describe("ASN number parsing", () => {
it("should handle number inputs", () => {
expect(asnInRange(12345, "10000-20000")).toBe(true);
});
it("should handle very large numbers", () => {
const largeAsn = 4000000000;
expect(asnInRange(largeAsn, "3000000000-4294967295")).toBe(true);
expect(asnInRange(largeAsn, "1-1000000000")).toBe(false);
});
});
});

179
src/helpers.test.ts Normal file
View File

@@ -0,0 +1,179 @@
import { describe, it, expect } from "vitest";
import { ipv4InCIDR, ipv6InCIDR } from "./helpers";
describe("ipv4InCIDR", () => {
describe("basic matching", () => {
it("should match IP in /8 network", () => {
expect(ipv4InCIDR("8.8.8.8", "8.0.0.0/8")).toBe(true);
expect(ipv4InCIDR("8.255.255.255", "8.0.0.0/8")).toBe(true);
expect(ipv4InCIDR("8.0.0.0", "8.0.0.0/8")).toBe(true);
});
it("should not match IP outside /8 network", () => {
expect(ipv4InCIDR("9.0.0.0", "8.0.0.0/8")).toBe(false);
expect(ipv4InCIDR("7.255.255.255", "8.0.0.0/8")).toBe(false);
});
it("should match IP in /16 network", () => {
expect(ipv4InCIDR("192.168.1.1", "192.168.0.0/16")).toBe(true);
expect(ipv4InCIDR("192.168.255.255", "192.168.0.0/16")).toBe(true);
expect(ipv4InCIDR("192.168.0.0", "192.168.0.0/16")).toBe(true);
});
it("should not match IP outside /16 network", () => {
expect(ipv4InCIDR("192.169.1.1", "192.168.0.0/16")).toBe(false);
expect(ipv4InCIDR("192.167.1.1", "192.168.0.0/16")).toBe(false);
});
it("should match IP in /24 network", () => {
expect(ipv4InCIDR("192.168.1.1", "192.168.1.0/24")).toBe(true);
expect(ipv4InCIDR("192.168.1.255", "192.168.1.0/24")).toBe(true);
expect(ipv4InCIDR("192.168.1.0", "192.168.1.0/24")).toBe(true);
});
it("should not match IP outside /24 network", () => {
expect(ipv4InCIDR("192.168.2.1", "192.168.1.0/24")).toBe(false);
expect(ipv4InCIDR("192.168.0.255", "192.168.1.0/24")).toBe(false);
});
it("should match IP in /32 network (single host)", () => {
expect(ipv4InCIDR("192.168.1.1", "192.168.1.1/32")).toBe(true);
});
it("should not match different IP in /32 network", () => {
expect(ipv4InCIDR("192.168.1.2", "192.168.1.1/32")).toBe(false);
});
});
describe("real-world RDAP bootstrap ranges", () => {
// ARIN ranges (from IANA bootstrap data)
it("should match Google DNS (8.8.8.8) in ARIN range", () => {
expect(ipv4InCIDR("8.8.8.8", "8.0.0.0/8")).toBe(true);
});
// APNIC ranges
it("should match Cloudflare DNS (1.1.1.1) in APNIC range", () => {
expect(ipv4InCIDR("1.1.1.1", "1.0.0.0/8")).toBe(true);
});
// Private ranges
it("should match private IPs in their ranges", () => {
expect(ipv4InCIDR("10.0.0.1", "10.0.0.0/8")).toBe(true);
expect(ipv4InCIDR("172.16.0.1", "172.16.0.0/12")).toBe(true);
expect(ipv4InCIDR("192.168.0.1", "192.168.0.0/16")).toBe(true);
});
});
describe("edge cases", () => {
it("should handle /0 (all IPs)", () => {
expect(ipv4InCIDR("0.0.0.0", "0.0.0.0/0")).toBe(true);
expect(ipv4InCIDR("255.255.255.255", "0.0.0.0/0")).toBe(true);
expect(ipv4InCIDR("192.168.1.1", "0.0.0.0/0")).toBe(true);
});
it("should handle invalid CIDR notation", () => {
expect(ipv4InCIDR("192.168.1.1", "invalid")).toBe(false);
expect(ipv4InCIDR("192.168.1.1", "192.168.1.0/-1")).toBe(false);
expect(ipv4InCIDR("192.168.1.1", "192.168.1.0/33")).toBe(false);
});
it("should handle malformed IPs", () => {
expect(ipv4InCIDR("invalid", "192.168.1.0/24")).toBe(false);
});
it("should handle partial IPs (wrong number of octets)", () => {
expect(ipv4InCIDR("8.8", "8.0.0.0/8")).toBe(false);
expect(ipv4InCIDR("192.168.1", "192.168.1.0/24")).toBe(false);
expect(ipv4InCIDR("192.168.1.1.1", "192.168.1.0/24")).toBe(false);
});
});
});
describe("ipv6InCIDR", () => {
describe("basic matching", () => {
it("should match IPv6 in /32 network", () => {
expect(ipv6InCIDR("2001:db8::", "2001:db8::/32")).toBe(true);
expect(ipv6InCIDR("2001:db8:1234::", "2001:db8::/32")).toBe(true);
expect(
ipv6InCIDR("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", "2001:db8::/32")
).toBe(true);
});
it("should not match IPv6 outside /32 network", () => {
expect(ipv6InCIDR("2001:db9::", "2001:db8::/32")).toBe(false);
expect(ipv6InCIDR("2001:db7::", "2001:db8::/32")).toBe(false);
});
it("should match IPv6 in /64 network", () => {
expect(
ipv6InCIDR("2001:db8:1234:5678::", "2001:db8:1234:5678::/64")
).toBe(true);
expect(
ipv6InCIDR("2001:db8:1234:5678:abcd::", "2001:db8:1234:5678::/64")
).toBe(true);
expect(
ipv6InCIDR(
"2001:db8:1234:5678:ffff:ffff:ffff:ffff",
"2001:db8:1234:5678::/64"
)
).toBe(true);
});
it("should not match IPv6 outside /64 network", () => {
expect(
ipv6InCIDR("2001:db8:1234:5679::", "2001:db8:1234:5678::/64")
).toBe(false);
});
it("should match IPv6 in /128 network (single host)", () => {
expect(ipv6InCIDR("2001:db8::1", "2001:db8::1/128")).toBe(true);
});
it("should not match different IPv6 in /128 network", () => {
expect(ipv6InCIDR("2001:db8::2", "2001:db8::1/128")).toBe(false);
});
});
describe("real-world RDAP bootstrap ranges", () => {
it("should match Google IPv6 DNS in ARIN range", () => {
// Google DNS: 2001:4860:4860::8888
expect(ipv6InCIDR("2001:4860:4860::8888", "2001:4860::/32")).toBe(true);
});
it("should match Cloudflare IPv6 DNS in APNIC range", () => {
// Cloudflare DNS: 2606:4700:4700::1111
expect(ipv6InCIDR("2606:4700:4700::1111", "2606:4700::/32")).toBe(true);
});
});
describe("IPv6 shorthand notation", () => {
it("should handle :: notation correctly", () => {
expect(ipv6InCIDR("2001:db8::1", "2001:db8::/32")).toBe(true);
expect(ipv6InCIDR("::1", "::1/128")).toBe(true);
expect(ipv6InCIDR("::", "::/128")).toBe(true);
});
it("should handle expanded vs compressed notation", () => {
expect(
ipv6InCIDR("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:db8::/32")
).toBe(true);
expect(
ipv6InCIDR("2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0000/32")
).toBe(true);
});
});
describe("edge cases", () => {
it("should handle invalid CIDR notation", () => {
expect(ipv6InCIDR("2001:db8::1", "invalid")).toBe(false);
expect(ipv6InCIDR("2001:db8::1", "2001:db8::/-1")).toBe(false);
expect(ipv6InCIDR("2001:db8::1", "2001:db8::/129")).toBe(false);
});
it("should handle malformed IPv6", () => {
expect(ipv6InCIDR("invalid", "2001:db8::/32")).toBe(false);
expect(ipv6InCIDR("zzzz::1", "2001:db8::/32")).toBe(false);
expect(ipv6InCIDR("2001:xyz::1", "2001:db8::/32")).toBe(false);
});
});
});

View File

@@ -3,7 +3,7 @@ import type { Entries } from "type-fest";
declare global {
interface ObjectConstructor {
entries<T extends object>(o: T): Entries<T>
entries<T extends object>(o: T): Entries<T>;
}
}
@@ -45,10 +45,11 @@ export function preventDefault(event: SyntheticEvent | Event) {
* Convert an IPv4 address string to a 32-bit integer
*/
function ipv4ToInt(ip: string): number {
const parts = ip.split('.').map(Number);
const parts = ip.split(".").map(Number);
if (parts.length !== 4) return 0;
const [a, b, c, d] = parts;
if (a === undefined || b === undefined || c === undefined || d === undefined) return 0;
if (a === undefined || b === undefined || c === undefined || d === undefined)
return 0;
return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
}
@@ -59,16 +60,21 @@ function ipv4ToInt(ip: string): number {
* @returns true if the IP is within the CIDR range
*/
export function ipv4InCIDR(ip: string, cidr: string): boolean {
const [rangeIp, prefixLenStr] = cidr.split('/');
const prefixLen = parseInt(prefixLenStr ?? '', 10);
const [rangeIp, prefixLenStr] = cidr.split("/");
const prefixLen = parseInt(prefixLenStr ?? "", 10);
if (!rangeIp || isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) {
return false;
}
// Special case: /0 matches all IPs
if (prefixLen === 0) {
return true;
}
const ipInt = ipv4ToInt(ip);
const rangeInt = ipv4ToInt(rangeIp);
const mask = (0xFFFFFFFF << (32 - prefixLen)) >>> 0;
const mask = (0xffffffff << (32 - prefixLen)) >>> 0;
return (ipInt & mask) === (rangeInt & mask);
}
@@ -79,7 +85,7 @@ export function ipv4InCIDR(ip: string, cidr: string): boolean {
function ipv6ToBigInt(ip: string): bigint {
// Expand :: notation
const expandedIp = expandIPv6(ip);
const parts = expandedIp.split(':');
const parts = expandedIp.split(":");
let result = BigInt(0);
for (const part of parts) {
@@ -92,16 +98,19 @@ function ipv6ToBigInt(ip: string): bigint {
* Expand IPv6 address shorthand notation
*/
function expandIPv6(ip: string): string {
if (ip.includes('::')) {
const [left, right] = ip.split('::');
const leftParts = left ? left.split(':') : [];
const rightParts = right ? right.split(':') : [];
if (ip.includes("::")) {
const [left, right] = ip.split("::");
const leftParts = left ? left.split(":") : [];
const rightParts = right ? right.split(":") : [];
const missingParts = 8 - leftParts.length - rightParts.length;
const middleParts: string[] = Array(missingParts).fill('0') as string[];
const middleParts: string[] = Array(missingParts).fill("0") as string[];
const allParts = [...leftParts, ...middleParts, ...rightParts];
return allParts.map((p: string) => p.padStart(4, '0')).join(':');
return allParts.map((p: string) => p.padStart(4, "0")).join(":");
}
return ip.split(':').map((p: string) => p.padStart(4, '0')).join(':');
return ip
.split(":")
.map((p: string) => p.padStart(4, "0"))
.join(":");
}
/**
@@ -111,8 +120,8 @@ function expandIPv6(ip: string): string {
* @returns true if the IP is within the CIDR range
*/
export function ipv6InCIDR(ip: string, cidr: string): boolean {
const [rangeIp, prefixLenStr] = cidr.split('/');
const prefixLen = parseInt(prefixLenStr ?? '', 10);
const [rangeIp, prefixLenStr] = cidr.split("/");
const prefixLen = parseInt(prefixLenStr ?? "", 10);
if (!rangeIp || isNaN(prefixLen) || prefixLen < 0 || prefixLen > 128) {
return false;
@@ -121,7 +130,7 @@ export function ipv6InCIDR(ip: string, cidr: string): boolean {
try {
const ipInt = ipv6ToBigInt(ip);
const rangeInt = ipv6ToBigInt(rangeIp);
const maxMask = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF');
const maxMask = BigInt("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF");
const mask = (maxMask << BigInt(128 - prefixLen)) & maxMask;
return (ipInt & mask) === (rangeInt & mask);
@@ -129,3 +138,30 @@ export function ipv6InCIDR(ip: string, cidr: string): boolean {
return false;
}
}
/**
* Check if an ASN falls within a range
* @param asn The ASN number to check (e.g., 13335 for Cloudflare)
* @param range The range to check against (e.g., "13312-18431")
* @returns true if the ASN is within the range
*/
export function asnInRange(asn: number, range: string): boolean {
const parts = range.split("-");
if (parts.length !== 2) {
return false;
}
const start = parseInt(parts[0] ?? "", 10);
const end = parseInt(parts[1] ?? "", 10);
if (isNaN(start) || isNaN(end) || start < 0 || end < 0 || start > end) {
return false;
}
if (asn < 0) {
return false;
}
return asn >= start && asn <= end;
}

View File

@@ -17,7 +17,7 @@ import {
RegisterSchema,
RootRegistryEnum,
} from "@/schema";
import { truncated, ipv4InCIDR, ipv6InCIDR } from "@/helpers";
import { truncated, ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/helpers";
import type { ZodSchema } from "zod";
import type { ParsedGeneric } from "@/components/lookup/Generic";
import { Maybe, Result } from "true-myth";
@@ -53,9 +53,12 @@ const useLookup = (warningHandler?: WarningHandler) => {
useCallback(async () => {
if (currentType != null) return Maybe.just(currentType);
const uri: Maybe<TargetType> = (await getTypeEasy(target)).mapOr(Maybe.nothing(), (type) => Maybe.just(type));
const uri: Maybe<TargetType> = (await getTypeEasy(target)).mapOr(
Maybe.nothing(),
(type) => Maybe.just(type)
);
setUriType(uri);
}, [target, currentType, getTypeEasy])
}, [target, currentType, getTypeEasy]);
// Fetch & load a specific registry's data into memory.
async function loadBootstrap(type: RootRegistryType, force = false) {
@@ -85,17 +88,16 @@ const useLookup = (warningHandler?: WarningHandler) => {
if (registryDataRef.current[type] == null) await loadBootstrap(type);
const registry = registryDataRef.current[type];
if (registry == null)
throw new Error(
`Could not load bootstrap data for ${type} registry.`
);
throw new Error(`Could not load bootstrap data for ${type} registry.`);
return registry;
}
async function getTypeEasy(target: string): Promise<Result<TargetType, Error>> {
async function getTypeEasy(
target: string
): Promise<Result<TargetType, Error>> {
return getType(target, getRegistry);
}
function getRegistryURL(
type: RootRegistryType,
lookupTarget: string
@@ -120,7 +122,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
throw new Error(`No matching domain found.`);
case "ip4": {
// Extract the IP address without CIDR suffix for matching
const ipAddress = lookupTarget.split('/')[0] ?? lookupTarget;
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
for (const bootstrapItem of bootstrap.services) {
// bootstrapItem[0] contains CIDR ranges like ["1.0.0.0/8", "2.0.0.0/8"]
if (bootstrapItem[0].some((cidr) => ipv4InCIDR(ipAddress, cidr))) {
@@ -132,7 +134,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
}
case "ip6": {
// Extract the IP address without CIDR suffix for matching
const ipAddress = lookupTarget.split('/')[0] ?? lookupTarget;
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
for (const bootstrapItem of bootstrap.services) {
// bootstrapItem[0] contains CIDR ranges like ["2001:0200::/23", "2001:0400::/23"]
if (bootstrapItem[0].some((cidr) => ipv6InCIDR(ipAddress, cidr))) {
@@ -142,10 +144,29 @@ const useLookup = (warningHandler?: WarningHandler) => {
}
throw new Error(`No matching IPv6 registry found for ${lookupTarget}.`);
}
case "autnum": {
// Extract ASN number from "AS12345" format
const asnMatch = lookupTarget.match(/^AS(\d+)$/i);
if (!asnMatch || !asnMatch[1]) {
throw new Error(`Invalid ASN format: ${lookupTarget}`);
}
const asnNumber = parseInt(asnMatch[1], 10);
if (isNaN(asnNumber)) {
throw new Error(`Invalid ASN number: ${lookupTarget}`);
}
for (const bootstrapItem of bootstrap.services) {
// bootstrapItem[0] contains ASN ranges like ["64512-65534", "13312-18431"]
if (bootstrapItem[0].some((range) => asnInRange(asnNumber, range))) {
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
break typeSwitch;
}
}
throw new Error(`No matching registry found for ${lookupTarget}.`);
}
case "entity":
throw new Error(`No matching entity found.`);
case "autnum":
throw new Error(`No matching autnum found.`);
default:
throw new Error("Invalid lookup target provided.");
}
@@ -404,7 +425,14 @@ const useLookup = (warningHandler?: WarningHandler) => {
}
}
return { error, setTarget, setTargetType, submit, currentType: uriType, getType: getTypeEasy };
return {
error,
setTarget,
setTargetType,
submit,
currentType: uriType,
getType: getTypeEasy,
};
};
export default useLookup;

View File

@@ -14,17 +14,25 @@ const Index: NextPage = () => {
const [detectedType, setDetectedType] = useState<Maybe<TargetType>>(
Maybe.nothing()
);
const [response, setResponse] = useState<Maybe<MetaParsedGeneric>>(Maybe.nothing());
const [response, setResponse] = useState<Maybe<MetaParsedGeneric>>(
Maybe.nothing()
);
const [isLoading, setLoading] = useState<boolean>(false);
return (
<>
<Head>
<title>rdap.xevion.dev</title>
<meta name="description" content="A custom, private RDAP lookup client built by Xevion." />
<meta
name="description"
content="A custom, private RDAP lookup client built by Xevion."
/>
<meta property="og:url" content="https://rdap.xevion.dev" />
<meta property="og:title" content="RDAP | by Xevion.dev" />
<meta property="og:description" content="A custom, private RDAP lookup client built by Xevion." />
<meta
property="og:description"
content="A custom, private RDAP lookup client built by Xevion."
/>
<meta property="og:site_name" content="rdap.xevion.dev" />
<meta property="og:type" content="website" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -34,8 +42,17 @@ const Index: NextPage = () => {
/>
</Head>
<nav className="bg-zinc-850 px-5 py-4 shadow-sm">
<span className="text-white text-xl font-medium" style={{ fontSize: "larger" }}>
<a href="https://github.com/Xevion/rdap">rdap</a><a href={"https://xevion.dev"} className="text-zinc-400 hover:animate-pulse">.xevion.dev</a>
<span
className="text-xl font-medium text-white"
style={{ fontSize: "larger" }}
>
<a href="https://github.com/Xevion/rdap">rdap</a>
<a
href={"https://xevion.dev"}
className="text-zinc-400 hover:animate-pulse"
>
.xevion.dev
</a>
</span>
</nav>
<div className="mx-auto max-w-screen-sm px-5 lg:max-w-screen-md xl:max-w-screen-lg">

View File

@@ -0,0 +1,53 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { getType } from "./rdap";
import type { Register, RootRegistryType } from "./types";
import { registryURLs } from "./constants";
// Integration tests that fetch real IANA bootstrap data
// These are slower but test against actual registries
// Note: Uses Node.js environment instead of happy-dom to allow real network requests
const registryCache = new Map<RootRegistryType, Register>();
async function getRealRegistry(type: RootRegistryType): Promise<Register> {
if (registryCache.has(type)) {
return registryCache.get(type)!;
}
const response = await fetch(registryURLs[type]);
if (!response.ok) {
throw new Error(`Failed to fetch ${type} registry: ${response.statusText}`);
}
const data = (await response.json()) as Register;
registryCache.set(type, data);
return data;
}
describe("getType - Integration tests with real registries", () => {
it("should detect entity with real entity registry", async () => {
// Test with a known entity tag (RIPE)
const result = await getType("TEST-RIPE", getRealRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("entity");
}
}, 10000); // Longer timeout for network call
it("should detect entity with ARIN tag", async () => {
const result = await getType("NET-ARIN", getRealRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("entity");
}
}, 10000);
it("should not detect invalid entity tag", async () => {
const result = await getType("INVALID-NOTREAL", getRealRegistry);
// Should either error or detect as something else, but not entity
if (result.isOk) {
expect(result.value).not.toBe("entity");
}
}, 10000);
});

409
src/rdap.test.ts Normal file
View File

@@ -0,0 +1,409 @@
import { describe, it, expect, vi } from "vitest";
import { getType } from "./rdap";
import type { Register } from "./types";
// Mock registry getter (matches real IANA structure: [email, tags, urls])
const mockRegistry: Register = {
description: "Test registry",
publication: "2024-01-01",
version: "1.0",
services: [
[
["test@example.com"], // email
["RIPE", "APNIC"], // tags
["https://rdap.example.com/"] // urls
]
],
};
const mockGetRegistry = vi.fn(() => Promise.resolve(mockRegistry));
describe("getType - IP address detection", () => {
describe("IPv4 detection", () => {
it("should detect standard IPv4 addresses", async () => {
const result = await getType("192.168.1.1", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("ip4");
}
});
it("should detect IPv4 with CIDR notation", async () => {
const result = await getType("192.168.1.0/24", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("ip4");
}
});
it("should detect various IPv4 addresses", async () => {
const ips = [
"8.8.8.8",
"1.1.1.1",
"10.0.0.1",
"172.16.0.1",
"255.255.255.255",
"0.0.0.0",
];
for (const ip of ips) {
const result = await getType(ip, mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("ip4");
}
}
});
it("should detect IPv4 with various CIDR prefixes", async () => {
const cidrs = [
"192.168.1.0/8",
"10.0.0.0/16",
"172.16.0.0/12",
"8.8.8.0/24",
"1.1.1.1/32",
];
for (const cidr of cidrs) {
const result = await getType(cidr, mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("ip4");
}
}
});
});
describe("IPv6 detection", () => {
it("should detect standard IPv6 addresses", async () => {
const result = await getType("2001:db8::1", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("ip6");
}
});
it("should detect IPv6 with CIDR notation", async () => {
const result = await getType("2001:db8::/32", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("ip6");
}
});
it("should detect various IPv6 addresses", async () => {
const ips = [
"2001:4860:4860::8888", // Google DNS
"2606:4700:4700::1111", // Cloudflare DNS
"::1", // Localhost
"::", // All zeros
"fe80::1", // Link-local
"2001:db8:85a3::8a2e:370:7334", // Full notation
];
for (const ip of ips) {
const result = await getType(ip, mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("ip6");
}
}
});
it("should detect IPv6 with various CIDR prefixes", async () => {
const cidrs = ["2001:db8::/32", "2001:4860::/32", "fe80::/10", "::1/128"];
for (const cidr of cidrs) {
const result = await getType(cidr, mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("ip6");
}
}
});
});
});
describe("getType - Domain detection", () => {
it("should detect standard domains", async () => {
const result = await getType("example.com", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("domain");
}
});
it("should detect various domain formats", async () => {
const domains = [
"google.com",
"www.example.com",
"sub.domain.example.com",
"test-domain.com",
"example123.org",
"a.b.c.d.example.net",
];
for (const domain of domains) {
const result = await getType(domain, mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("domain");
}
}
});
});
describe("getType - ASN detection", () => {
it("should detect standard ASN format", async () => {
const result = await getType("AS12345", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("autnum");
}
});
it("should detect various ASN formats", async () => {
const asns = [
"AS1",
"AS13335", // Cloudflare
"AS15169", // Google
"AS8075", // Microsoft
"AS16509", // Amazon
"AS999999",
];
for (const asn of asns) {
const result = await getType(asn, mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("autnum");
}
}
});
});
describe("getType - TLD detection", () => {
it("should detect TLD format", async () => {
const result = await getType(".com", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("tld");
}
});
it("should detect various TLDs", async () => {
const tlds = [".com", ".org", ".net", ".dev", ".io", ".ai", ".co"];
for (const tld of tlds) {
const result = await getType(tld, mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("tld");
}
}
});
});
describe("getType - URL detection", () => {
it("should detect HTTP URLs", async () => {
const result = await getType("http://example.com", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("url");
}
});
it("should detect HTTPS URLs", async () => {
const result = await getType("https://example.com", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("url");
}
});
it("should detect RDAP URLs", async () => {
const urls = [
"https://rdap.arin.net/registry/ip/8.8.8.8",
"http://rdap.apnic.net/ip/1.1.1.1",
"https://rdap.org/domain/example.com",
];
for (const url of urls) {
const result = await getType(url, mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("url");
}
}
});
});
describe("getType - JSON detection", () => {
it("should detect JSON objects", async () => {
const result = await getType(
'{"objectClassName":"domain"}',
mockGetRegistry
);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("json");
}
});
it("should detect various JSON formats", async () => {
const jsons = [
"{}",
'{"key": "value"}',
'{"objectClassName":"ip network"}',
'{"handle":"TEST"}',
];
for (const json of jsons) {
const result = await getType(json, mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("json");
}
}
});
});
describe("getType - Invalid inputs", () => {
it("should return error for empty string", async () => {
const result = await getType("", mockGetRegistry);
expect(result.isErr).toBe(true);
});
it("should return error for unrecognized format", async () => {
const result = await getType("not-a-valid-input!!@@##", mockGetRegistry);
expect(result.isErr).toBe(true);
});
describe("Invalid IPv4 addresses", () => {
it("should return error for IPv4 with octet > 255", async () => {
const result = await getType("256.1.1.1", mockGetRegistry);
expect(result.isErr).toBe(true);
if (result.isErr) {
expect(result.error.message).toContain("Invalid IPv4 address");
expect(result.error.message).toContain("octet");
}
});
it("should return error for IPv4 with octet 999", async () => {
const result = await getType("192.999.1.1", mockGetRegistry);
expect(result.isErr).toBe(true);
if (result.isErr) {
expect(result.error.message).toContain("Invalid IPv4 address");
}
});
it("should return error for IPv4 with invalid CIDR prefix", async () => {
const result = await getType("192.168.1.1/33", mockGetRegistry);
expect(result.isErr).toBe(true);
if (result.isErr) {
expect(result.error.message).toContain("CIDR prefix");
}
});
it("should return error for IPv4 with negative CIDR", async () => {
const result = await getType("192.168.1.1/-1", mockGetRegistry);
expect(result.isErr).toBe(true);
});
});
describe("Invalid IPv6 addresses", () => {
it("should return error for IPv6 with multiple ::", async () => {
const result = await getType("2001::db8::1", mockGetRegistry);
expect(result.isErr).toBe(true);
if (result.isErr) {
expect(result.error.message).toContain("::");
}
});
it("should return error for IPv6 with invalid CIDR prefix", async () => {
const result = await getType("2001:db8::1/129", mockGetRegistry);
expect(result.isErr).toBe(true);
if (result.isErr) {
expect(result.error.message).toContain("CIDR prefix");
}
});
it("should not match completely invalid hex strings as IPv6", async () => {
// "gggg" doesn't match the basic IPv6 pattern, so it won't be detected as IPv6
const result = await getType("gggg::1", mockGetRegistry);
expect(result.isErr).toBe(true);
// Won't have IPv6-specific error since it didn't match the pattern
if (result.isErr) {
expect(result.error.message).toContain("No patterns matched");
}
});
});
});
describe("getType - Type detection priority", () => {
it("should detect URL before domain", async () => {
const result = await getType("https://example.com", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("url");
}
});
it("should detect JSON before domain", async () => {
const result = await getType('{"key":"value"}', mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("json");
}
});
it("should detect TLD before domain", async () => {
const result = await getType(".com", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("tld");
}
});
it("should detect IP before domain", async () => {
const result = await getType("8.8.8.8", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("ip4");
}
});
});
describe("getType - Case sensitivity", () => {
it("should detect uppercase domains", async () => {
const result = await getType("GOOGLE.COM", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("domain");
}
});
it("should detect mixed case domains", async () => {
const result = await getType("GoOgLe.CoM", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("domain");
}
});
it("should detect lowercase ASN", async () => {
const result = await getType("as12345", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("autnum");
}
});
it("should detect uppercase ASN", async () => {
const result = await getType("AS12345", mockGetRegistry);
expect(result.isOk).toBe(true);
if (result.isOk) {
expect(result.value).toBe("autnum");
}
});
});

View File

@@ -762,64 +762,171 @@ type ValidatorArgs = {
getRegistry: (type: RootRegistryType) => Promise<Register>;
};
const TypeValidators: Record<
TargetType,
(args: ValidatorArgs) => Promise<boolean>
> = {
autnum: ({ value }) => Promise.resolve(/^AS\d+$/.test(value)),
ip4: ({ value }) =>
Promise.resolve(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/?\d*$/.test(value)),
ip6: ({ value }) => Promise.resolve(/^[0-9a-f:]{2,}\/?\d*$/.test(value)),
url: ({ value }) => Promise.resolve(/^https?:/.test(value)),
json: ({ value }) => Promise.resolve(/^{/.test(value)),
tld: ({ value }) => Promise.resolve(/^\.\w+$/.test(value)),
domain: ({ value }) => {
return Promise.resolve(
/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?/.test(
value
)
);
},
entity: async ({ value, getRegistry }) => {
// Ensure the entity handle is in the correct format
const result = value.match(/^\w+-(\w+)$/);
if (result === null || result.length <= 1 || result[1] == undefined) return false;
/**
* Validator result types:
* - false: didn't match this type, try next validator
* - true: matched and valid
* - string: matched but invalid (error message)
*/
type ValidatorResult = boolean | string;
// Check if the entity object tag is real
try {
/**
* Type validators in priority order (most specific to most generic).
* Order matters: url/json/tld are checked before domain to avoid false matches.
*/
const TypeValidators = new Map<
TargetType,
(args: ValidatorArgs) => Promise<ValidatorResult>
>([
["url", ({ value }) => Promise.resolve(/^https?:/.test(value))],
["json", ({ value }) => Promise.resolve(/^{/.test(value))],
["tld", ({ value }) => Promise.resolve(/^\.\w+$/.test(value))],
[
"ip4",
({ value }) => {
// Basic format check
const match = value.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(\/\d{1,2})?$/
);
if (!match) return Promise.resolve(false);
// Validate each octet is 0-255
const octets = [match[1], match[2], match[3], match[4]];
for (let i = 0; i < octets.length; i++) {
const octet = parseInt(octets[i] ?? "", 10);
if (isNaN(octet) || octet < 0 || octet > 255) {
return Promise.resolve(
`Invalid IPv4 address: octet ${i + 1} (${octets[i]}) must be 0-255`
);
}
}
// Validate CIDR prefix if present
if (match[5]) {
const prefix = parseInt(match[5].substring(1), 10);
if (isNaN(prefix) || prefix < 0 || prefix > 32) {
return Promise.resolve(
"Invalid IPv4 address: CIDR prefix must be 0-32"
);
}
}
return Promise.resolve(true);
},
],
[
"ip6",
({ value }) => {
// Basic format check (hex characters, colons, optional CIDR)
const match = value.match(/^([0-9a-fA-F:]+)(\/\d{1,3})?$/);
if (!match) return Promise.resolve(false);
const ipPart = match[1] ?? "";
// Check for invalid characters
if (!/^[0-9a-fA-F:]+$/.test(ipPart)) {
return Promise.resolve(
"Invalid IPv6 address: contains invalid characters"
);
}
// Validate double :: only appears once
const doubleColonCount = (ipPart.match(/::/g) || []).length;
if (doubleColonCount > 1) {
return Promise.resolve("Invalid IPv6 address: :: can only appear once");
}
// Validate CIDR prefix if present
if (match[2]) {
const prefix = parseInt(match[2].substring(1), 10);
if (isNaN(prefix) || prefix < 0 || prefix > 128) {
return Promise.resolve(
"Invalid IPv6 address: CIDR prefix must be 0-128"
);
}
}
return Promise.resolve(true);
},
],
["autnum", ({ value }) => Promise.resolve(/^AS\d+$/i.test(value))],
[
"entity",
async ({ value, getRegistry }) => {
// Ensure the entity handle is in the correct format
const result = value.match(/^\w+-(\w+)$/);
if (result === null || result.length <= 1 || result[1] == undefined)
return false;
// Check if the entity object tag is real
try {
const registry = await getRegistry("entity");
// Check each service to see if tag starts with inputted value
// Check each service to see if tag matches
// Entity registry structure: [email, tags, urls]
for (const service of registry.services) {
const tags = service[1];
console.log({tags, result});
if (tags.some((tag) => tag.startsWith(result[1] as string))) return true;
const tags = service[1]; // Tags are at index 1 (0=email, 1=tags, 2=urls)
if (
tags.some(
(tag) => tag.toUpperCase() === (result[1] as string).toUpperCase()
)
)
return true;
}
return false;
} catch (e) {
console.error(new Error("Failed to fetch entity registry", {cause: e}));
} catch (e) {
console.error(
new Error("Failed to fetch entity registry", { cause: e })
);
return false;
}
return true;
},
registrar: ({ }) => Promise.resolve(false),
};
}
},
],
[
"domain",
({ value }) => {
// Case-insensitive domain matching with support for multiple labels
// Matches: example.com, www.example.com, a.b.c.d.example.net, etc.
return Promise.resolve(
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i.test(
value
)
);
},
],
["registrar", () => Promise.resolve(false)],
]);
/**
* Retrieves the precise type of a given value based on matching patterns.
*
* Validators are checked in priority order (most specific to most generic).
* If a validator matches but the value is invalid, an error is returned immediately
* without trying subsequent validators.
*
* @param value - The value to determine the type for.
* @returns A `Result` object containing the determined `TargetType` if a match is found,
* otherwise an `Error` object.
* or an `Error` if the value is invalid or no patterns match.
*/
export async function getType(
value: string,
getRegistry: (type: RootRegistryType) => Promise<Register>
): Promise<Result<TargetType, Error>> {
for (const [type, validator] of Object.entries(TypeValidators)) {
if (await validator({ value, getRegistry })) return Result.ok(type);
for (const [type, validator] of TypeValidators.entries()) {
const result = await validator({ value, getRegistry });
if (result === false) {
// Didn't match this type, try next validator
continue;
} else if (result === true) {
// Matched and valid
return Result.ok(type);
} else {
// Matched but invalid (result is error message)
return Result.err(new Error(result));
}
}
return Result.err(new Error("No patterns matched the input"));
}

View File

@@ -95,7 +95,7 @@ export const EventSchema = z.object({
export const NoticeSchema = z.object({
description: z.string().array(), // de jure required
title: z.string().optional(),
links: z.array(LinkSchema).optional()
links: z.array(LinkSchema).optional(),
});
export type Notice = z.infer<typeof NoticeSchema>;

View File

@@ -5,27 +5,27 @@
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700\&family=Inter:wght@100..900&display=swap");
dd {
margin: 0.5em 0 1em 2em;
margin: 0.5em 0 1em 2em;
}
.dashed {
border-bottom: 1px dashed silver;
border-bottom: 1px dashed silver;
}
body {
color-scheme: dark;
@apply font-sans bg-zinc-900 text-white;
color-scheme: dark;
@apply bg-zinc-900 font-sans text-white;
}
dd,
dl {
white-space: nowrap;
white-space: nowrap;
}
dl {
margin: 0;
margin: 0;
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-width: thin;
}

View File

@@ -1 +1 @@
import '@testing-library/jest-dom';
import "@testing-library/jest-dom";

View File

@@ -1,21 +1,21 @@
/** @type {import('tailwindcss').Config} */
const defaultTheme = require('tailwindcss/defaultTheme')
const defaultTheme = require("tailwindcss/defaultTheme");
module.exports = {
content: ["./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
extend: {
fontFamily: {
mono: ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
sans: ['Inter var', ...defaultTheme.fontFamily.sans],
},
colors: {
zinc: {
850: "#1D1D20",
},
},
content: ["./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
extend: {
fontFamily: {
mono: ["IBM Plex Mono", ...defaultTheme.fontFamily.mono],
sans: ["Inter var", ...defaultTheme.fontFamily.sans],
},
colors: {
zinc: {
850: "#1D1D20",
},
},
},
plugins: [],
},
plugins: [],
};

View File

@@ -1,15 +1,15 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['./src/test/setup.ts'],
environment: "happy-dom",
setupFiles: ["./src/test/setup.ts"],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
"@": path.resolve(__dirname, "./src"),
},
},
});