From 3a90145f0939a12f9566e7209de3521254a50ea1 Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Wed, 12 Aug 2026 17:08:53 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=E2=9C=A8=EF=BC=9Averi?= =?UTF-8?q?fy=20only=20programs=20are=20executable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the pipeline looks at file modes, so the bit has been accumulating on files that are not programs. A shebang is the honest test of whether a file expects to be run, and it settles both directions: executable without `#!`, and `#!` without executable. Reads 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. Assisted-by: Claude-Code:claude-opus-5 --- build/tasks/verify/verify-file-modes.mts | 68 ++++++++++++++++++++++++ package-scripts.yml | 5 +- 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 build/tasks/verify/verify-file-modes.mts diff --git a/build/tasks/verify/verify-file-modes.mts b/build/tasks/verify/verify-file-modes.mts new file mode 100644 index 000000000..19c401221 --- /dev/null +++ b/build/tasks/verify/verify-file-modes.mts @@ -0,0 +1,68 @@ +/** + * @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) => { + const file = await open(path); + + 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 (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 From bb8b065a51ae964802b4356edf7f2909cdbdc656 Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Wed, 12 Aug 2026 17:08:53 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=F0=9F=94=A7=EF=BC=9As?= =?UTF-8?q?top=2023=20files=20claiming=20to=20be=20programs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What the new task finds: README.md, .gitignore, .editorconfig, the liquid layouts and includes, the VS Code and linter configs, a workflow. None of them has a shebang and none is executed; only the two post-*.sh scripts in .devcontainer are. Modes only -- no file's contents change. Assisted-by: Claude-Code:claude-opus-5 --- .browserslistrc | 0 .editorconfig | 0 .gitattributes | 0 .github/ISSUE_TEMPLATE/1-bug-report.md | 0 .github/PULL_REQUEST_TEMPLATE.md | 0 .github/dependabot.yml | 0 .github/workflows/lint-and-test.yml | 0 .gitignore | 0 .markdownlint.jsonc | 0 .remarkignore | 0 .remarkrc.mjs | 0 .renovaterc.json5 | 0 .stylelintrc.json | 0 .vscode/extensions.json | 0 .vscode/keybindings.json | 0 README.md | 0 _includes/footer.liquid | 0 _includes/head.liquid | 0 _includes/header.liquid | 0 _layouts/default.liquid | 0 _layouts/docs.liquid | 0 _layouts/page.liquid | 0 project-terms.txt | 0 23 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 .browserslistrc mode change 100755 => 100644 .editorconfig mode change 100755 => 100644 .gitattributes mode change 100755 => 100644 .github/ISSUE_TEMPLATE/1-bug-report.md mode change 100755 => 100644 .github/PULL_REQUEST_TEMPLATE.md mode change 100755 => 100644 .github/dependabot.yml mode change 100755 => 100644 .github/workflows/lint-and-test.yml mode change 100755 => 100644 .gitignore mode change 100755 => 100644 .markdownlint.jsonc mode change 100755 => 100644 .remarkignore mode change 100755 => 100644 .remarkrc.mjs mode change 100755 => 100644 .renovaterc.json5 mode change 100755 => 100644 .stylelintrc.json mode change 100755 => 100644 .vscode/extensions.json mode change 100755 => 100644 .vscode/keybindings.json mode change 100755 => 100644 README.md mode change 100755 => 100644 _includes/footer.liquid mode change 100755 => 100644 _includes/head.liquid mode change 100755 => 100644 _includes/header.liquid mode change 100755 => 100644 _layouts/default.liquid mode change 100755 => 100644 _layouts/docs.liquid mode change 100755 => 100644 _layouts/page.liquid mode change 100755 => 100644 project-terms.txt 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/project-terms.txt b/project-terms.txt old mode 100755 new mode 100644 From 1947d4b9ff72dc1d71bcf7beafeb13c1e7691b84 Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Thu, 13 Aug 2026 03:10:17 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=F0=9F=94=A7=EF=BC=9As?= =?UTF-8?q?kip=20a=20file=20that=20is=20tracked=20but=20not=20there?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A half-finished `git rm` or an interrupted checkout leaves the index naming a file that is not on disk, and reading it for a shebang threw an unhandled ENOENT instead of saying anything useful. The mode of a file that is not there is not this task's argument to make, so it moves on. Found in review. A real offender is still caught. Assisted-by: Claude-Code:claude-opus-5 --- build/tasks/verify/verify-file-modes.mts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/build/tasks/verify/verify-file-modes.mts b/build/tasks/verify/verify-file-modes.mts index 19c401221..ca3997928 100644 --- a/build/tasks/verify/verify-file-modes.mts +++ b/build/tasks/verify/verify-file-modes.mts @@ -29,7 +29,12 @@ const tracked = execFileSync('git', ['ls-files', '--stage', '-z'], { * @returns {Promise} Whether the file opens with `#!`. */ const hasShebang = async (path: string) => { - const file = await open(path); + // 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); @@ -50,6 +55,8 @@ for (const { mode, path } of tracked) { 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) {