Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 233 additions & 11 deletions lib/consistency-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
// that exists, a lockfile, a lint gate. Each absence is reported with the reason it
// matters, once, and never as a score.
//
// Metadata only: no build output, no install, no bytes, no network, and no npm
// dependency. The one value from the network, the major on nextcloud/server master,
// is read by make and handed in. It runs on any Nextcloud app, one that never heard
// Metadata only: no build output, no install, no bytes, and no npm dependency.
// From the network it reads only whether the links of appinfo/info.xml answer,
// which --no-network leaves out; the major on nextcloud/server master is read by
// make and handed in. It runs on any Nextcloud app, one that never heard
// of ncmake included. What the checkout does not state is reported as undetermined
// rather than guessed. Node 18 or newer.

Expand All @@ -45,10 +46,12 @@ const defaults = {
details: false, // every table; the default is the verdict alone
json: false,
jsonFile: null,
// The links of info.xml are the one thing the analyser reads from the network
// itself; make hands NO_NETWORK=1 in as CONSISTENCY_AUDIT_NO_NETWORK.
network: process.env.CONSISTENCY_AUDIT_NO_NETWORK !== '1',
};

// The server releases this file knows about. An audit that reads no network cannot
// look them up, so the state is written down with the month it was taken in, and
// The server releases this file knows about. The audit does not look them up, so the state is written down with the month it was taken in, and
// the report names that month wherever it judges by it. The analyser is refreshed
// from the ncmake cache like every module, which is what keeps this current.
const NC = { newest: 35, oldestMaintained: 32, asOf: '2026-09' };
Expand Down Expand Up @@ -196,8 +199,7 @@ const READ_MAX = 4 * 1024 * 1024;

