diff --git a/.browserslistrc b/.browserslistrc old mode 100755 new mode 100644 diff --git a/.editorconfig b/.editorconfig old mode 100755 new mode 100644 diff --git a/.gitattributes b/.gitattributes old mode 100755 new mode 100644 diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.md b/.github/ISSUE_TEMPLATE/1-bug-report.md old mode 100755 new mode 100644 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md old mode 100755 new mode 100644 diff --git a/.github/dependabot.yml b/.github/dependabot.yml old mode 100755 new mode 100644 diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml old mode 100755 new mode 100644 diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc old mode 100755 new mode 100644 diff --git a/.remarkignore b/.remarkignore old mode 100755 new mode 100644 diff --git a/.remarkrc.mjs b/.remarkrc.mjs old mode 100755 new mode 100644 diff --git a/.renovaterc.json5 b/.renovaterc.json5 old mode 100755 new mode 100644 diff --git a/.stylelintrc.json b/.stylelintrc.json old mode 100755 new mode 100644 diff --git a/.vscode/extensions.json b/.vscode/extensions.json old mode 100755 new mode 100644 diff --git a/.vscode/keybindings.json b/.vscode/keybindings.json old mode 100755 new mode 100644 diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/_includes/footer.liquid b/_includes/footer.liquid old mode 100755 new mode 100644 diff --git a/_includes/head.liquid b/_includes/head.liquid old mode 100755 new mode 100644 diff --git a/_includes/header.liquid b/_includes/header.liquid old mode 100755 new mode 100644 diff --git a/_layouts/default.liquid b/_layouts/default.liquid old mode 100755 new mode 100644 diff --git a/_layouts/docs.liquid b/_layouts/docs.liquid old mode 100755 new mode 100644 diff --git a/_layouts/page.liquid b/_layouts/page.liquid old mode 100755 new mode 100644 diff --git a/build/tasks/verify/verify-file-modes.mts b/build/tasks/verify/verify-file-modes.mts new file mode 100644 index 000000000..ca3997928 --- /dev/null +++ b/build/tasks/verify/verify-file-modes.mts @@ -0,0 +1,75 @@ +/** + * @file Verify only the files meant to be run are marked executable. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/verify/verify-file-modes + */ + +import { execFileSync } from 'node:child_process'; +import { open } from 'node:fs/promises'; + +// The mode git records, rather than the mode on disk: that is what other +// clones receive, and it is the only one a checkout on a filesystem without +// permission bits still reports faithfully. +const tracked = execFileSync('git', ['ls-files', '--stage', '-z'], { + encoding: 'utf8', +}) + .split('\0') + .filter(Boolean) + .map((entry) => { + const [mode] = entry.split(' '); + + return { mode, path: entry.slice(entry.indexOf('\t') + 1) }; + }); + +/** + * Reads the first two bytes, which is all it takes to know whether a file + * expects to be run as a program. + * @param {string} path The file to inspect. + * @returns {Promise} Whether the file opens with `#!`. + */ +const hasShebang = async (path: string) => { + // Tracked but not on disk, which is what a half-finished `git rm` or an + // interrupted checkout leaves behind. Nothing to read, and the mode of a + // file that is not there is not this task's argument to make. + const file = await open(path).catch(() => undefined); + + if (file === undefined) return undefined; + + try { + const { buffer, bytesRead } = await file.read(Buffer.alloc(2), 0, 2, 0); + + return bytesRead === 2 && buffer.toString('latin1') === '#!'; + } finally { + await file.close(); + } +}; + +const offenders: string[] = []; + +for (const { mode, path } of tracked) { + // Symlinks (120000) and submodules (160000) carry neither the bit nor a + // shebang to read, so only the two regular-file modes are of interest. + if (mode !== '100755' && mode !== '100644') continue; + + const executable = mode === '100755'; + const runnable = await hasShebang(path); + + if (runnable === undefined) continue; + + if (executable && !runnable) { + offenders.push(` ${path} is executable but has no \`#!\` line`); + } else if (runnable && !executable) { + offenders.push(` ${path} opens with \`#!\` but is not executable`); + } +} + +if (offenders.length > 0) { + console.error( + `File modes disagree with what the files are:\n${offenders.join('\n')}\n\n` + + 'Run `chmod +x` or `chmod -x` to settle it. Nothing else in the ' + + 'pipeline looks at modes, which is how 23 files came to claim they ' + + 'were programs.' + ); + process.exitCode = 1; +} diff --git a/package-scripts.yml b/package-scripts.yml index 8684b917a..fc0d4b500 100644 --- a/package-scripts.yml +++ b/package-scripts.yml @@ -4,12 +4,13 @@ scripts: siteifyHealthFiles: node build/tasks/compile/siteify-health-files.mts verify: # Announces each task and names the ones that failed. Without this the - # output is a wall of tool chatter with no indication of which of the - # thirteen produced it. + # output is a wall of tool chatter with no indication of which task + # produced it. all: 'rc=0; failed=; for i in build/tasks/verify/*.mts; do echo "==> $i"; node "$i" || { rc=1; failed="$failed $i"; }; done; [ -z "$failed" ] || echo "FAILED:$failed" >&2; exit $rc' browserslist: node build/tasks/verify/verify-browserslist.mts scss: node build/tasks/verify/verify-scss.mts dockerfile: node build/tasks/verify/verify-dockerfile.mts + fileModes: node build/tasks/verify/verify-file-modes.mts htmlValidForVNU: node build/tasks/verify/verify-html-valid-for-vnu.mts js: node build/tasks/verify/verify-js.mts json: node build/tasks/verify/verify-json.mts diff --git a/project-terms.txt b/project-terms.txt old mode 100755 new mode 100644