diff --git a/package.json b/package.json index 5a855cbd..a850d9bd 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "hosted-git-info": "^10.1.1", "libnpmdiff": "^9.0.2", "lucide-react": "^0.577.0", + "minimatch": "^10.2.6", "next": "~16.3.5", "npm-package-arg": "^14.0.0", "pacote": "^22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24dc3709..6705c7fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.3.0) + minimatch: + specifier: ^10.2.6 + version: 10.2.6 next: specifier: ~16.3.5 version: 16.3.5(@babel/core@7.29.7)(@types/node@24.13.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0)(react@19.3.0) diff --git a/src/app/[...parts]/_page/NpmDiff/ViewTypeSwitch.tsx b/src/app/[...parts]/_page/NpmDiff/ViewTypeSwitch.tsx index 7f1e5938..139db229 100644 --- a/src/app/[...parts]/_page/NpmDiff/ViewTypeSwitch.tsx +++ b/src/app/[...parts]/_page/NpmDiff/ViewTypeSwitch.tsx @@ -35,37 +35,35 @@ const ViewTypeButton = forwardRef< ...props }, ref, - ) => ( - - ), + + {children} + + + ); + }, ); ViewTypeButton.displayName = "ViewTypeButton"; diff --git a/src/app/[...parts]/page.tsx b/src/app/[...parts]/page.tsx index a4b43f78..0474da73 100644 --- a/src/app/[...parts]/page.tsx +++ b/src/app/[...parts]/page.tsx @@ -2,16 +2,23 @@ import { type Metadata } from "next"; import { notFound, redirect } from "next/navigation"; import { type JSX, Suspense } from "react"; import { type ViewType } from "react-diff-view"; +import Code from "^/components/ui/Code"; import { createSimplePackageSpec } from "^/lib/createSimplePackageSpec"; import { DEFAULT_DIFF_FILES_GLOB } from "^/lib/default-diff-files"; -import destination from "^/lib/destination"; -import { parseQuery, type QueryParams } from "^/lib/query"; +import destination, { + type Destination, + SpecNotFoundError, +} from "^/lib/destination"; +import { + parseDiffOptions, + type QueryParams, + toSearchString, +} from "^/lib/query"; import { simplePackageSpecToString } from "^/lib/SimplePackageSpec"; -import decodeParts from "^/lib/utils/decodeParts"; import { isCatalogPage } from "^/lib/utils/isCatalogPage"; +import parseParts from "^/lib/utils/parseParts"; import specsToDiff from "^/lib/utils/specsToDiff"; -import splitParts from "^/lib/utils/splitParts"; -import validateSpecs from "^/lib/utils/validateSpecs"; +import ErrorBox from "./_error/ErrorBox"; import BundlephobiaDiff from "./_page/BundlephobiaDiff"; import CatalogPage from "./_page/catalog/CatalogPage"; import { generateCatalogMetadata } from "./_page/catalog/generateCatalogMetadata"; @@ -32,9 +39,9 @@ export async function generateMetadata({ params, }: DiffPageProps): Promise { const { parts } = await params; - const specs = splitParts(decodeParts(parts)); + const specs = parseParts(parts, { decode: true }); - if (!validateSpecs(specs)) { + if (specs == null) { notFound(); } @@ -55,11 +62,12 @@ const DiffPageInner = async ({ searchParams, }: DiffPageProps): Promise => { const { parts } = await params; - const { diffFiles, ...optionsQuery } = await searchParams; + const query = await searchParams; + const { diffFiles, ...optionsQuery } = query; - const specsOrVersions = splitParts(decodeParts(parts)); + const specsOrVersions = parseParts(parts, { decode: true }); - if (!validateSpecs(specsOrVersions)) { + if (specsOrVersions == null) { notFound(); } @@ -67,25 +75,38 @@ const DiffPageInner = async ({ return ; } - const { redirect: redirectTarget, canonicalSpecs } = - await destination(specsOrVersions); + const parsedOptions = parseDiffOptions({ + // If no diffFiles is passed, use the default. + // This is done here, since we don't want a fall back in the API + diffFiles: diffFiles ?? DEFAULT_DIFF_FILES_GLOB, + ...optionsQuery, + }); - if (redirectTarget !== false) { - const specsStr = specsToDiff(canonicalSpecs); - const searchStr = Object.entries(await searchParams) - .map(([key, value]) => `${key}=${value}`) - .join("&"); - - redirect( - `/${specsStr}` + (searchStr?.length > 0 ? `?${searchStr}` : ""), + if (!parsedOptions.ok) { + return ( + +