// The SPDX licence list, release 3.29.0 of spdx/license-list-data (2026-09-16): every
// licence id and every exception id that is not deprecated. package.json and composer.json
// may state any of them. An audit that reads no network cannot look the list up, so it is
// written down here with the release it is taken from.
// may state any of them. The audit does not look the list up, so it is written down here with the release it is taken from.
const spdxList = (ids) => new Map(ids.trim().split(/\s+/).map((id) => [id.toLowerCase(), id]));
const SPDX_LICENCES = spdxList(`
0BSD 3D-Slicer-1.0 AAL Abstyles AdaCore-doc Adobe-2006 Adobe-Display-PostScript Adobe-Glyph
Expand Down Expand Up @@ -360,9 +362,12 @@ Options:
--details print every table behind the findings
--json emit the full result as JSON and nothing else
--json-file=<file> write that JSON to <file> as well, beside the report
--no-network leave the links of appinfo/info.xml unchecked
-h, --help this text

The audit reads the checkout only: no build, no install, no network. The major
The audit reads the checkout: no build, no install. From the network it reads
only whether the links of appinfo/info.xml answer, one HEAD each, which
--no-network or CONSISTENCY_AUDIT_NO_NETWORK=1 leaves out. The major
on nextcloud/server master is handed in by make as CONSISTENCY_AUDIT_NC_MASTER,
the newest released major as CONSISTENCY_AUDIT_NC_NEWEST; without them, the
table of majors in the analyser stands in. The Node.js release schedule is
Expand All @@ -389,6 +394,7 @@ function parseArgs(argv) {
case '--json': opt.json = true; break;
case '--json-file': opt.jsonFile = val; break;
case '--details': opt.details = true; break;
case '--no-network': opt.network = false; break;
case '--top': opt.top = Number(val); break;
default:
if (key.startsWith('-')) die(`unknown option: ${key}`);
Expand Down Expand Up @@ -439,12 +445,32 @@ function readInfo(root) {
repository: tagText(xml, 'repository'),
bugs: tagText(xml, 'bugs'),
website: tagText(xml, 'website'),
links: infoLinks(xml),
nextcloud: tagAttrs(dep, 'nextcloud'),
php: tagAttrs(dep, 'php'),
libs: tagTexts(dep, 'lib').map((l) => l.toLowerCase()),
};
}

// The links of the App Store listing, each with the element it comes from:
// <website>, <bugs>, <repository>, the three of <documentation>, every
// <screenshot> with its small-thumbnail, and every <donation>.
function infoLinks(xml) {
const out = [];
const add = (element, url) => { if (url && url.trim()) out.push({ element, url: decodeEntities(url.trim()) }); };
for (const name of ['website', 'bugs', 'repository']) for (const u of tagTexts(xml, name)) add(`<${name}>`, u);
const doc = xml.match(/<documentation>[\s\S]*?<\/documentation>/)?.[0] ?? '';
for (const name of ['user', 'admin', 'developer']) for (const u of tagTexts(doc, name)) add(`<documentation><${name}>`, u);
for (const m of xml.matchAll(/<screenshot(\s[^>]*)?>([\s\S]*?)<\/screenshot>/g)) {
add('<screenshot>', m[2]);
const thumb = /\bsmall-thumbnail\s*=\s*"([^"]*)"/.exec(m[1] ?? '')?.[1];
if (thumb) add('<screenshot small-thumbnail>', thumb);
}
for (const u of tagTexts(xml, 'donation')) add('<donation>', u);
return out;
}
const decodeEntities = (s) => s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'");

// A file that exists but does not parse is a finding of its own, so a broken JSON
// is carried as broken instead of counting as absent.
function readJson(root, name) {
Expand Down Expand Up @@ -1135,6 +1161,7 @@ function audit(opt) {
repository: info.repository,
bugs: info.bugs,
website: info.website,
links: info.links,
nextcloud: info.nextcloud ? { min: info.nextcloud['min-version'] ?? null, max: info.nextcloud['max-version'] ?? null } : null,
php: info.php ? { min: info.php['min-version'] ?? null, max: info.php['max-version'] ?? null } : null,
libs: info.libs,
Expand Down Expand Up @@ -1177,6 +1204,106 @@ function audit(opt) {
};
}

// == The links of info.xml ==
// Whether each link of the App Store listing leads anywhere: one HEAD per link,
// a GET where a server refuses HEAD, redirects followed one hop at a time so a
// permanent one can be named, and each URL asked once per run. The one network
// read of the audit; --no-network leaves it out.
const LINK_TIMEOUT = 10000;
const LINK_HOPS = 5;
const LINK_UA = 'ncmake consistency-audit (+https://github.com/ernolf/ncmake)';

async function request(url, method) {
const res = await fetch(url, { method, redirect: 'manual', signal: AbortSignal.timeout(LINK_TIMEOUT), headers: { 'user-agent': LINK_UA } });
res.body?.cancel().catch(() => {});
return res;
}

// What a failed request says about the link: a host that does not resolve is
// dead, a timeout or a refused connection may be temporary.
function failure(e) {
if (e?.name === 'TimeoutError' || e?.name === 'AbortError') return { error: 'timeout' };
const code = e?.cause?.code ?? e?.code ?? null;
if (code === 'ENOTFOUND') return { error: 'unresolved' };
return { error: 'connection', code };
}

async function probe(url) {
let current = url;
let permanent = true;
for (let hop = 0; hop <= LINK_HOPS; hop++) {
let res;
try {
res = await request(current, 'HEAD');
if ([400, 403, 405, 501].includes(res.status)) res = await request(current, 'GET');
} catch (e) {
return { ...failure(e), final: current, hops: hop };
}
const location = res.headers.get('location');
if (res.status >= 300 && res.status < 400 && location) {
permanent &&= res.status === 301 || res.status === 308;
try {
current = new URL(location, current).href;
} catch {
return { status: res.status, final: current, hops: hop };
}
continue;
}
return { status: res.status, type: res.headers.get('content-type'), final: current, hops: hop, permanent: hop > 0 && permanent };
}
return { error: 'redirects', final: current, hops: LINK_HOPS };
}

// The repository of origin, owner/repo, for the screenshots pinned to a release
// tag of it, which make version moves along with the version (#304).
function originSlug(root) {
const r = spawnSync('git', ['-c', 'safe.directory=*', '-C', root, 'remote', 'get-url', 'origin'], { encoding: 'utf8' });
if (r.status !== 0) return null;
return r.stdout.trim().replace(/^git@github\.com:/, '').replace(/^https:\/\/github\.com\//, '').replace(/\.git$/, '') || null;
}
const hasTag = (root, tag) => spawnSync('git', ['-c', 'safe.directory=*', '-C', root, 'rev-parse', '-q', '--verify', `refs/tags/${tag}`]).status === 0;

// A screenshot pinned to a release tag of the app's own repository that does not
// answer, while this checkout has no such tag: make version moved it to the next
// release, which is not tagged yet. The URL answers once the tag is pushed; until
// then the file has to be in the working tree the tag will be cut from. A tag the
// checkout lacks but origin has, as in a shallow clone, answers and is not asked.
function pendingTag(root, slug, link) {
if (!slug || !link.element.startsWith('<screenshot')) return null;
const own = escapeRe(slug);
const m = new RegExp(`^https://(?:raw\\.githubusercontent\\.com/${own}/|cdn\\.jsdelivr\\.net/gh/${own}@)(v\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.]+)?)/([^?#]+)`, 'i').exec(link.url);
if (!m || hasTag(root, m[1])) return null;
const file = decodeURIComponent(m[2]);
return { tag: m[1], path: file, present: fs.existsSync(path.join(root, file)) };
}

