From 83d93cd8ea410c9f619592b32d844a2f6bd8c3cd Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 10 Oct 2026 14:11:38 -0700 Subject: [PATCH 1/4] Harden dependencies and remote fetches Reject HTTPS snippets that redirect to HTTP unless allowInsecureHttp is set, and stream remote bodies with a 5 MB limit. Replace fast-glob with tinyglobby, removing the only production advisories (micromatch/braces); sort results for a stable order. Drop the deprecated mdast package, move @types/mdast to devDependencies, and stop publishing the 1.3 MB logo. --- README.md | 5 +- package-lock.json | 124 ++++++++++++++++++------------- package.json | 8 +- scripts/smoke-test-package.mjs | 7 +- src/glob.ts | 11 ++- src/remote.ts | 54 +++++++++++++- test/remote.test.ts | 129 +++++++++++++++++++++++++++------ test/sync-plan.test.ts | 11 +-- 8 files changed, 247 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index f96bf82..a0389b6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-markdown-code +markdown-code [![CI Build](https://github.com/scalvert/markdown-code/actions/workflows/ci.yml/badge.svg)](https://github.com/scalvert/markdown-code/actions/workflows/ci.yml) [![npm version](https://badge.fury.io/js/markdown-code.svg)](https://badge.fury.io/js/markdown-code) @@ -200,7 +200,8 @@ syntax as a local path: ```` HTTPS is allowed by default. Plain HTTP is rejected unless `allowInsecureHttp` is -set to `true`. Remote requests time out after 30 seconds by default; configure +set to `true`, including an HTTPS URL that redirects to HTTP. Remote files larger +than 5 MB are rejected. Remote requests time out after 30 seconds by default; configure `remoteTimeout` in milliseconds when needed. Remote failures remain errors even when `missingSnippetSeverity` is set to `warning`, because the source was configured but could not be loaded. diff --git a/package-lock.json b/package-lock.json index b1137ec..533492f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,13 @@ "version": "1.2.0", "license": "MIT", "dependencies": { - "@types/mdast": "^4.0.3", - "fast-glob": "^3.3.2", "language-map": "^1.5.0", - "mdast": "^3.0.0", "picocolors": "^1.1.1", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-mdx": "^3.1.1", "remark-parse": "^11.0.0", + "tinyglobby": "^0.2.17", "unified": "^11.0.4", "unist-util-visit": "^5.1.0", "yargs": "^18.0.0" @@ -29,6 +27,7 @@ "devDependencies": { "@eslint/js": "^9.35.0", "@release-it-plugins/lerna-changelog": "^9.0.0", + "@types/mdast": "^4.0.4", "@types/node": "^25.6.0", "@types/yargs": "^17.0.35", "@typescript-eslint/eslint-plugin": "^8.58.1", @@ -1415,6 +1414,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -1428,6 +1428,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -1437,6 +1438,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -4854,6 +4856,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6320,6 +6323,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -6336,6 +6340,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6396,6 +6401,7 @@ "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -6447,6 +6453,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -7076,6 +7083,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7095,6 +7103,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7162,6 +7171,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -8066,13 +8076,6 @@ "node": "*" } }, - "node_modules/mdast": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mdast/-/mdast-3.0.0.tgz", - "integrity": "sha512-xySmf8g4fPKMeC07jXGz971EkLbWAJ83s4US2Tj9lEdnZ142UP5grN73H1Xd3HzrdbU5o9GYYP/y8F9ZSwLE9g==", - "deprecated": "`mdast` was renamed to `remark`", - "license": "MIT" - }, "node_modules/mdast-util-from-markdown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", @@ -8290,6 +8293,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -8911,6 +8915,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -10076,6 +10081,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -10428,6 +10434,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -10609,6 +10616,54 @@ "node": "^20.19.0 || ^22.13.0 || >=24.0.0" } }, + "node_modules/release-it/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/release-it/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/release-it/node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/release-it/node_modules/yargs-parser": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", @@ -10776,6 +10831,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -10902,6 +10958,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -11457,14 +11514,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -11477,7 +11533,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -11495,7 +11550,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -11528,6 +11582,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -12030,24 +12085,6 @@ } } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -12061,23 +12098,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vite/node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, "node_modules/vitest": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", diff --git a/package.json b/package.json index 3784a6c..53304b8 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,7 @@ "markdown-code": "dist/cli.js" }, "files": [ - "dist", - "markdown-code.png" + "dist" ], "scripts": { "build": "tsup", @@ -40,15 +39,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@types/mdast": "^4.0.3", - "fast-glob": "^3.3.2", "language-map": "^1.5.0", - "mdast": "^3.0.0", "picocolors": "^1.1.1", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-mdx": "^3.1.1", "remark-parse": "^11.0.0", + "tinyglobby": "^0.2.17", "unified": "^11.0.4", "unist-util-visit": "^5.1.0", "yargs": "^18.0.0" @@ -56,6 +53,7 @@ "devDependencies": { "@eslint/js": "^9.35.0", "@release-it-plugins/lerna-changelog": "^9.0.0", + "@types/mdast": "^4.0.4", "@types/node": "^25.6.0", "@types/yargs": "^17.0.35", "@typescript-eslint/eslint-plugin": "^8.58.1", diff --git a/scripts/smoke-test-package.mjs b/scripts/smoke-test-package.mjs index 217ae8c..666f63a 100644 --- a/scripts/smoke-test-package.mjs +++ b/scripts/smoke-test-package.mjs @@ -15,12 +15,7 @@ const repoRoot = resolve(import.meta.dirname, '..'); const { version } = JSON.parse( readFileSync(join(repoRoot, 'package.json'), 'utf-8'), ); -const allowedFiles = new Set([ - 'LICENSE', - 'README.md', - 'package.json', - 'markdown-code.png', -]); +const allowedFiles = new Set(['LICENSE', 'README.md', 'package.json']); const npmCli = process.env.npm_execpath; if (!npmCli) { diff --git a/src/glob.ts b/src/glob.ts index 45ffe24..792d4ef 100644 --- a/src/glob.ts +++ b/src/glob.ts @@ -1,5 +1,5 @@ import { resolve } from 'node:path'; -import fg from 'fast-glob'; +import { glob } from 'tinyglobby'; export function toGlobPattern( pattern: string, @@ -19,10 +19,15 @@ export async function findMarkdownFiles({ excludeGlob, workingDir, }: MarkdownFileQuery): Promise> { - const files = await fg(toGlobPattern(markdownGlob), { + if (markdownGlob.trim() === '') { + throw new TypeError('markdownGlob must be a non-empty pattern'); + } + + const files = await glob(toGlobPattern(markdownGlob), { ignore: excludeGlob.map((pattern) => toGlobPattern(pattern)), cwd: workingDir, absolute: true, + expandDirectories: false, }); - return files.map((file) => resolve(file)); + return files.map((file) => resolve(file)).sort(); } diff --git a/src/remote.ts b/src/remote.ts index aef1837..a7d94e9 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -1,9 +1,49 @@ export interface FetchOptions { timeout?: number | undefined; allowInsecureHttp?: boolean | undefined; + maxBytes?: number | undefined; } const DEFAULT_TIMEOUT = 30000; // 30 seconds +const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; + +function tooLarge(url: string, maxBytes: number): Error { + return new Error(`Remote file is larger than ${maxBytes} bytes: ${url}`); +} + +async function readBody( + response: Response, + url: string, + maxBytes: number, +): Promise { + const declaredLength = Number(response.headers.get('content-length')); + if (declaredLength > maxBytes) { + await response.body?.cancel(); + throw tooLarge(url, maxBytes); + } + + if (!response.body) { + return ''; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let received = 0; + let text = ''; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + return text + decoder.decode(); + } + received += value.byteLength; + if (received > maxBytes) { + await reader.cancel(); + throw tooLarge(url, maxBytes); + } + text += decoder.decode(value, { stream: true }); + } +} /** * Check if a path is a remote URL (HTTP or HTTPS) @@ -102,6 +142,18 @@ export async function fetchRemoteContent( }, }); + if ( + response.redirected && + new URL(response.url).protocol === 'http:' && + !options.allowInsecureHttp + ) { + await response.body?.cancel(); + throw new Error( + `Remote file redirected to an insecure HTTP URL: ${url} -> ${response.url}. ` + + 'Use HTTPS or set allowInsecureHttp: true in config.', + ); + } + if (!response.ok) { if (response.status === 404) { throw new Error(`Remote file not found: ${url}`); @@ -111,7 +163,7 @@ export async function fetchRemoteContent( ); } - return await response.text(); + return await readBody(response, url, options.maxBytes ?? DEFAULT_MAX_BYTES); } catch (error) { if (error instanceof Error) { if (error.name === 'AbortError') { diff --git a/test/remote.test.ts b/test/remote.test.ts index cf109ef..592a8b1 100644 --- a/test/remote.test.ts +++ b/test/remote.test.ts @@ -138,20 +138,14 @@ describe('remote', () => { it('should fetch content from valid URL', async () => { const mockContent = 'const x = 1;'; - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: () => Promise.resolve(mockContent), - } as Response); + vi.mocked(fetch).mockResolvedValue(new Response(mockContent)); const result = await fetchRemoteContent('https://example.com/file.ts'); expect(result).toBe(mockContent); }); it('should strip hash fragment when fetching', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: () => Promise.resolve('content'), - } as Response); + vi.mocked(fetch).mockResolvedValue(new Response('content')); await fetchRemoteContent('https://example.com/file.ts#L10-L20'); @@ -163,10 +157,7 @@ describe('remote', () => { }); it('should throw on 404', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 404, - } as Response); + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 404 })); await expect( fetchRemoteContent('https://example.com/missing.ts'), @@ -174,10 +165,7 @@ describe('remote', () => { }); it('should throw on other HTTP errors', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 500, - } as Response); + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 500 })); await expect( fetchRemoteContent('https://example.com/file.ts'), @@ -185,10 +173,7 @@ describe('remote', () => { }); it('should include User-Agent header', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: () => Promise.resolve('content'), - } as Response); + vi.mocked(fetch).mockResolvedValue(new Response('content')); await fetchRemoteContent('https://example.com/file.ts'); @@ -208,11 +193,107 @@ describe('remote', () => { ).rejects.toThrow('Insecure HTTP URLs are not allowed'); }); + describe('redirects', () => { + function redirectedResponse(finalUrl: string, body = 'content') { + const response = new Response(body); + Object.defineProperty(response, 'redirected', { value: true }); + Object.defineProperty(response, 'url', { value: finalUrl }); + return response; + } + + it('rejects an HTTPS URL that redirects to HTTP', async () => { + vi.mocked(fetch).mockResolvedValue( + redirectedResponse('http://example.com/file.ts'), + ); + + await expect( + fetchRemoteContent('https://example.com/file.ts'), + ).rejects.toThrow( + 'Remote file redirected to an insecure HTTP URL: https://example.com/file.ts -> http://example.com/file.ts', + ); + }); + + it('follows a redirect to HTTP when allowInsecureHttp is true', async () => { + vi.mocked(fetch).mockResolvedValue( + redirectedResponse('http://example.com/file.ts'), + ); + + await expect( + fetchRemoteContent('https://example.com/file.ts', { + allowInsecureHttp: true, + }), + ).resolves.toBe('content'); + }); + + it('follows a redirect that stays on HTTPS', async () => { + vi.mocked(fetch).mockResolvedValue( + redirectedResponse('https://cdn.example.com/file.ts'), + ); + + await expect( + fetchRemoteContent('https://example.com/file.ts'), + ).resolves.toBe('content'); + }); + }); + + describe('size limit', () => { + function streamOf(chunks: Array): ReadableStream { + return new ReadableStream({ + start(controller) { + chunks.forEach((chunk) => controller.enqueue(chunk)); + controller.close(); + }, + }); + } + + it('rejects a declared Content-Length over the limit', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response('x', { headers: { 'content-length': '11' } }), + ); + + await expect( + fetchRemoteContent('https://example.com/big.ts', { maxBytes: 10 }), + ).rejects.toThrow( + 'Remote file is larger than 10 bytes: https://example.com/big.ts', + ); + }); + + it('stops reading a body that grows past the limit', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response( + streamOf([new Uint8Array(6), new Uint8Array(6), new Uint8Array(6)]), + ), + ); + + await expect( + fetchRemoteContent('https://example.com/big.ts', { maxBytes: 10 }), + ).rejects.toThrow('Remote file is larger than 10 bytes'); + }); + + it('accepts a body exactly at the limit', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('0123456789')); + + await expect( + fetchRemoteContent('https://example.com/ok.ts', { maxBytes: 10 }), + ).resolves.toBe('0123456789'); + }); + + it('decodes UTF-8 characters split across chunks', async () => { + const bytes = new TextEncoder().encode('café → 🚀'); + vi.mocked(fetch).mockResolvedValue( + new Response( + streamOf([bytes.slice(0, 4), bytes.slice(4, 9), bytes.slice(9)]), + ), + ); + + await expect( + fetchRemoteContent('https://example.com/utf8.ts'), + ).resolves.toBe('café → 🚀'); + }); + }); + it('should allow HTTP when allowInsecureHttp is true', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: () => Promise.resolve('content'), - } as Response); + vi.mocked(fetch).mockResolvedValue(new Response('content')); await expect( fetchRemoteContent('http://example.com/file.ts', { diff --git a/test/sync-plan.test.ts b/test/sync-plan.test.ts index 48bb1c4..d49c19b 100644 --- a/test/sync-plan.test.ts +++ b/test/sync-plan.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { chmodSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { Project } from 'fixturify-project'; @@ -11,13 +11,6 @@ import { import { syncMarkdownFiles } from '../src/sync.js'; import type { RuntimeConfig } from '../src/types.js'; -vi.mock('fast-glob', async (importOriginal) => { - const actual = await importOriginal(); - const sortedGlob = async (...args: Parameters) => - (await actual.default(...args)).sort(); - return { default: sortedGlob }; -}); - async function collect(plan: SyncPlan): Promise> { if (plan.status !== 'planned') { throw new Error(`Expected a plan, got ${plan.error}`); @@ -175,7 +168,7 @@ describe('planSync', () => { expect(plan).toMatchInlineSnapshot(` { - "error": "Error finding markdown files: TypeError: Patterns must be a string (non empty) or an array of strings", + "error": "Error finding markdown files: TypeError: markdownGlob must be a non-empty pattern", "status": "error", } `); From 48b4d3e3286c534a3e2e06e9d6ed32f86ae04dfa Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 10 Oct 2026 14:25:24 -0700 Subject: [PATCH 2/4] Keep fast-glob; guard the ../ and symlink cases tinyglobby misses tinyglobby 0.2.17 drops the working directory's own subtree from a ../ pattern and, in some layouts, symlinked directories under a symlinked absolute root, so md-code check --markdown-glob '../**/*.md' found nothing. Go back to fast-glob, keep sorted results, drop the empty-pattern guard that was only needed for tinyglobby, and add glob tests for both cases. Regenerate the lockfile from main so it only drops mdast. --- package-lock.json | 114 ++++++++++++++++------------------------- package.json | 2 +- src/glob.ts | 9 +--- test/glob.test.ts | 44 ++++++++++++++++ test/sync-plan.test.ts | 2 +- 5 files changed, 91 insertions(+), 80 deletions(-) diff --git a/package-lock.json b/package-lock.json index 533492f..7c961ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,13 @@ "version": "1.2.0", "license": "MIT", "dependencies": { + "fast-glob": "^3.3.2", "language-map": "^1.5.0", "picocolors": "^1.1.1", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-mdx": "^3.1.1", "remark-parse": "^11.0.0", - "tinyglobby": "^0.2.17", "unified": "^11.0.4", "unist-util-visit": "^5.1.0", "yargs": "^18.0.0" @@ -1414,7 +1414,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -1428,7 +1427,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -1438,7 +1436,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -4856,7 +4853,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6323,7 +6319,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -6340,7 +6335,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6401,7 +6395,6 @@ "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -6453,7 +6446,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -7083,7 +7075,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7103,7 +7094,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7171,7 +7161,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -8293,7 +8282,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -8915,7 +8903,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -10081,7 +10068,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -10434,7 +10420,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -10616,54 +10601,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24.0.0" } }, - "node_modules/release-it/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/release-it/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/release-it/node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, "node_modules/release-it/node_modules/yargs-parser": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", @@ -10831,7 +10768,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -10958,7 +10894,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -11514,13 +11449,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.4" + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -11533,6 +11469,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -11550,6 +11487,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -11582,7 +11520,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -12085,6 +12022,24 @@ } } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -12098,6 +12053,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vite/node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/vitest": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", diff --git a/package.json b/package.json index 53304b8..8262d34 100644 --- a/package.json +++ b/package.json @@ -39,13 +39,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "fast-glob": "^3.3.2", "language-map": "^1.5.0", "picocolors": "^1.1.1", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-mdx": "^3.1.1", "remark-parse": "^11.0.0", - "tinyglobby": "^0.2.17", "unified": "^11.0.4", "unist-util-visit": "^5.1.0", "yargs": "^18.0.0" diff --git a/src/glob.ts b/src/glob.ts index 792d4ef..f3bfd7d 100644 --- a/src/glob.ts +++ b/src/glob.ts @@ -1,5 +1,5 @@ import { resolve } from 'node:path'; -import { glob } from 'tinyglobby'; +import fg from 'fast-glob'; export function toGlobPattern( pattern: string, @@ -19,15 +19,10 @@ export async function findMarkdownFiles({ excludeGlob, workingDir, }: MarkdownFileQuery): Promise> { - if (markdownGlob.trim() === '') { - throw new TypeError('markdownGlob must be a non-empty pattern'); - } - - const files = await glob(toGlobPattern(markdownGlob), { + const files = await fg(toGlobPattern(markdownGlob), { ignore: excludeGlob.map((pattern) => toGlobPattern(pattern)), cwd: workingDir, absolute: true, - expandDirectories: false, }); return files.map((file) => resolve(file)).sort(); } diff --git a/test/glob.test.ts b/test/glob.test.ts index b825129..0b4aaa9 100644 --- a/test/glob.test.ts +++ b/test/glob.test.ts @@ -1,3 +1,4 @@ +import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Project } from 'fixturify-project'; @@ -50,6 +51,49 @@ describe('findMarkdownFiles', () => { ); }); + it('includes the working directory itself in a ../ pattern', async () => { + const files = await findMarkdownFiles({ + markdownGlob: '../**/*.md', + excludeGlob: [], + workingDir: join(project.baseDir, 'docs'), + }); + + expect(files).toEqual([ + join(project.baseDir, 'README.md'), + join(project.baseDir, 'docs', 'draft.md'), + join(project.baseDir, 'docs', 'guide.md'), + ]); + }); + + it('follows symlinked directories under a symlinked absolute root', async () => { + const outside = new Project(); + await outside.write({ 'linked.md': '' }); + try { + mkdirSync(join(project.baseDir, 'tree')); + writeFileSync(join(project.baseDir, 'tree', 'own.md'), ''); + symlinkSync( + outside.baseDir, + join(project.baseDir, 'tree', 'link'), + 'junction', + ); + const alias = join(project.baseDir, 'alias'); + symlinkSync(join(project.baseDir, 'tree'), alias, 'junction'); + + const files = await findMarkdownFiles({ + markdownGlob: join(alias, '**', '*.md'), + excludeGlob: [], + workingDir: alias, + }); + + expect(files).toEqual([ + join(alias, 'link', 'linked.md'), + join(alias, 'own.md'), + ]); + } finally { + outside.dispose(); + } + }); + it('accepts an absolute pattern built with platform paths', async () => { const files = await findMarkdownFiles({ markdownGlob: join(project.baseDir, 'docs', '*.md'), diff --git a/test/sync-plan.test.ts b/test/sync-plan.test.ts index d49c19b..bb8d9b9 100644 --- a/test/sync-plan.test.ts +++ b/test/sync-plan.test.ts @@ -168,7 +168,7 @@ describe('planSync', () => { expect(plan).toMatchInlineSnapshot(` { - "error": "Error finding markdown files: TypeError: markdownGlob must be a non-empty pattern", + "error": "Error finding markdown files: TypeError: Patterns must be a string (non empty) or an array of strings", "status": "error", } `); From 7eedda7a878152b44d4ff9bf15b94275675a1251 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 10 Oct 2026 14:31:47 -0700 Subject: [PATCH 3/4] Follow redirects manually and validate every hop Checking only the final URL let an HTTP hop in the middle of a redirect chain through (https -> http -> https), and an insecure hop was requested before being rejected. Follow up to 10 redirects with redirect: 'manual', resolving relative Locations and rejecting http: (unless allowInsecureHttp) and non-HTTP schemes before requesting them. --- src/remote.ts | 82 +++++++++++++++++++++++++++++++---------- test/remote.test.ts | 89 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 136 insertions(+), 35 deletions(-) diff --git a/src/remote.ts b/src/remote.ts index a7d94e9..8a68e4d 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -117,6 +117,63 @@ export function validateRemoteUrl( /** * Fetch content from a remote URL. */ +const MAX_REDIRECTS = 10; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +function redirectTarget( + url: string, + from: string, + location: string, + options: FetchOptions, +): string { + const target = new URL(location, from); + target.hash = ''; + + if (target.protocol === 'http:' && !options.allowInsecureHttp) { + throw new Error( + `Remote file redirected to an insecure HTTP URL: ${url} -> ${target}. ` + + 'Use HTTPS or set allowInsecureHttp: true in config.', + ); + } + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + throw new Error( + `Remote file redirected to an unsupported URL: ${url} -> ${target}`, + ); + } + return target.toString(); +} + +async function fetchFollowingRedirects( + url: string, + fetchUrl: string, + options: FetchOptions, + signal: AbortSignal, +): Promise { + let current = fetchUrl; + + for (let redirects = 0; ; redirects++) { + const response = await fetch(current, { + signal, + redirect: 'manual', + headers: { + 'User-Agent': 'markdown-code', + Accept: 'text/plain, */*', + }, + }); + + const location = response.headers.get('location'); + if (!REDIRECT_STATUSES.has(response.status) || location === null) { + return response; + } + + await response.body?.cancel(); + if (redirects === MAX_REDIRECTS) { + throw new Error(`Too many redirects: ${url}`); + } + current = redirectTarget(url, current, location, options); + } +} + export async function fetchRemoteContent( url: string, options: FetchOptions = {}, @@ -134,25 +191,12 @@ export async function fetchRemoteContent( const timeoutId = setTimeout(() => controller.abort(), timeout); try { - const response = await fetch(fetchUrl, { - signal: controller.signal, - headers: { - 'User-Agent': 'markdown-code', - Accept: 'text/plain, */*', - }, - }); - - if ( - response.redirected && - new URL(response.url).protocol === 'http:' && - !options.allowInsecureHttp - ) { - await response.body?.cancel(); - throw new Error( - `Remote file redirected to an insecure HTTP URL: ${url} -> ${response.url}. ` + - 'Use HTTPS or set allowInsecureHttp: true in config.', - ); - } + const response = await fetchFollowingRedirects( + url, + fetchUrl, + options, + controller.signal, + ); if (!response.ok) { if (response.status === 404) { diff --git a/test/remote.test.ts b/test/remote.test.ts index 592a8b1..88b94f6 100644 --- a/test/remote.test.ts +++ b/test/remote.test.ts @@ -194,16 +194,17 @@ describe('remote', () => { }); describe('redirects', () => { - function redirectedResponse(finalUrl: string, body = 'content') { - const response = new Response(body); - Object.defineProperty(response, 'redirected', { value: true }); - Object.defineProperty(response, 'url', { value: finalUrl }); - return response; + function redirect(location: string, status = 302) { + return new Response('moved', { status, headers: { location } }); } - it('rejects an HTTPS URL that redirects to HTTP', async () => { - vi.mocked(fetch).mockResolvedValue( - redirectedResponse('http://example.com/file.ts'), + function requestedUrls() { + return vi.mocked(fetch).mock.calls.map(([input]) => String(input)); + } + + it('rejects a redirect to HTTP without requesting it', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + redirect('http://example.com/file.ts'), ); await expect( @@ -211,28 +212,84 @@ describe('remote', () => { ).rejects.toThrow( 'Remote file redirected to an insecure HTTP URL: https://example.com/file.ts -> http://example.com/file.ts', ); + expect(requestedUrls()).toEqual(['https://example.com/file.ts']); }); - it('follows a redirect to HTTP when allowInsecureHttp is true', async () => { - vi.mocked(fetch).mockResolvedValue( - redirectedResponse('http://example.com/file.ts'), - ); + it('rejects an HTTP hop in the middle of a redirect chain', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(redirect('https://a.example.com/one')) + .mockResolvedValueOnce(redirect('http://b.example.com/two')); + + await expect( + fetchRemoteContent('https://example.com/file.ts'), + ).rejects.toThrow('-> http://b.example.com/two'); + expect(requestedUrls()).toEqual([ + 'https://example.com/file.ts', + 'https://a.example.com/one', + ]); + }); + + it('follows HTTP hops when allowInsecureHttp is true', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(redirect('http://example.com/two', 301)) + .mockResolvedValueOnce(redirect('https://example.com/three', 308)) + .mockResolvedValueOnce(new Response('content')); await expect( fetchRemoteContent('https://example.com/file.ts', { allowInsecureHttp: true, }), ).resolves.toBe('content'); + expect(requestedUrls()).toEqual([ + 'https://example.com/file.ts', + 'http://example.com/two', + 'https://example.com/three', + ]); }); - it('follows a redirect that stays on HTTPS', async () => { - vi.mocked(fetch).mockResolvedValue( - redirectedResponse('https://cdn.example.com/file.ts'), + it('resolves a relative Location against the current URL', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(redirect('../raw/file.ts#L1-L2', 307)) + .mockResolvedValueOnce(new Response('content')); + + await expect( + fetchRemoteContent('https://example.com/blob/file.ts'), + ).resolves.toBe('content'); + expect(requestedUrls()).toEqual([ + 'https://example.com/blob/file.ts', + 'https://example.com/raw/file.ts', + ]); + }); + + it('rejects a redirect to a non-HTTP scheme', async () => { + vi.mocked(fetch).mockResolvedValueOnce(redirect('file:///etc/passwd')); + + await expect( + fetchRemoteContent('https://example.com/file.ts'), + ).rejects.toThrow( + 'Remote file redirected to an unsupported URL: https://example.com/file.ts -> file:///etc/passwd', + ); + }); + + it('gives up after 10 redirects', async () => { + vi.mocked(fetch).mockImplementation(() => + Promise.resolve(redirect('https://example.com/again')), ); await expect( fetchRemoteContent('https://example.com/file.ts'), - ).resolves.toBe('content'); + ).rejects.toThrow('Too many redirects: https://example.com/file.ts'); + expect(requestedUrls()).toHaveLength(11); + }); + + it('treats a 3xx without a Location as the final response', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(null, { status: 304 }), + ); + + await expect( + fetchRemoteContent('https://example.com/file.ts'), + ).rejects.toThrow('HTTP 304'); }); }); From 328c09f571ab0a657afbd07d85afabc27c5c5329 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 10 Oct 2026 14:38:29 -0700 Subject: [PATCH 4/4] Reject credentialed or invalid redirects; cancel error response bodies --- src/remote.ts | 8 ++++++++ test/remote.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/remote.ts b/src/remote.ts index 8a68e4d..4ae8353 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -126,9 +126,16 @@ function redirectTarget( location: string, options: FetchOptions, ): string { + if (!URL.canParse(location, from)) { + throw new Error(`Remote file sent an invalid redirect: ${url}`); + } const target = new URL(location, from); target.hash = ''; + if (target.username || target.password) { + throw new Error(`Remote file redirected to a URL with credentials: ${url}`); + } + if (target.protocol === 'http:' && !options.allowInsecureHttp) { throw new Error( `Remote file redirected to an insecure HTTP URL: ${url} -> ${target}. ` + @@ -199,6 +206,7 @@ export async function fetchRemoteContent( ); if (!response.ok) { + await response.body?.cancel(); if (response.status === 404) { throw new Error(`Remote file not found: ${url}`); } diff --git a/test/remote.test.ts b/test/remote.test.ts index 88b94f6..b30c4aa 100644 --- a/test/remote.test.ts +++ b/test/remote.test.ts @@ -164,6 +164,21 @@ describe('remote', () => { ).rejects.toThrow('Remote file not found'); }); + it('cancels the body of an error response', async () => { + let cancelled = false; + const body = new ReadableStream({ + cancel() { + cancelled = true; + }, + }); + vi.mocked(fetch).mockResolvedValue(new Response(body, { status: 500 })); + + await expect( + fetchRemoteContent('https://example.com/file.ts'), + ).rejects.toThrow('HTTP 500'); + expect(cancelled).toBe(true); + }); + it('should throw on other HTTP errors', async () => { vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 500 })); @@ -271,6 +286,30 @@ describe('remote', () => { ); }); + it('rejects a redirect to a URL with credentials without echoing them', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + redirect('https://user:secret@example.com/file.ts'), + ); + + const error = await fetchRemoteContent( + 'https://example.com/file.ts', + ).catch((caught: unknown) => caught as Error); + + expect(error.message).toBe( + 'Remote file redirected to a URL with credentials: https://example.com/file.ts', + ); + }); + + it('names the URL when the redirect Location is invalid', async () => { + vi.mocked(fetch).mockResolvedValueOnce(redirect('http://[::1')); + + await expect( + fetchRemoteContent('https://example.com/file.ts'), + ).rejects.toThrow( + 'Remote file sent an invalid redirect: https://example.com/file.ts', + ); + }); + it('gives up after 10 redirects', async () => { vi.mocked(fetch).mockImplementation(() => Promise.resolve(redirect('https://example.com/again')),