Invalid options

+ {parsedOptions.message} +
); + } + + let target: Destination; + try { + target = await destination(specsOrVersions); + } catch (e) { + if (e instanceof SpecNotFoundError) { + notFound(); + } + throw e; + } + + const { redirect: redirectTarget, canonicalSpecs } = target; + + if (redirectTarget !== false) { + redirect(`/${specsToDiff(canonicalSpecs)}${toSearchString(query)}`); } else { - const options = parseQuery({ - // If no diffFiles is passed, use the default. - // This is done here, since we don't want a fall back in the API - diffFiles: diffFiles ?? DEFAULT_DIFF_FILES_GLOB, - ...optionsQuery, - }); + const { options } = parsedOptions; const [a, b] = canonicalSpecs.map((spec) => createSimplePackageSpec(spec), diff --git a/src/app/api/[...parts]/route.ts b/src/app/api/[...parts]/route.ts index 8bee7e31..8f4b9d0b 100644 --- a/src/app/api/[...parts]/route.ts +++ b/src/app/api/[...parts]/route.ts @@ -1,11 +1,13 @@ import { type NextRequest, NextResponse } from "next/server"; -import destination from "^/lib/destination"; +import destination, { + type Destination, + SpecNotFoundError, +} from "^/lib/destination"; import npmDiff from "^/lib/npmDiff"; -import { parseQuery } from "^/lib/query"; +import { fromSearchParams, parseDiffOptions } from "^/lib/query"; import { defaultPageCachingHeaders } from "^/lib/utils/headers"; +import parseParts from "^/lib/utils/parseParts"; import specsToDiff from "^/lib/utils/specsToDiff"; -import splitParts from "^/lib/utils/splitParts"; -import validateSpecs from "^/lib/utils/validateSpecs"; export const maxDuration = 60; @@ -21,19 +23,33 @@ export interface DiffApiContext { export async function GET(req: NextRequest, { params }: DiffApiContext) { const { parts } = await params; const { searchParams } = new URL(req.url); - const options = Object.fromEntries(searchParams); - const specsOrVersions = splitParts(parts); + const specsOrVersions = parseParts(parts); - if (!validateSpecs(specsOrVersions)) { + if (specsOrVersions == null) { return NextResponse.json("Invalid package spec", { status: 400 }); } - const { redirect: red, canonicalSpecs } = - await destination(specsOrVersions); + const parsedOptions = parseDiffOptions(fromSearchParams(searchParams)); + + if (!parsedOptions.ok) { + return NextResponse.json(parsedOptions.message, { status: 400 }); + } + + let target: Destination; + try { + target = await destination(specsOrVersions); + } catch (e) { + if (e instanceof SpecNotFoundError) { + return NextResponse.json(e.message, { status: 404 }); + } + throw e; + } + + const { redirect: red, canonicalSpecs } = target; if (red === false) { - const result = await npmDiff(canonicalSpecs, parseQuery(options)); + const result = await npmDiff(canonicalSpecs, parsedOptions.options); if (result.ok) { return new NextResponse(result.diff, { @@ -47,8 +63,8 @@ export async function GET(req: NextRequest, { params }: DiffApiContext) { } else { const newUrl = new URL(`/api/${specsToDiff(canonicalSpecs)}`, req.url); - Array.from(searchParams).forEach(([key, value]) => { - newUrl.searchParams.set(key, value); + searchParams.forEach((value, key) => { + newUrl.searchParams.append(key, value); }); return NextResponse.redirect( diff --git a/src/app/page.client.tsx b/src/app/page.client.tsx index 596e0d39..a0c62231 100644 --- a/src/app/page.client.tsx +++ b/src/app/page.client.tsx @@ -6,6 +6,7 @@ import { useBoolean } from "react-use"; import Stack from "^/components/ui/Stack"; import { type AutocompleteSuggestion } from "^/lib/autocomplete"; import { DEFAULT_DIFF_FILES_GLOB } from "^/lib/default-diff-files"; +import { toSearchString } from "^/lib/query/searchParams"; import ExamplesList from "./_page/ExamplesList"; import MainForm from "./_page/MainForm/MainForm"; import OptionsForm from "./_page/OptionsForm"; @@ -47,11 +48,7 @@ const IndexPageClient: FunctionComponent = ({ const goToDiff = (a: string | undefined, b: string | undefined): void => { setLoading(true); - router.push( - `/${a}...${b}?${Object.entries(query) - .map(([k, v]) => `${k}=${v}`) - .join("&")}`, - ); + router.push(`/${a}...${b}${toSearchString(query)}`); }; return ( diff --git a/src/lib/destination/SpecNotFoundError.ts b/src/lib/destination/SpecNotFoundError.ts new file mode 100644 index 00000000..df55ab48 --- /dev/null +++ b/src/lib/destination/SpecNotFoundError.ts @@ -0,0 +1,6 @@ +export default class SpecNotFoundError extends Error { + constructor(spec: string) { + super(`Could not find "${spec}"`); + this.name = "SpecNotFoundError"; + } +} diff --git a/src/lib/destination/canonicalSpec.test.ts b/src/lib/destination/canonicalSpec.test.ts index a5891d15..f3728e6a 100644 --- a/src/lib/destination/canonicalSpec.test.ts +++ b/src/lib/destination/canonicalSpec.test.ts @@ -1,6 +1,7 @@ import { manifest, resolve } from "pacote"; import type pacote from "pacote"; import canonicalSpec from "./canonicalSpec"; +import SpecNotFoundError from "./SpecNotFoundError"; // Increase the timeout for long running tests jest.setTimeout(15_000); @@ -199,4 +200,12 @@ describe("canonicalSpec", () => { expect(() => t("file:example")).rejects.toThrow(expectedError), ]); }); + + it("Throws SpecNotFoundError for a package missing from the registry", () => + expect(() => + t("npm-diff-app-does-not-exist-9f3a2c@latest"), + ).rejects.toThrow(SpecNotFoundError)); + + it("Throws SpecNotFoundError for a range with no matching version", () => + expect(() => t("chalk@^999.0.0")).rejects.toThrow(SpecNotFoundError)); }); diff --git a/src/lib/destination/canonicalSpec.ts b/src/lib/destination/canonicalSpec.ts index 60756164..9e8244d3 100644 --- a/src/lib/destination/canonicalSpec.ts +++ b/src/lib/destination/canonicalSpec.ts @@ -1,6 +1,8 @@ import { cacheLife } from "next/cache"; import npa, { type AliasResult } from "npm-package-arg"; import { manifest, resolve } from "pacote"; +import { hasErrorCode } from "^/lib/utils/hasErrorCode"; +import SpecNotFoundError from "./SpecNotFoundError"; const hashFinder = /(?:\#.*)?$/; @@ -58,14 +60,34 @@ async function handleNpaResult(result: npa.Result): Promise { * - https://github.com/npm/npm-package-arg#result-object * - https://docs.npmjs.com/cli/v7/commands/npm-install */ -async function canonicalSpec(spec: string): Promise { +async function cachedCanonicalSpec(spec: string): Promise { "use cache"; - cacheLife("hours"); + try { + const canonical = await handleNpaResult(npa(spec)); + + cacheLife("hours"); + + return canonical; + } catch (e) { + if (hasErrorCode(e, "E404") || hasErrorCode(e, "ETARGET")) { + cacheLife("minutes"); + + return null; + } + + throw e; + } +} - const result = npa(spec); +async function canonicalSpec(spec: string): Promise { + const canonical = await cachedCanonicalSpec(spec); + + if (canonical == null) { + throw new SpecNotFoundError(spec); + } - return handleNpaResult(result); + return canonical; } export default canonicalSpec; diff --git a/src/lib/destination/index.ts b/src/lib/destination/index.ts index b055d745..a8763185 100644 --- a/src/lib/destination/index.ts +++ b/src/lib/destination/index.ts @@ -1,5 +1,6 @@ import arrayEquals from "^/lib/utils/arrayEquals"; import canonicalSpec from "./canonicalSpec"; +import SpecNotFoundError from "./SpecNotFoundError"; import versionsToSpecs from "./versionsToSpecs"; export type Redirect = false | "temporary" | "permanent"; @@ -52,3 +53,4 @@ async function destination( } export default destination; +export { SpecNotFoundError }; diff --git a/src/lib/query/index.ts b/src/lib/query/index.ts index c17aa00f..c74f873d 100644 --- a/src/lib/query/index.ts +++ b/src/lib/query/index.ts @@ -1,5 +1,6 @@ -import parseQuery from "./parseQuery"; +import parseDiffOptions from "./parseDiffOptions"; import type QueryParams from "./QueryParams"; +import { fromSearchParams, toSearchString } from "./searchParams"; export type { QueryParams }; -export { parseQuery }; +export { fromSearchParams, parseDiffOptions, toSearchString }; diff --git a/src/lib/query/parseDiffOptions.test.ts b/src/lib/query/parseDiffOptions.test.ts new file mode 100644 index 00000000..afd26990 --- /dev/null +++ b/src/lib/query/parseDiffOptions.test.ts @@ -0,0 +1,165 @@ +import { DEFAULT_DIFF_FILES_GLOB } from "^/lib/default-diff-files"; +import parseDiffOptions from "./parseDiffOptions"; + +describe("parseDiffOptions", () => { + it("accepts valid options", () => { + expect( + parseDiffOptions({ + diffFiles: ["{lib,dist}/**", "*.md"], + diffUnified: "3", + diffSrcPrefix: "a/", + diffDstPrefix: "b/", + }), + ).toStrictEqual({ + ok: true, + options: { + diffFiles: ["{lib,dist}/**", "*.md"], + diffUnified: 3, + diffSrcPrefix: "a/", + diffDstPrefix: "b/", + }, + }); + }); + + it("accepts empty options", () => { + expect(parseDiffOptions({})).toStrictEqual({ ok: true, options: {} }); + }); + + it("accepts the default diffFiles glob", () => { + expect( + parseDiffOptions({ diffFiles: DEFAULT_DIFF_FILES_GLOB }).ok, + ).toBe(true); + }); + + describe("diffFiles", () => { + it("rejects an exponential brace pattern quickly", () => { + const start = Date.now(); + const result = parseDiffOptions({ + diffFiles: "{a,b}".repeat(17), + }); + + expect(result.ok).toBe(false); + expect(Date.now() - start).toBeLessThan(500); + }); + + it("rejects a large numeric brace range", () => { + expect(parseDiffOptions({ diffFiles: "{1..100000}" }).ok).toBe( + false, + ); + }); + + it("accepts a pattern expanding to exactly 16 alternatives", () => { + expect(parseDiffOptions({ diffFiles: "{a,b}".repeat(4) }).ok).toBe( + true, + ); + }); + + it("rejects a pattern expanding to 32 alternatives", () => { + expect(parseDiffOptions({ diffFiles: "{a,b}".repeat(5) }).ok).toBe( + false, + ); + }); + + it("counts alternatives across all patterns", () => { + expect( + parseDiffOptions({ + diffFiles: ["{a,b}".repeat(3), "{a,b}".repeat(3)], + }).ok, + ).toBe(true); + expect( + parseDiffOptions({ + diffFiles: ["{a,b}".repeat(3), "{a,b}".repeat(3), "c"], + }).ok, + ).toBe(false); + }); + + it("expands braces escaped with a backslash, like libnpmdiff", () => { + expect( + parseDiffOptions({ diffFiles: "\\{a,b}".repeat(17) }).ok, + ).toBe(false); + }); + + it.each([ + "*(?|?)Z", + "+(a|b)", + "!(*.map)", + "@(a|b)", + "?(a)", + "\\*(a|b)", + "{a,*(?|?)}", + ])("rejects extglob %p", (pattern) => { + expect(parseDiffOptions({ diffFiles: pattern }).ok).toBe(false); + }); + + it.each(["*.js", "**/*.d.ts", "*a*", "**/lib/**/*-*", "{*a*,*b*}"])( + "accepts %p", + (pattern) => { + expect(parseDiffOptions({ diffFiles: pattern }).ok).toBe(true); + }, + ); + + it.each(["*?*?*?*?Z", "*a*b*c", "a**b*c*"])( + "rejects %p with more than two *", + (pattern) => { + expect(parseDiffOptions({ diffFiles: pattern }).ok).toBe(false); + }, + ); + + it("accepts 10 patterns and rejects 11", () => { + expect( + parseDiffOptions({ diffFiles: Array(10).fill("*.js") }).ok, + ).toBe(true); + expect( + parseDiffOptions({ diffFiles: Array(11).fill("*.js") }).ok, + ).toBe(false); + }); + + it("accepts 256 character patterns and rejects longer", () => { + expect(parseDiffOptions({ diffFiles: "a".repeat(256) }).ok).toBe( + true, + ); + expect(parseDiffOptions({ diffFiles: "a".repeat(257) }).ok).toBe( + false, + ); + }); + }); + + describe("diffUnified", () => { + it.each(["0", "3", "100"])("accepts %p", (value) => { + expect(parseDiffOptions({ diffUnified: value })).toStrictEqual({ + ok: true, + options: { diffUnified: Number(value) }, + }); + }); + + it.each(["-5", "101", "abc", "1.5", "3abc"])("rejects %p", (value) => { + expect(parseDiffOptions({ diffUnified: value }).ok).toBe(false); + }); + }); + + describe("prefixes", () => { + it("accepts 64 characters and rejects longer", () => { + expect(parseDiffOptions({ diffSrcPrefix: "a".repeat(64) }).ok).toBe( + true, + ); + expect(parseDiffOptions({ diffSrcPrefix: "a".repeat(65) }).ok).toBe( + false, + ); + expect(parseDiffOptions({ diffDstPrefix: "a".repeat(65) }).ok).toBe( + false, + ); + }); + + it.each(["a\n+++ b/fake", "a\r", "a\t", "a\x7f"])( + "rejects control characters in %p", + (value) => { + expect(parseDiffOptions({ diffSrcPrefix: value }).ok).toBe( + false, + ); + expect(parseDiffOptions({ diffDstPrefix: value }).ok).toBe( + false, + ); + }, + ); + }); +}); diff --git a/src/lib/query/parseDiffOptions.ts b/src/lib/query/parseDiffOptions.ts new file mode 100644 index 00000000..94b84e12 --- /dev/null +++ b/src/lib/query/parseDiffOptions.ts @@ -0,0 +1,122 @@ +import { braceExpand } from "minimatch"; +import { DEFAULT_DIFF_FILES_GLOB } from "^/lib/default-diff-files"; +import { type NpmDiffOptions } from "^/lib/npmDiff"; +import parseQuery from "./parseQuery"; +import type QueryParams from "./QueryParams"; + +const MAX_DIFF_FILES = 10; +const MAX_DIFF_FILES_PATTERN_LENGTH = 256; +const MAX_DIFF_FILES_BRACE_EXPANSION = 16; +const MAX_DIFF_FILES_STARS = 2; +const MAX_DIFF_UNIFIED = 100; +const MAX_DIFF_PREFIX_LENGTH = 64; + +// Same normalization libnpmdiff applies before matching, so `\{a,b}` +// can't slip past the checks as a literal brace. +function normalizeMatch(pattern: string): string { + return pattern.replace(/\\+/g, "/").replace(/^\.\/|^\./, ""); +} + +/** + * minimatch compiles globs to backtracking regexes. Extglobs and more than two + * `*` in a pattern can take seconds to match a single long file name, and + * libnpmdiff matches every pattern against every file in both tarballs. + * `**` path segments are not counted, they match whole segments. + */ +function isSlowGlob(pattern: string): boolean { + if (/[!@?*+]\(/.test(pattern)) { + return true; + } + const stars = pattern + .split("/") + .filter((segment) => segment !== "**") + .join("/") + .match(/\*+/g); + return (stars?.length ?? 0) > MAX_DIFF_FILES_STARS; +} + +function validateDiffFiles(diffFiles: string[]): string | undefined { + if (diffFiles.length > MAX_DIFF_FILES) { + return `Too many diffFiles patterns, at most ${MAX_DIFF_FILES} are allowed.`; + } + + let alternatives = 0; + for (const pattern of diffFiles) { + if (pattern.length > MAX_DIFF_FILES_PATTERN_LENGTH) { + return `diffFiles patterns can be at most ${MAX_DIFF_FILES_PATTERN_LENGTH} characters long.`; + } + if (pattern === DEFAULT_DIFF_FILES_GLOB) { + alternatives++; + continue; + } + + const expanded = braceExpand(normalizeMatch(pattern), { + braceExpandMax: MAX_DIFF_FILES_BRACE_EXPANSION + 1, + }); + alternatives += expanded.length; + if (alternatives > MAX_DIFF_FILES_BRACE_EXPANSION) { + return `diffFiles patterns can expand to at most ${MAX_DIFF_FILES_BRACE_EXPANSION} alternatives in total.`; + } + if (expanded.some(isSlowGlob)) { + return `diffFiles patterns can not use extglobs like !(...) and can have at most ${MAX_DIFF_FILES_STARS} * outside of ** segments.`; + } + } + + return undefined; +} + +function validatePrefix( + name: string, + prefix: string | undefined, +): string | undefined { + if (prefix == null) { + return undefined; + } + if (prefix.length > MAX_DIFF_PREFIX_LENGTH) { + return `${name} can be at most ${MAX_DIFF_PREFIX_LENGTH} characters long.`; + } + // Prefixes are written verbatim into the ---/+++ header lines + if (/[\x00-\x1f\x7f]/.test(prefix)) { + return `${name} can not contain control characters.`; + } + return undefined; +} + +function validateOptions({ + diffFiles, + diffUnified, + diffSrcPrefix, + diffDstPrefix, +}: NpmDiffOptions): string | undefined { + if (diffFiles != null) { + const diffFilesError = validateDiffFiles(diffFiles); + if (diffFilesError) { + return diffFilesError; + } + } + + if ( + diffUnified != null && + !( + Number.isInteger(diffUnified) && + diffUnified >= 0 && + diffUnified <= MAX_DIFF_UNIFIED + ) + ) { + return `diffUnified must be an integer between 0 and ${MAX_DIFF_UNIFIED}.`; + } + + return ( + validatePrefix("diffSrcPrefix", diffSrcPrefix) ?? + validatePrefix("diffDstPrefix", diffDstPrefix) + ); +} + +export default function parseDiffOptions( + query: QueryParams, +): { ok: true; options: NpmDiffOptions } | { ok: false; message: string } { + const options = parseQuery(query); + const message = validateOptions(options); + + return message == null ? { ok: true, options } : { ok: false, message }; +} diff --git a/src/lib/query/parseQuery/parseNumber.test.ts b/src/lib/query/parseQuery/parseNumber.test.ts index 76430a98..818ee425 100644 --- a/src/lib/query/parseQuery/parseNumber.test.ts +++ b/src/lib/query/parseQuery/parseNumber.test.ts @@ -8,7 +8,10 @@ describe("query/parseNumber", () => { it(`input is "1"`, () => expect(parseNumber("1")).toBe(1)); it(`input is "999"`, () => expect(parseNumber("999")).toBe(999)); - it(`input is "123XXX"`, () => expect(parseNumber("123XXX")).toBe(123)); + it(`input is "-5"`, () => expect(parseNumber("-5")).toBe(-5)); + it(`input is "123XXX"`, () => expect(parseNumber("123XXX")).toBeNaN()); + it(`input is "1.5"`, () => expect(parseNumber("1.5")).toBeNaN()); + it(`input is "abc"`, () => expect(parseNumber("abc")).toBeNaN()); it(`input is [1 ,2 ,3]`, () => expect(parseNumber(["1", "2", "3"])).toBe(3)); diff --git a/src/lib/query/parseQuery/parseNumber.ts b/src/lib/query/parseQuery/parseNumber.ts index bfcae9a8..7c5813f0 100644 --- a/src/lib/query/parseQuery/parseNumber.ts +++ b/src/lib/query/parseQuery/parseNumber.ts @@ -7,7 +7,7 @@ function parseNumber(str: undefined | string | string[]): undefined | number { return undefined; } - return parseInt(str); + return /^-?\d+$/.test(str) ? Number(str) : NaN; } export default parseNumber; diff --git a/src/lib/query/searchParams.test.ts b/src/lib/query/searchParams.test.ts new file mode 100644 index 00000000..1a4ad7d0 --- /dev/null +++ b/src/lib/query/searchParams.test.ts @@ -0,0 +1,52 @@ +import { fromSearchParams, toSearchString } from "./searchParams"; + +describe("toSearchString", () => { + it("returns an empty string for no params", () => { + expect(toSearchString({})).toBe(""); + expect(toSearchString({ diffFiles: undefined })).toBe(""); + }); + + it("encodes values", () => { + const search = toSearchString({ diffFiles: "a&diffNameOnly=true#x" }); + + expect(search).toBe("?diffFiles=a%26diffNameOnly%3Dtrue%23x"); + expect(Array.from(new URLSearchParams(search.slice(1)))).toStrictEqual([ + ["diffFiles", "a&diffNameOnly=true#x"], + ]); + }); + + it("appends each value of arrays", () => { + expect(toSearchString({ diffFiles: ["a,b", "c"], view: "split" })).toBe( + "?diffFiles=a%2Cb&diffFiles=c&view=split", + ); + }); +}); + +describe("fromSearchParams", () => { + it("keeps single values as strings and repeated values as arrays", () => { + expect( + fromSearchParams( + new URLSearchParams("diffFiles=a&diffFiles=b&diffUnified=3"), + ), + ).toStrictEqual({ diffFiles: ["a", "b"], diffUnified: "3" }); + }); + + it("round-trips with toSearchString", () => { + const input = new URLSearchParams( + "diffFiles=a%26b&diffFiles=c&diffText=", + ); + + expect(toSearchString(fromSearchParams(input))).toBe(`?${input}`); + }); + + it("treats __proto__ as a normal key", () => { + const query = fromSearchParams( + new URLSearchParams("__proto__=a&__proto__=b"), + ); + + expect(Object.getPrototypeOf(query)).toBe(Object.prototype); + expect( + Object.getOwnPropertyDescriptor(query, "__proto__")?.value, + ).toEqual(["a", "b"]); + }); +}); diff --git a/src/lib/query/searchParams.ts b/src/lib/query/searchParams.ts new file mode 100644 index 00000000..745367d6 --- /dev/null +++ b/src/lib/query/searchParams.ts @@ -0,0 +1,34 @@ +type SearchParamValue = string | string[] | undefined; + +type SearchParamsRecord = { [K in keyof T]: SearchParamValue }; + +export function toSearchString>( + query: T, +): string { + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + for (const item of Array.isArray(value) ? value : [value]) { + if (item != null) { + searchParams.append(key, item); + } + } + } + + const search = searchParams.toString(); + + return search.length > 0 ? `?${search}` : ""; +} + +export function fromSearchParams( + searchParams: URLSearchParams, +): Record { + // Object.fromEntries defines own properties, so a `__proto__` key can't + // replace the prototype. + return Object.fromEntries( + [...new Set(searchParams.keys())].map((key) => { + const values = searchParams.getAll(key); + return [key, values.length === 1 ? values[0] : values]; + }), + ); +} diff --git a/src/lib/utils/parseParts.test.ts b/src/lib/utils/parseParts.test.ts new file mode 100644 index 00000000..2912cccf --- /dev/null +++ b/src/lib/utils/parseParts.test.ts @@ -0,0 +1,58 @@ +import parseParts from "./parseParts"; + +describe("parseParts", () => { + it("parses two specs", () => { + expect(parseParts("semver@7.6.0...semver@7.6.3")).toEqual([ + "semver@7.6.0", + "semver@7.6.3", + ]); + }); + + it("parses scoped specs from segments", () => { + expect(parseParts(["@types", "react@17...@types", "react@18"])).toEqual( + ["@types/react@17", "@types/react@18"], + ); + }); + + it("parses a spec and a version", () => { + expect(parseParts("semver@7.6.0...7.6.3")).toEqual([ + "semver@7.6.0", + "7.6.3", + ]); + }); + + it("parses a single package name", () => { + expect(parseParts("semver")).toEqual(["semver"]); + }); + + it.each(["x", "1", "1.0.0"])( + "parses package name %p that is also a semver range", + (name) => { + expect(parseParts(name)).toEqual([name]); + }, + ); + + it("decodes parts when asked", () => { + expect( + parseParts("semver%407.6.0...semver%407.6.3", { decode: true }), + ).toEqual(["semver@7.6.0", "semver@7.6.3"]); + }); + + it.each([ + ["too many separators", "a...b...c"], + ["only a version range", "^1.0.0"], + ["only versions", "1.0.0...2.0.0"], + ["invalid package name", "NOT VALID@1"], + ["unsupported spec type", "file:../foo...semver@1"], + ])("returns null for %s", (_, parts) => { + expect(parseParts(parts)).toBeNull(); + }); + + it("returns null for malformed percent-encoding", () => { + expect(parseParts("%E0%A4%A", { decode: true })).toBeNull(); + }); + + it("returns null for too many segments", () => { + expect(parseParts(["a", "b", "c", "d"])).toBeNull(); + }); +}); diff --git a/src/lib/utils/parseParts.ts b/src/lib/utils/parseParts.ts new file mode 100644 index 00000000..50b67932 --- /dev/null +++ b/src/lib/utils/parseParts.ts @@ -0,0 +1,28 @@ +import versionsToSpecs from "^/lib/destination/versionsToSpecs"; +import decodeParts from "./decodeParts"; +import { isCatalogPage } from "./isCatalogPage"; +import splitParts from "./splitParts"; +import validateSpecs from "./validateSpecs"; + +export default function parseParts( + parts: string | string[] | undefined, + { decode = false }: { decode?: boolean } = {}, +): [string] | [string, string] | null { + try { + const specsOrVersions = splitParts(decode ? decodeParts(parts) : parts); + + if (!validateSpecs(specsOrVersions)) { + return null; + } + + // Bare names like `x` or `1` are also semver ranges, which + // versionsToSpecs rejects, but they are valid catalog pages. + if (!isCatalogPage(specsOrVersions)) { + versionsToSpecs(specsOrVersions); + } + + return specsOrVersions; + } catch { + return null; + } +}