async function checkLinks(r, opt) {
const links = r.app?.links ?? [];
if (!links.length || !opt.network) return { checked: false, network: opt.network, list: links.map((l) => ({ ...l })) };
const slug = originSlug(r.root);
const asked = new Map();
const ask = (url) => {
if (!asked.has(url)) asked.set(url, probe(url));
return asked.get(url);
};
const list = await Promise.all(links.map(async (l) => {
if (!/^https?:\/\//i.test(l.url)) return { ...l, error: 'scheme' };
const out = { ...l, ...await ask(l.url) };
if (out.status === 404) {
const pending = pendingTag(r.root, slug, l);
if (pending) return { ...l, pending };
}
// A plain http link the server does not move to https: whether the same
// address answers over https.
if (/^http:/i.test(l.url) && !out.error && out.status < 400 && !/^https:/i.test(out.final)) {
const secure = await ask(l.url.replace(/^http:/i, 'https:'));
out.https = !secure.error && secure.status < 400;
}
return out;
}));
return { checked: true, network: true, list };
}

// == Findings ==
// A finding names the files it read and what they say. Where the checkout is
// silent, the finding says what is missing and why it would be worth stating,
Expand Down Expand Up @@ -1276,14 +1403,107 @@ function findings(r) {
'A link in appinfo/info.xml is not an http(s) URL:'
+ `${shape(app.repository) ? '' : ` <repository> ${app.repository}`}`
+ `${shape(app.bugs) ? '' : ` <bugs> ${app.bugs}`}.`,
' The audit reads no network, so it checks the form only; whether the target'
+ ' answers is not determined here.',
' Neither a browser nor the App Store can follow it; whether an http(s) link'
+ ' answers is checked under link-dead.',
]);
} else {
add('ok', 'repository-link', ['appinfo/info.xml links its repository and its bug tracker.']);
}
}

