diff --git a/knip.config.ts b/knip.config.ts index 724aa127..4abf6edc 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -12,6 +12,9 @@ const config: KnipConfig = { // Optional security tooling invoked from npm scripts; installed on demand. 'semgrep', 'gitleaks', + // Shell builtin, not a binary: the build script sets `umask 022` so files the + // packaging targets generate are world-readable (see scripts/after-pack.cjs). + 'umask', ], // Test files are allowed to have unused exports (test helpers, fixtures). ignoreExportsUsedInFile: true, diff --git a/package.json b/package.json index 182a5082..cd566acf 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "build:mcp": "esbuild dist-electron/mcp/server.js --bundle --platform=node --format=cjs --outfile=dist-electron/mcp-server.cjs", "build:frontend": "NODE_OPTIONS='--max-old-space-size=4096' vite build --config electron/vite.config.electron.ts", "build:remote": "vite build --config src/remote/vite.config.ts", - "build": "npm run build:frontend && npm run build:remote && npm run compile && npm run build:mcp && electron-builder", + "build": "umask 022 && npm run build:frontend && npm run build:remote && npm run compile && npm run build:mcp && electron-builder", "serve": "vite preview --config electron/vite.config.electron.ts", "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", @@ -130,6 +130,7 @@ "build": { "appId": "com.parallel-code.app", "productName": "Parallel Code", + "afterPack": "./scripts/after-pack.cjs", "directories": { "buildResources": "build", "output": "release" diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs new file mode 100644 index 00000000..ea64c566 --- /dev/null +++ b/scripts/after-pack.cjs @@ -0,0 +1,46 @@ +// electron-builder afterPack hook: make the packaged Linux app world-readable. +// +// Files installed or generated under a restrictive umask keep modes without +// world-read: node_modules as npm installed them (the asar-unpacked native +// modules), build output, and the directories electron-builder creates. dpkg +// installs those modes verbatim, which leaves a `/opt/Parallel Code` the desktop +// user cannot enter. +// +// Runs after the app is packed and before the deb and AppImage targets read it. +// Files the targets generate themselves (the desktop entry, changelog) come +// later and are covered by the `umask 022` in the `build` script instead. + +const fs = require('fs'); +const path = require('path'); + +// Directories 755; files 644, or 755 where the owner could already execute them, +// so no file becomes executable that its owner could not already execute. This +// clears setuid, setgid and sticky bits; the packed tree has none (chrome-sandbox +// is packed 0755, and the deb's postinst sets 4755 only on systems without user +// namespaces). Symlinks carry no mode of their own and lchmod is not portable, so +// they are left alone. A mode that is already right is not rewritten. +function normalizePermissions(target) { + const stat = fs.lstatSync(target); + if (stat.isSymbolicLink()) return; + + const wanted = stat.isDirectory() || stat.mode & 0o100 ? 0o755 : 0o644; + if ((stat.mode & 0o7777) !== wanted) fs.chmodSync(target, wanted); + + if (stat.isDirectory()) { + for (const entry of fs.readdirSync(target)) { + normalizePermissions(path.join(target, entry)); + } + } +} + +exports.default = async function afterPack(context) { + if (context.electronPlatformName !== 'linux') return; + + normalizePermissions(context.appOutDir); + + // fpm reads the menu icon from the build resources directory directly, so its + // modes reach the package too. This changes modes in the checkout's build/; + // git records only the executable bit, which is kept, so no diff results. + const buildResources = context.packager.buildResourcesDir; + if (buildResources && fs.existsSync(buildResources)) normalizePermissions(buildResources); +};