import type { StringFormat } from "../schema.ts"; const LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/; const labels = (parts: readonly string[]) => parts.every((part) => part.length <= 63 && LABEL.test(part)); export function isNsid(value: string): boolean { const parts = value.split("."); return value.length <= 317 && parts.length >= 3 && labels(parts) && /^[A-Za-z]/.test(parts[0]) && /^[A-Za-z][A-Za-z0-9]*$/.test(parts.at(-1) ?? ""); } function isDid(value: string): boolean { return value.length <= 2048 && /^did:[a-z]+:(?:[A-Za-z0-9._:-]|%[0-9A-Fa-f]{2})*(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})$/.test( value, ); } function isHandle(value: string): boolean { const parts = value.split("."); return value.length <= 253 && parts.length >= 2 && labels(parts) && /^[A-Za-z]/.test(parts.at(-1) ?? ""); } function isAtUri(value: string): boolean { if (value.length > 8192) return false; const match = /^at:\/\/([^/?#]+)(?:\/([^/?#]+)(?:\/([^/?#]+))?)?$/.exec(value); if (!match) return false; const [, authority, collection, key] = match; return (isDid(authority) || isHandle(authority)) && (collection === undefined || isNsid(collection)) && (key === undefined || (key.length <= 512 && key !== "." && key !== ".." && /^[A-Za-z0-9_~.:-]+$/.test(key))); } function isUri(value: string): boolean { if (value.length > 8192 || /%(?![0-9A-Fa-f]{2})/.test(value)) return false; const match = /^[A-Za-z][A-Za-z0-9+.-]*:(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/.exec( value, ); if (!match) return false; const [, authority, path, query = "", fragment = ""] = match; if ( !/^[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/.test(path) || !/^[A-Za-z0-9\-._~!$&'()*+,;=:@%/?]*$/.test(query) || !/^[A-Za-z0-9\-._~!$&'()*+,;=:@%/?]*$/.test(fragment) ) return false; if (authority === undefined) return true; const host = /^(?:[A-Za-z0-9\-._~!$&'()*+,;=:%]*@)?(\[[^\]]+\]|[A-Za-z0-9\-._~!$&'()*+,;=%]*)(?::[0-9]*)?$/ .exec(authority)?.[1]; if (host === undefined) return false; if (!host.startsWith("[")) return true; if (/^\[v[0-9a-f]+\.[A-Za-z0-9\-._~!$&'()*+,;=:]+\]$/i.test(host)) return true; try { new URL(`http://${host}/`); return true; } catch { return false; } } function isDatetime(value: string): boolean { const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$/ .exec(value); if (!match) return false; const [, y, m, d, h, min, s, zone] = match; const year = Number(y), month = Number(m), day = Number(d); const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; if ( month < 1 || month > 12 || day < 1 || day > days[month - 1] || Number(h) > 23 || Number(min) > 59 || Number(s) > 59 || zone === "-00:00" ) return false; if (zone !== "Z") { const hours = Number(zone.slice(1, 3)), minutes = Number(zone.slice(4)); if (hours > 23 || minutes > 59) return false; // Offsets must not move a year-zero date into the preceding year. if ( year === 0 && month === 1 && day === 1 && zone[0] === "+" && Number(h) * 60 + Number(min) < hours * 60 + minutes ) return false; } return true; } function isLanguage(value: string): boolean { // BCP 47 grandfathered tags cannot all be represented by the modern grammar. if ( /^(?:en-GB-oed|i-(?:ami|bnn|default|enochian|hak|klingon|lux|mingo|navajo|pwn|tao|tay|tsu)|sgn-(?:BE-FR|BE-NL|CH-DE)|art-lojban|cel-gaulish|no-bok|no-nyn|zh-(?:guoyu|hakka|min|min-nan|xiang))$/i .test(value) ) return true; if (/^x(?:-[a-z0-9]{1,8})+$/i.test(value)) return true; const match = /^([a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4}|[a-z]{5,8})(?:-[a-z]{4})?(?:-(?:[a-z]{2}|[0-9]{3}))?((?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*)((?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*)(?:-x(?:-[a-z0-9]{1,8})+)?$/i .exec(value); if (!match) return false; const [, , variantTags, extensionTags] = match; const variants = variantTags.toLowerCase().split("-").filter(Boolean); const extensions = [...extensionTags.toLowerCase().matchAll(/-([0-9a-wy-z])(?=-)/g)].map((m) => m[1] ); return new Set(variants).size === variants.length && new Set(extensions).size === extensions.length; } export function cidCodec(value: string): 0x55 | 0x71 | undefined { // AT Protocol's blessed form: base32 CIDv1, raw/dag-cbor, SHA-256. if (value.length !== 59 || value[0] !== "b") return; const bytes: number[] = []; let bits = 0, accumulator = 0; for (const char of value.slice(1)) { const digit = "abcdefghijklmnopqrstuvwxyz234567".indexOf(char); if (digit < 0) return; accumulator = (accumulator << 5) | digit; bits += 5; if (bits >= 8) { bits -= 8; bytes.push((accumulator >> bits) & 255); accumulator &= (1 << bits) - 1; } } if ( accumulator === 0 && bytes[0] === 1 && (bytes[1] === 0x55 || bytes[1] === 0x71) && bytes[2] === 0x12 && bytes[3] === 32 ) return bytes[1]; } export function base64Length(value: string): number | undefined { if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?$/.test(value)) return; const raw = value.replace(/=+$/, ""); const last = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".indexOf( raw.at(-1) ?? "A", ); const mask = raw.length % 4 === 2 ? 15 : raw.length % 4 === 3 ? 3 : 0; if ((last & mask) !== 0) return; return Math.floor(raw.length * 6 / 8); } export function matchesFormat(format: StringFormat, value: string): boolean { switch (format) { case "did": return isDid(value); case "handle": return isHandle(value); case "at-identifier": return isDid(value) || isHandle(value); case "at-uri": return isAtUri(value); case "datetime": return isDatetime(value); case "nsid": return isNsid(value); case "tid": return /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/.test(value); case "cid": return cidCodec(value) !== undefined; case "language": return isLanguage(value); case "uri": return isUri(value); } }