// -- whether the links lead anywhere --
if (app?.links?.length) {
const L = r.links;
const n = app.links.length;
const many = n === 1 ? 'link' : `${n} links`;
const where = (l) => ` ${l.element} ${l.url}`;
const after = (l) => (l.final && l.final !== l.url ? `, after a redirect to ${l.final}` : '');
const answer = (l) => ({
unresolved: 'its host does not resolve',
timeout: `no answer within ${LINK_TIMEOUT / 1000} s`,
connection: `the connection failed${l.code ? ` (${l.code})` : ''}`,
redirects: `more than ${LINK_HOPS} redirects`,
scheme: 'not an http(s) URL',
}[l.error] ?? `HTTP ${l.status}`) + after(l);
const answered = L.list.filter((l) => l.status);
if (!L.checked) {
add('info', 'link-unchecked', [
`The ${many} of appinfo/info.xml ${n === 1 ? 'is' : 'are'} not checked: the run reads no network (NO_NETWORK=1 or --no-network).`,
]);
} else if (!answered.length && L.list.some((l) => l.error && l.error !== 'scheme' && l.error !== 'unresolved')) {
add('info', 'link-unchecked', [
`None of the ${many} of appinfo/info.xml answered, so they are left unchecked.`,
' Most likely this run has no network; NO_NETWORK=1 says so up front.',
]);
} else {
const isShot = (l) => l.element.startsWith('<screenshot');
const ownField = (l) => l.element === '<repository>' || l.element === '<bugs>';
const dead = L.list.filter((l) => (l.pending && !l.pending.present)
|| [404, 410].includes(l.status) || l.error === 'unresolved' || (l.error === 'scheme' && !ownField(l)));
const failing = L.list.filter((l) => !dead.includes(l) && !l.pending
&& (l.status >= 400 || ['timeout', 'connection', 'redirects'].includes(l.error)));
const notImage = L.list.filter((l) => isShot(l) && l.status >= 200 && l.status < 300
&& !/^image\//i.test(l.type ?? ''));
// A move that only drops .git or a trailing slash is the same address:
// <repository> names the git URL, which the forge sends to its page.
const bare = (u) => u.toLowerCase().replace(/(\.git)?\/?$/, '');
const moved = L.list.filter((l) => l.permanent && l.status >= 200 && l.status < 300 && bare(l.final) !== bare(l.url));
const insecure = L.list.filter((l) => l.https);
const pending = L.list.filter((l) => l.pending?.present);
if (dead.length) {
add('warn', 'link-dead', [
`${dead.length === 1 ? 'A link' : `${dead.length} links`} of appinfo/info.xml ${dead.length === 1 ? 'leads' : 'lead'} nowhere:`,
...dead.map((l) => `${where(l)}: ${l.pending
? `tag ${l.pending.tag} does not exist yet and ${l.pending.path} is not in the working tree`
: answer(l)}`),
' The App Store page shows these links as they are; a reader who follows one ends'
+ ' on an error page.',
]);
}
if (notImage.length) {
add('warn', 'screenshot-not-image', [
`${notImage.length === 1 ? 'A screenshot' : `${notImage.length} screenshots`} of appinfo/info.xml ${notImage.length === 1 ? 'is' : 'are'} not served as an image:`,
...notImage.map((l) => `${where(l)}: ${l.type ?? 'no content type'}${after(l)}`),
' The App Store shows the bytes behind the URL, so it needs the image itself;'
+ ' a github.com/.../blob/... URL is the page around it, raw.githubusercontent.com'
+ ' serves the file.',
]);
}
if (failing.length) {
add('info', 'link-error', [
`${failing.length === 1 ? 'A link' : `${failing.length} links`} of appinfo/info.xml did not answer as expected:`,
...failing.map((l) => `${where(l)}: ${answer(l)}`),
' This may be temporary, or a server that refuses requests without a browser;'
+ ' worth opening once by hand.',
]);
}
if (moved.length) {
add('info', 'link-redirect', [
`${moved.length === 1 ? 'A link' : `${moved.length} links`} of appinfo/info.xml ${moved.length === 1 ? 'has' : 'have'} moved permanently:`,
...moved.flatMap((l) => [where(l), ` now ${l.final}`]),
' The old address works only as long as the redirect is kept; the new one can'
+ ' be stated directly.',
]);
}
if (insecure.length) {
add('info', 'link-insecure', [
`${insecure.length === 1 ? 'A link' : `${insecure.length} links`} of appinfo/info.xml ${insecure.length === 1 ? 'uses' : 'use'} http, while the same address answers over https:`,
...insecure.map(where),
]);
}
if (pending.length) {
add('info', 'link-pending', [
`${pending.length === 1 ? 'A screenshot points' : `${pending.length} screenshots point`} to a release tag that does not exist yet:`,
...pending.map((l) => `${where(l)}: ${l.pending.path} is in the working tree`),
' make version moved them to the next release; they answer once that tag is pushed.',
]);
}
if (!dead.length && !notImage.length && !failing.length && !moved.length && !insecure.length) {
add('ok', 'link-dead', [`The ${many} of appinfo/info.xml ${n === 1 ? 'answers' : 'answer'}${pending.length ? ', or will once the next tag is pushed' : ''}.`]);
}
}
}

// -- which servers the release is for --
if (app) {
const nc = app.nextcloud;
Expand Down Expand Up @@ -1823,7 +2043,8 @@ function renderSummary(r) {
p(LEGEND);
p('The audit compares what the checkout states about itself in more than one place,');
p('and names what a release needs that the checkout does not state. It reads');
p('metadata only: no build, no install, no network. The oldest maintained server');
p('metadata only: no build, no install; from the network only whether the links');
p('of appinfo/info.xml answer. The oldest maintained server');
p(`major is as of ${r.server.asOf}, written into the analyser and refreshed with it;`);
p(`the newest released, ${r.server.newest}, is from ${r.server.newestFrom};`);
p(`the major on master, ${r.server.master.major}, is from ${r.server.master.from}.`);
Expand Down Expand Up @@ -1959,6 +2180,7 @@ function renderDetails(r, opt) {
// == Main ==
const opt = parseArgs(process.argv.slice(2));
const report = audit(opt);
report.links = await checkLinks(report, opt);
report.findings = findings(report);
const json = opt.json || opt.jsonFile ? `${JSON.stringify(report, null, 2)}\n` : null;
if (opt.json) {
Expand Down
Loading
Loading