Skip to content

Commit fe4788d

Browse files
feildmasterclaude
andcommitted
ci: make sure the install link in plugins.json answers
Resolving a version and an install link only proves a string came back. A script whose @downloadurl is stale resolves fine and still leaves the menu with an install link that 404s. Send a HEAD request to the resolved link, following redirects, and retry as a ranged GET when the host refuses HEAD. Fail on an error status, an unreachable host, or a text/html response, since no userscript is served as a web page. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 079d2c9 commit fe4788d

3 files changed

Lines changed: 96 additions & 4 deletions

File tree

‎readme.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Community plugins are listed in [plugins.json](plugins.json), which UnderScript
6565
- `downloadURL` is only needed when the install link can't be derived from `updateURL`.
6666
- A plugin needs at least one of `updateURL` and `downloadURL`. With only `downloadURL`, UnderScript reads the version from it and installs from it, the same as `plugin.updater('https://...')`.
6767

68-
Pull requests that change `plugins.json` are checked automatically. The file must be valid JSON, every entry needs a valid `name` and `author` and at least one of `updateURL` and `downloadURL` (https links only), and UnderScript must be able to read a version and an install link from them. Run `npm run plugins` to see the same errors locally, or `npm run plugins -- --offline` to skip the network part.
68+
Pull requests that change `plugins.json` are checked automatically. The file must be valid JSON, every entry needs a valid `name` and `author` and at least one of `updateURL` and `downloadURL` (https links only), and UnderScript must be able to read a version and an install link from them, and that link has to answer and not be a web page. Run `npm run plugins` to see the same errors locally, or `npm run plugins -- --offline` to skip the network part.
6969

7070
To try entries out before pushing them, declare the file as a `plugins.json` resource in your development userscript header:
7171

‎scripts/plugins.js‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,30 @@ export async function resolve(entry) {
9696
return { version, url };
9797
}
9898

99+
const HEAD_UNSUPPORTED = [403, 405];
100+
const PROBE = { maxRedirects: 5, validateStatus: () => true };
101+
102+
async function reach(url) {
103+
const response = await axios.head(url, PROBE);
104+
if (!HEAD_UNSUPPORTED.includes(response.status)) return response;
105+
const ranged = await axios.get(url, { ...PROBE, headers: { Range: 'bytes=0-0' }, responseType: 'stream' });
106+
ranged.data?.destroy?.();
107+
return ranged;
108+
}
109+
110+
/**
111+
* Makes sure an install link answers, and is not a web page.
112+
* @param {string} url
113+
* @returns {Promise<void>}
114+
*/
115+
export async function verify(url) {
116+
const { status, headers } = await reach(url);
117+
if (status >= 400) throw new Error(`${url} answered ${status}`);
118+
if (/text\/html/i.test(headers?.['content-type'] ?? '')) {
119+
throw new Error(`${url} is a web page, not a script`);
120+
}
121+
}
122+
99123
const HINTS = {
100124
'Version not found': 'the file has no // @version line',
101125
'URL not found': 'the file has no // @downloadURL line, and the entry has no downloadURL',
@@ -127,12 +151,19 @@ export async function check(text, { network = true } = {}) {
127151
const invalid = new Set(problems.map(({ index }) => index));
128152
const failures = await Promise.all(data.map(async (entry, index) => {
129153
if (invalid.has(index)) return undefined;
154+
const fail = (message) => ({ index, name: entry.name, message });
155+
let link;
156+
try {
157+
link = await resolve(entry);
158+
} catch (e) {
159+
return fail(`Could not resolve an install link: ${describe(e)}`);
160+
}
130161
try {
131-
await resolve(entry);
132-
return undefined;
162+
await verify(link.url);
133163
} catch (e) {
134-
return { index, name: entry.name, message: `Could not resolve an install link: ${describe(e)}` };
164+
return fail(`The install link does not work: ${describe(e)}`);
135165
}
166+
return undefined;
136167
}));
137168
return [...problems, ...failures.filter(Boolean)];
138169
}

‎test/scripts/plugins.js‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,18 @@ describe('plugins.json', () => {
123123

124124
describe('network', () => {
125125
const original = axios.get;
126+
const originalHead = axios.head;
126127
let responses;
128+
let heads;
127129

128130
beforeEach(() => {
129131
responses = new Map();
132+
heads = new Map();
133+
axios.head = async (url) => {
134+
const answer = heads.get(url) ?? { status: 200, headers: { 'content-type': 'text/plain' } };
135+
if (answer instanceof Error) throw answer;
136+
return answer;
137+
};
130138
axios.get = async (url) => {
131139
if (!responses.has(url)) {
132140
const error = new Error('Request failed with status code 404');
@@ -140,6 +148,7 @@ describe('plugins.json', () => {
140148

141149
afterEach(() => {
142150
axios.get = original;
151+
axios.head = originalHead;
143152
});
144153

145154
describe('resolve', () => {
@@ -242,6 +251,58 @@ describe('plugins.json', () => {
242251
}]);
243252
});
244253

254+
describe('install link', () => {
255+
const install = 'https://example.com/install.user.js';
256+
const serve = () => responses.set('https://example.com/example.user.js', script([
257+
'// @version 1.0.0',
258+
`// @downloadURL ${install}`,
259+
]));
260+
const run = async () => (await check(JSON.stringify([entry()])))
261+
.map(({ message }) => message);
262+
263+
it('accepts a link that answers', async () => {
264+
serve();
265+
expect(await run()).to.deep.equal([]);
266+
});
267+
268+
it('reports a link that answers 404', async () => {
269+
serve();
270+
heads.set(install, { status: 404, headers: {} });
271+
expect(await run()).to.deep.equal([
272+
`The install link does not work: ${install} answered 404`,
273+
]);
274+
});
275+
276+
it('falls back to a ranged request when HEAD is refused', async () => {
277+
serve();
278+
heads.set(install, { status: 405, headers: {} });
279+
responses.set(install, 'script');
280+
expect(await run()).to.deep.equal([]);
281+
});
282+
283+
it('reports a link that is a web page', async () => {
284+
serve();
285+
heads.set(install, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } });
286+
expect(await run()).to.deep.equal([
287+
`The install link does not work: ${install} is a web page, not a script`,
288+
]);
289+
});
290+
291+
it('reports a link that cannot be reached', async () => {
292+
serve();
293+
heads.set(install, new Error('getaddrinfo ENOTFOUND example.com'));
294+
expect(await run()).to.deep.equal([
295+
'The install link does not work: getaddrinfo ENOTFOUND example.com',
296+
]);
297+
});
298+
299+
it('is skipped when offline', async () => {
300+
serve();
301+
heads.set(install, { status: 404, headers: {} });
302+
expect(await check(JSON.stringify([entry()]), { network: false })).to.deep.equal([]);
303+
});
304+
});
305+
245306
it('does not use the network when offline', async () => {
246307
const text = JSON.stringify([entry({ updateURL: 'https://example.com/missing.user.js' })]);
247308
expect(await check(text, { network: false })).to.deep.equal([]);

0 commit comments

Comments
 (0)