diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b929b7..6e0f6c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,30 @@ jobs: - name: Verify portable process cleanup run: node scripts/live-bridge/matrix.mjs + + portable-storage: + runs-on: macos-15 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.19.0 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build the product + run: pnpm build + + - name: Use encrypted storage on macOS + run: node scripts/release/storage-smoke-child.mjs "$PWD" "$RUNNER_TEMP/braid-storage-smoke" + + - name: Test macOS storage security boundaries + env: + TMPDIR: ${{ runner.temp }} + run: pnpm run test -- --scope security diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7eb556..999db4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -149,10 +149,6 @@ jobs: os: macos-15 platform: darwin architecture: arm64 - - id: windows-x64 - os: windows-latest - platform: win32 - architecture: x64 runs-on: ${{ matrix.os }} timeout-minutes: 30 permissions: @@ -281,10 +277,6 @@ jobs: os: macos-15 platform: darwin architecture: arm64 - - id: windows-x64 - os: windows-latest - platform: win32 - architecture: x64 runs-on: ${{ matrix.os }} timeout-minutes: 30 permissions: @@ -474,7 +466,7 @@ jobs: VERSION: ${{ needs.candidate.outputs.version }} run: | TAG="v$VERSION" - NOTES="Published @tangle-network/braid@$VERSION from commit ${{ inputs.commit }}. The exact package completed candidate and registry install/use checks on Linux x64, macOS arm64, and Windows x64." + NOTES="Published @tangle-network/braid@$VERSION from commit ${{ inputs.commit }}. The exact package completed candidate and registry install/use checks on Linux x64 and macOS arm64." if git rev-parse "$TAG" >/dev/null 2>&1; then test "$(git rev-list -n 1 "$TAG")" = "${{ inputs.commit }}" else diff --git a/README.md b/README.md index 98c873d..23ea23c 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,10 @@ A concrete local route is `AgentProfile` with `harness: 'pi'` → Braid admissio Braid requires Node.js 22.19 or newer. +Current validated release targets are Linux x64 and macOS arm64. + +npm rejects Windows installation because encrypted state cannot yet meet Braid's path-race boundary there. + ```bash npm install --global @tangle-network/braid braid diff --git a/THIRD_PARTY_LICENSES.json b/THIRD_PARTY_LICENSES.json index c33eca3..49614bd 100644 --- a/THIRD_PARTY_LICENSES.json +++ b/THIRD_PARTY_LICENSES.json @@ -21,6 +21,10 @@ "license": "MIT", "name": "@hono/node-server" }, + { + "license": "MIT", + "name": "@koromix/koffi-linux-x64" + }, { "license": "MIT", "name": "@napi-rs/keyring-linux-x64-gnu" @@ -141,6 +145,10 @@ "license": "MIT", "name": "is-any-array" }, + { + "license": "MIT", + "name": "koffi" + }, { "license": "MIT", "name": "linear-sum-assignment" diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d419524..4035ddf 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -14,6 +14,7 @@ The implementation is expected to depend on and adapt behavior from the followin | [Microsoft Aspire](https://github.com/dotnet/aspire) | Windows Job Object creation and cleanup pattern for portable release proof | `dotnet/aspire@be77aa36daf995fae0e72091141410c7082fcba3` | MIT | | [`better-sqlite3-multiple-ciphers`](https://github.com/m4heshd/better-sqlite3-multiple-ciphers) | Pinned production SQLite binding with SQLCipher-compatible encryption | `better-sqlite3-multiple-ciphers@13.0.3` | MIT | | [`@napi-rs/keyring`](https://github.com/Brooooooklyn/keyring-node) | Native macOS Keychain, Linux Secret Service, and Windows Credential Manager access without passing secrets through command arguments | `@napi-rs/keyring@1.3.0` | MIT | +| [Koffi](https://github.com/Koromix/koffi) | Prebuilt Node FFI for operating system `openat`, `mkdirat`, `linkat`, `renameat`, and `unlinkat` calls | `koffi@3.1.4` | MIT | ## Attribution rule @@ -28,7 +29,9 @@ The original copyright and license notice must remain with every substantial cop A dependency declared in `package.json` does not require a file-level source header, but its license must remain represented in the generated release license inventory. -W5 adds the pinned `better-sqlite3-multiple-ciphers@13.0.3` and `@napi-rs/keyring@1.3.0` production dependencies and no new copied source. +W5 adds the pinned `better-sqlite3-multiple-ciphers@13.0.3`, `@napi-rs/keyring@1.3.0`, and `koffi@3.1.4` production dependencies. + +It contains no copied source from these packages. Its coordinator, storage port, credential port, and release scripts use Braid code and Node.js platform APIs around that binding. diff --git a/docs/03-architecture.md b/docs/03-architecture.md index 43ef77a..10ca07b 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -604,7 +604,7 @@ Raw trace export is a separate explicit action with a preview of included conten The required installation route is `npm install --global @tangle-network/braid` with binary name `braid`. -Release verification covers clean installations on current supported macOS arm64, Linux x64, and Windows x64 environments. +Release verification covers clean installations on current supported macOS arm64 and Linux x64 environments. The package refuses to run against a shared contract version outside its tested range and explains the supported range. diff --git a/docs/07-security-and-privacy.md b/docs/07-security-and-privacy.md index 38ce2da..f93bd81 100644 --- a/docs/07-security-and-privacy.md +++ b/docs/07-security-and-privacy.md @@ -171,7 +171,19 @@ Trust review includes `.braid/config.json`, selected profile sources, hooks, loc All Braid local paths are converted to canonical absolute paths before policy checks. -Operations use file descriptors or no-follow flags where available and recheck identity before mutation to resist symlink replacement. +Linux and macOS path operations call the operating system's descriptor-relative functions through Koffi. + +Each path component opens below its parent descriptor with no-follow flags. + +Current npm releases support Linux and macOS. +Inherited key descriptors use procfs on Linux and `F_GETPATH` on macOS before Braid checks that the key stays outside the workspace. +Linux reopens the validated SQLite descriptor through procfs. +macOS resolves the validated descriptor because stock SQLite needs normal journal and WAL paths. +Braid then rejects startup unless SQLite's live database descriptor matches the validated inode. +The agent filesystem jail cannot reach Braid's external state directory. +A hostile process running as the same operating-system user is outside this boundary. + +The package rejects Windows until Braid has an equivalent native path-opening primitive. Writes reject path traversal, NUL bytes, device files, sockets, FIFOs, hard-link surprises where detectable, and destinations outside allowed roots. diff --git a/docs/08-verification.md b/docs/08-verification.md index 2894f25..de682da 100644 --- a/docs/08-verification.md +++ b/docs/08-verification.md @@ -349,7 +349,7 @@ A failing or uncalibrated judge blocks semantic claims but cannot override passi ### Layer 9: installation and release checks -The exact npm tarball installs in clean current supported macOS arm64, Linux x64, and Windows x64 environments. +The exact npm tarball installs in clean current supported macOS arm64 and Linux x64 environments. Each environment verifies native database encryption, terminal startup, headless turn, path handling, credential adapter behavior, update check disablement, and uninstall without deleting user data. @@ -540,9 +540,9 @@ The candidate job runs `pnpm check` once, then `pnpm release:prepare` builds and A code-free job endorses that exact package before publication. -After npm publication, the same clean-install, plain-flow, encrypted-storage, digest, architecture, and cleanup smoke runs for the candidate and registry package on Linux x64, macOS arm64, and Windows x64. +After npm publication, the same clean-install, plain-flow, encrypted-storage, digest, architecture, and cleanup smoke runs for the candidate and registry package on Linux x64 and macOS arm64. -The publication process validates those six records, the package SHA-256, and npm provenance. +The publication process validates those four records, the package SHA-256, and npm provenance. It writes `publication/proof.json` below the external artifact directory. @@ -599,7 +599,7 @@ Each check records identifier, category, required status, command, working direc An audit archive includes every check field and output digest. -The publication endorsement covers the exact package, package manifest, six platform-use records, and npm provenance. +The publication endorsement covers the exact package, package manifest, four platform-use records, and npm provenance. The publication and tag jobs accept only the public key pinned in `release/endorsement-public-key.pem` and recompute the complete index before accepting its signature. diff --git a/docs/09-delivery-plan.md b/docs/09-delivery-plan.md index 1f477dc..30b0137 100644 --- a/docs/09-delivery-plan.md +++ b/docs/09-delivery-plan.md @@ -405,8 +405,8 @@ All owning repositories for final compatible releases, then `tangle-network/brai - Endorse the exact candidate package and package manifest in an isolated code-free job. - Publish `@tangle-network/braid` with npm provenance. - Download the registry package in clean supported environments and repeat the post-publication smoke. -- Require matching candidate and registry package digests plus successful plain messaging, encrypted storage, and temporary-state cleanup on Linux x64, macOS arm64, and Windows x64. -- Validate the six platform results and npm provenance in one publication record. +- Require matching candidate and registry package digests plus successful plain messaging, encrypted storage, and temporary-state cleanup on Linux x64 and macOS arm64. +- Validate the four platform results and npm provenance in one publication record. - Endorse that fixed release bundle in an isolated code-free job. - Tag the source commit and attach the package, publication records, screenshots, and flow recording. @@ -414,7 +414,7 @@ All owning repositories for final compatible releases, then `tangle-network/brai The exact main commit passes `pnpm check` and `pnpm release:prepare`. -All three candidate checks and all three registry checks must pass. +Both candidate checks and both registry checks must pass. The registry package SHA-256 must match the approved candidate. diff --git a/package.json b/package.json index f9c784f..ec8b377 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,10 @@ "engines": { "node": ">=22.19.0" }, + "os": [ + "darwin", + "linux" + ], "packageManager": "pnpm@11.18.0", "license": "MIT", "repository": { @@ -100,7 +104,8 @@ "@tangle-network/agent-runtime": "0.132.0", "@tangle-network/sandbox": "0.19.4", "better-sqlite3-multiple-ciphers": "13.0.3", - "chalk": "6.0.0" + "chalk": "6.0.0", + "koffi": "3.1.4" }, "devDependencies": { "@biomejs/biome": "2.5.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 934a448..7d9c9d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: chalk: specifier: 6.0.0 version: 6.0.0 + koffi: + specifier: 3.1.4 + version: 3.1.4 devDependencies: '@biomejs/biome': specifier: 2.5.7 @@ -341,6 +344,81 @@ packages: peerDependencies: hono: 4.13.0 + '@koromix/koffi-darwin-arm64@3.1.4': + resolution: {integrity: sha512-/9o0uahf25sNXz7CczfMAsgdHrrrkDK3/d1W5ygJUC7QnpWo80103yTYpYahWP3vTABK5yjzKtURgssv1paskA==} + cpu: [arm64] + os: [darwin] + + '@koromix/koffi-darwin-x64@3.1.4': + resolution: {integrity: sha512-6IOhfAHbrySr6lYRU720Hg+IMQvtMpN08k9Ppf9WF8NxYRdHLnW1FJm7zCbClfrwudtjhS/piwDYwgAkO5u8cg==} + cpu: [x64] + os: [darwin] + + '@koromix/koffi-freebsd-arm64@3.1.4': + resolution: {integrity: sha512-JKCWC0awdVvq7Nd/etn4PXFTa7uvyHn7IzqtaOZ3r4dJRdwQVby7Ai/wsQo8UUrJfAYlALkLYgFgU8wgsnAE/A==} + cpu: [arm64] + os: [freebsd] + + '@koromix/koffi-freebsd-ia32@3.1.4': + resolution: {integrity: sha512-gU9pShDRLMZzftdGW+mTzyL8Cpa/7nzHPHe5vFakjGgtIzVFzdFBqwli4oB+tFsx44W1VqMMlvMMVlnz54ERiQ==} + cpu: [ia32] + os: [freebsd] + + '@koromix/koffi-freebsd-x64@3.1.4': + resolution: {integrity: sha512-2kppLX97xBM3WoQET6noN4W02zT2fkFRXHYluAwcCcmkEax8AVJ1CYs6hxcZ3kaNPc+5P7yMw3V/b1lg2v3aMw==} + cpu: [x64] + os: [freebsd] + + '@koromix/koffi-linux-arm64@3.1.4': + resolution: {integrity: sha512-yYbypuGVGqrNchkAMY59kj+7TZ1c1u9lXRG1+74X9T8G4rOaushoVONNYLuu+ygpbwsKzz/NvEDtRioRU/dQlQ==} + cpu: [arm64] + os: [linux] + + '@koromix/koffi-linux-ia32@3.1.4': + resolution: {integrity: sha512-IoA/8Qfc6ZEmwMw2Nf4aSp9RfJnxh0UHhdqD4FsVXm0vC797kLMuzj744vv5tll+waVfjrU10jREqjtnMVFoQw==} + cpu: [ia32] + os: [linux] + + '@koromix/koffi-linux-loong64@3.1.4': + resolution: {integrity: sha512-ZUTdea+9dg6CV9J9CIGbhTh0FtSBgvcGKqDrlp9BVQF71jEDKOri1by/TrDe8yQUyC5kzWN8vWnkzES5wT0xDg==} + cpu: [loong64] + os: [linux] + + '@koromix/koffi-linux-riscv64@3.1.4': + resolution: {integrity: sha512-CINyyhNYV/8MX52MGhYcik2G6PXH+KEU2JEO7dOONlsGol4lSGyW40RvYA4RQgNYk8q8imGSEScL08X8eOXnaA==} + cpu: [riscv64] + os: [linux] + + '@koromix/koffi-linux-x64@3.1.4': + resolution: {integrity: sha512-x3XnAy/tUTTCX/gMpV7VJNpOQIVQvzNhNYDrpyIeS9Q8/f1qLsE0vp0tj7A/YEDIfMVLqoJtyamfRJc04+vk4w==} + cpu: [x64] + os: [linux] + + '@koromix/koffi-openbsd-ia32@3.1.4': + resolution: {integrity: sha512-r9p/fffvmBm7+iT5BZ+c17gZJ280jvmbinrPZqjG14rF9I4lk7xrlV79YfsexkeN4mcPjF2hSPtbMNFBoQU3Dw==} + cpu: [ia32] + os: [openbsd] + + '@koromix/koffi-openbsd-x64@3.1.4': + resolution: {integrity: sha512-SNp5AxOzheC2YaWPu3Y86wxRHHWf6V9NMl5Ot5nu9OpnP61Yinzug7JwsCeXtcZZTbKLsfsWoT7y4n17UYpOVA==} + cpu: [x64] + os: [openbsd] + + '@koromix/koffi-win32-arm64@3.1.4': + resolution: {integrity: sha512-oS8ETU35AelOD6DY7xmmz9qq26Xl38upXWiZbsdxbtH9UEIY0QpenQOuCK/0+q4CtfiLorRUlglGkO9YgPAIeA==} + cpu: [arm64] + os: [win32] + + '@koromix/koffi-win32-ia32@3.1.4': + resolution: {integrity: sha512-zd7Qh8s4fzblD9zzuDf44XCbujYg3QrffhgcNJg79/YC6ABT2m0CUtX4yFic9EWm2ps8NPAM25kCTCXPpt3eaw==} + cpu: [ia32] + os: [win32] + + '@koromix/koffi-win32-x64@3.1.4': + resolution: {integrity: sha512-BPeQXc1bRd0QBOklvsP+AjoRnUzKbPNE6rfx7VNxrebhh09MKld2ibstgKWn6ejQLEcfKEoUJ+WAWIhX4AOsIg==} + cpu: [x64] + os: [win32] + '@napi-rs/keyring-darwin-arm64@1.3.0': resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} engines: {node: '>= 10'} @@ -704,6 +782,9 @@ packages: is-any-array@3.0.0: resolution: {integrity: sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww==} + koffi@3.1.4: + resolution: {integrity: sha512-KHX39XIg7afe8ds+0MHPoLiKR9dCzsVK4oAmBUSaeJlcX0xur22f15C2DILbZ6GJ9eyqC+e6Sb1cTG7M17z+Tg==} + linear-sum-assignment@1.0.9: resolution: {integrity: sha512-1T2Ek3sxpt2mBHeBFMRJEikiIK/yIOwf+mrxv/DkAU/5ddnCMndZL//hFH7QuHa1tbaQADzsf9t7rkGZKqoFfQ==} @@ -955,6 +1036,51 @@ snapshots: dependencies: hono: 4.13.0 + '@koromix/koffi-darwin-arm64@3.1.4': + optional: true + + '@koromix/koffi-darwin-x64@3.1.4': + optional: true + + '@koromix/koffi-freebsd-arm64@3.1.4': + optional: true + + '@koromix/koffi-freebsd-ia32@3.1.4': + optional: true + + '@koromix/koffi-freebsd-x64@3.1.4': + optional: true + + '@koromix/koffi-linux-arm64@3.1.4': + optional: true + + '@koromix/koffi-linux-ia32@3.1.4': + optional: true + + '@koromix/koffi-linux-loong64@3.1.4': + optional: true + + '@koromix/koffi-linux-riscv64@3.1.4': + optional: true + + '@koromix/koffi-linux-x64@3.1.4': + optional: true + + '@koromix/koffi-openbsd-ia32@3.1.4': + optional: true + + '@koromix/koffi-openbsd-x64@3.1.4': + optional: true + + '@koromix/koffi-win32-arm64@3.1.4': + optional: true + + '@koromix/koffi-win32-ia32@3.1.4': + optional: true + + '@koromix/koffi-win32-x64@3.1.4': + optional: true + '@napi-rs/keyring-darwin-arm64@1.3.0': optional: true @@ -1230,6 +1356,24 @@ snapshots: is-any-array@3.0.0: {} + koffi@3.1.4: + optionalDependencies: + '@koromix/koffi-darwin-arm64': 3.1.4 + '@koromix/koffi-darwin-x64': 3.1.4 + '@koromix/koffi-freebsd-arm64': 3.1.4 + '@koromix/koffi-freebsd-ia32': 3.1.4 + '@koromix/koffi-freebsd-x64': 3.1.4 + '@koromix/koffi-linux-arm64': 3.1.4 + '@koromix/koffi-linux-ia32': 3.1.4 + '@koromix/koffi-linux-loong64': 3.1.4 + '@koromix/koffi-linux-riscv64': 3.1.4 + '@koromix/koffi-linux-x64': 3.1.4 + '@koromix/koffi-openbsd-ia32': 3.1.4 + '@koromix/koffi-openbsd-x64': 3.1.4 + '@koromix/koffi-win32-arm64': 3.1.4 + '@koromix/koffi-win32-ia32': 3.1.4 + '@koromix/koffi-win32-x64': 3.1.4 + linear-sum-assignment@1.0.9: dependencies: cheminfo-types: 1.15.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 906ebb0..cdba875 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ allowBuilds: '@dataiku/uv': true better-sqlite3-multiple-ciphers: true esbuild: true + koffi: true node-pty: true ignoreScripts: false minimumReleaseAgeExclude: diff --git a/scripts/build-startup.mjs b/scripts/build-startup.mjs index 606794c..c6a2eb8 100644 --- a/scripts/build-startup.mjs +++ b/scripts/build-startup.mjs @@ -34,5 +34,6 @@ await build({ '@tangle-network/sandbox', 'better-sqlite3-multiple-ciphers', 'keytar', + 'koffi', ], }) diff --git a/scripts/release/publication-proof.mjs b/scripts/release/publication-proof.mjs index 9f6c50d..f71f9e4 100644 --- a/scripts/release/publication-proof.mjs +++ b/scripts/release/publication-proof.mjs @@ -15,7 +15,6 @@ import { readAndValidateNpmProvenance } from './npm-provenance.mjs' export const REQUIRED_RELEASE_TARGETS = Object.freeze([ Object.freeze({ id: 'linux-x64', platform: 'linux', architecture: 'x64' }), Object.freeze({ id: 'macos-arm64', platform: 'darwin', architecture: 'arm64' }), - Object.freeze({ id: 'windows-x64', platform: 'win32', architecture: 'x64' }), ]) function sha256(bytes) { diff --git a/scripts/release/smoke-package.mjs b/scripts/release/smoke-package.mjs index f73c0be..ef4d4cc 100644 --- a/scripts/release/smoke-package.mjs +++ b/scripts/release/smoke-package.mjs @@ -29,6 +29,7 @@ async function run(file, args, options = {}) { let stdout = '' let stderr = '' let timer + let timedOut = false child.stdout.setEncoding('utf8') child.stderr.setEncoding('utf8') child.stdout.on('data', (chunk) => { @@ -39,20 +40,26 @@ async function run(file, args, options = {}) { stderr += chunk if (stderr.length > 2 * 1024 * 1024) child.kill('SIGKILL') }) - child.once('error', reject) + child.once('error', (error) => { + clearTimeout(timer) + reject(error) + }) child.once('close', (code, signal) => { clearTimeout(timer) if (code === 0 && signal === null) resolvePromise({ stdout, stderr }) else reject( new Error( - `${file} exited with code ${String(code)} and signal ${String(signal)}\n${stdout}\n${stderr}`, + `${options.label ?? file}${timedOut ? ` timed out after ${String(options.timeoutMs ?? 120_000)} ms` : ` exited with code ${String(code)} and signal ${String(signal)}`}\n${stdout}\n${stderr}`, ), ) }) if (options.stdin !== undefined) child.stdin.end(options.stdin) else child.stdin.end() - timer = setTimeout(() => child.kill('SIGKILL'), options.timeoutMs ?? 120_000) + timer = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + }, options.timeoutMs ?? 120_000) }) } @@ -166,7 +173,11 @@ try { '--package-lock=false', tarballPath, ]) - await run(npm.file, npm.args, { cwd: smokeRoot }) + await run(npm.file, npm.args, { + cwd: smokeRoot, + label: 'Package installation', + timeoutMs: 10 * 60_000, + }) const packageRoot = join(installRoot, 'node_modules', '@tangle-network', 'braid') const packageJson = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) assert(packageJson.version === proof.version, 'Installed package version differs') diff --git a/scripts/release/storage-smoke-child.mjs b/scripts/release/storage-smoke-child.mjs index 9e23e34..f2a2b2e 100644 --- a/scripts/release/storage-smoke-child.mjs +++ b/scripts/release/storage-smoke-child.mjs @@ -1,4 +1,4 @@ -import { join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' function assert(condition, message) { @@ -10,6 +10,17 @@ assert(packageRootValue && storageRootValue, 'Package and storage roots are requ const packageRoot = resolve(packageRootValue) const storageRoot = resolve(storageRootValue) const braid = await import(pathToFileURL(join(packageRoot, 'dist', 'index.js')).href) +const privateFiles = await import( + pathToFileURL(join(packageRoot, 'dist', 'adapters', 'persistence', 'safe-file.js')).href +) +const privatePath = join(storageRoot, 'private', 'platform-smoke.json') +privateFiles.ensurePrivateDirectory(dirname(privatePath)) +privateFiles.writePrivateFile(privatePath, 'first') +assert(privateFiles.readNoFollow(privatePath, 32)?.toString('utf8') === 'first', 'Write failed') +privateFiles.replacePrivateFile(privatePath, 'second', { overwrite: true }) +assert(privateFiles.readNoFollow(privatePath, 32)?.toString('utf8') === 'second', 'Replace failed') +privateFiles.removePrivateFile(privatePath) +assert(privateFiles.readNoFollow(privatePath, 32) === undefined, 'Remove failed') const credentials = new braid.MemoryCredentialStore() const storage = await braid.openSqliteStorage({ path: join(storageRoot, 'braid.sqlite'), @@ -35,4 +46,4 @@ try { } finally { await storage.close() } -process.stdout.write(`${JSON.stringify({ encryptedStorage: true })}\n`) +process.stdout.write(`${JSON.stringify({ encryptedStorage: true, privateFiles: true })}\n`) diff --git a/scripts/release/verification-flow.mjs b/scripts/release/verification-flow.mjs index 8f943e0..6da5b2a 100644 --- a/scripts/release/verification-flow.mjs +++ b/scripts/release/verification-flow.mjs @@ -80,7 +80,7 @@ export async function verifyRelease( process.stdout.write( publicationRequired ? `Validated ${documentation.requirements.size} requirements, ${plan.checks.size} recorded checks, and ${artifactResult.artifacts.size} artifacts for @tangle-network/braid@${source.evidence.braidVersion}\n` - : `Validated the pre-publication candidate; VR-10 remains pending until three registry package smokes pass.\n`, + : `Validated the pre-publication candidate; VR-10 remains pending until every supported registry package smoke passes.\n`, ) return { ...source, ...artifactResult, ...output } } diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index f4af9d7..f8b14dc 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -381,7 +381,7 @@ if (process.env.BRAID_PACKAGE_PROOF_ISOLATED !== '1') { recursive: true, filter: (source) => !source.startsWith(join(repository, '.test-dist-')) && - !['.git', 'node_modules', 'dist', '.test-dist', 'artifacts'].some( + !['.git', 'node_modules', 'dist', '.test-dist', '.tmp', 'artifacts'].some( (excluded) => source === join(repository, excluded) || source.startsWith(`${join(repository, excluded)}${sep}`), diff --git a/src/adapters/credentials/headless-key.ts b/src/adapters/credentials/headless-key.ts index 7124b5b..e4bd969 100644 --- a/src/adapters/credentials/headless-key.ts +++ b/src/adapters/credentials/headless-key.ts @@ -5,13 +5,13 @@ import { fstatSync, lstatSync, openSync, - readlinkSync, readSync, realpathSync, statSync, } from 'node:fs' import { isAbsolute, join, parse, relative, resolve, sep } from 'node:path' import { CredentialError } from '../../ports/credentials.js' +import { descriptorTargetPath } from '../persistence/posix-at.js' export interface HeadlessKeyFileSource { readonly type: 'file' @@ -127,7 +127,7 @@ function parseKey(bytes: Buffer): Buffer { function descriptorPath(fd: number): string | undefined { try { - const path = readlinkSync(`/proc/self/fd/${fd}`) + const path = descriptorTargetPath(fd) if (path.includes(' (deleted)')) { throw new CredentialError( 'HEADLESS_KEY_FD_DELETED', diff --git a/src/adapters/persistence/posix-at.ts b/src/adapters/persistence/posix-at.ts new file mode 100644 index 0000000..f7f170b --- /dev/null +++ b/src/adapters/persistence/posix-at.ts @@ -0,0 +1,157 @@ +import { readlinkSync } from 'node:fs' + +import koffi from 'koffi' + +interface PosixAtBindings { + readonly fcntl: ( + fileDescriptor: number, + command: number, + pointerType: 'void *', + value: Buffer, + ) => number + readonly linkAt: ( + sourceDirectory: number, + source: string, + targetDirectory: number, + target: string, + flags: number, + ) => number + readonly mkdirAt: (directory: number, path: string, mode: number) => number + readonly openAt: ( + directory: number, + path: string, + flags: number, + modeType: 'unsigned int', + mode: number, + ) => number + readonly renameAt: ( + sourceDirectory: number, + source: string, + targetDirectory: number, + target: string, + ) => number + readonly unlinkAt: (directory: number, path: string, flags: number) => number +} + +let cachedBindings: PosixAtBindings | undefined + +function bindings(): PosixAtBindings { + if (process.platform !== 'darwin' && process.platform !== 'linux') { + const error = new Error( + `Descriptor-relative filesystem operations are unavailable on ${process.platform}`, + ) as NodeJS.ErrnoException + error.code = 'ENOSYS' + error.syscall = 'openat' + throw error + } + if (cachedBindings !== undefined) return cachedBindings + + const libc = koffi.load(null) + cachedBindings = { + fcntl: libc.func('int fcntl(int fileDescriptor, int command, ...)') as PosixAtBindings['fcntl'], + linkAt: libc.func( + 'int linkat(int sourceDirectory, const char *source, int targetDirectory, const char *target, int flags)', + ) as PosixAtBindings['linkAt'], + mkdirAt: libc.func( + 'int mkdirat(int directory, const char *path, unsigned int mode)', + ) as PosixAtBindings['mkdirAt'], + openAt: libc.func( + 'int openat(int directory, const char *path, int flags, ...)', + ) as PosixAtBindings['openAt'], + renameAt: libc.func( + 'int renameat(int sourceDirectory, const char *source, int targetDirectory, const char *target)', + ) as PosixAtBindings['renameAt'], + unlinkAt: libc.func( + 'int unlinkat(int directory, const char *path, int flags)', + ) as PosixAtBindings['unlinkAt'], + } + return cachedBindings +} + +const DARWIN_F_GETPATH = 50 +const DARWIN_MAX_PATH_LENGTH = 1024 +const CLOSE_ON_EXEC = process.platform === 'darwin' ? 0x01000000 : 0o2000000 + +export function descriptorTargetPath(fileDescriptor: number): string { + if (process.platform === 'linux') return readlinkSync(`/proc/self/fd/${fileDescriptor}`) + if (process.platform !== 'darwin') { + const error = new Error( + `Descriptor target paths are unavailable on ${process.platform}`, + ) as NodeJS.ErrnoException + error.code = 'ENOSYS' + error.syscall = 'fcntl' + throw error + } + + const output = Buffer.alloc(DARWIN_MAX_PATH_LENGTH) + const result = bindings().fcntl(fileDescriptor, DARWIN_F_GETPATH, 'void *', output) + if (result < 0) throw syscallError('fcntl', String(fileDescriptor)) + const terminator = output.indexOf(0) + if (terminator <= 0) { + const error = new Error('Darwin F_GETPATH returned an invalid path') as NodeJS.ErrnoException + error.code = 'EIO' + error.path = String(fileDescriptor) + error.syscall = 'fcntl' + throw error + } + return output.toString('utf8', 0, terminator) +} + +export function descriptorPath(fileDescriptor: number): string { + if (process.platform === 'linux') return `/proc/self/fd/${fileDescriptor}` + return descriptorTargetPath(fileDescriptor) +} + +function errnoCode(errno: number): string { + for (const [name, value] of Object.entries(koffi.os.errno)) { + if (value === errno && name.startsWith('E')) return name + } + return `ERRNO_${errno}` +} + +function syscallError(syscall: string, path: string): NodeJS.ErrnoException { + const errno = koffi.errno() + const code = errnoCode(errno) + const error = new Error(`${code}: ${syscall} '${path}'`) as NodeJS.ErrnoException + error.code = code + error.errno = errno + error.path = path + error.syscall = syscall + return error +} + +export function openAt(directory: number, path: string, flags: number, mode = 0): number { + const result = bindings().openAt(directory, path, flags | CLOSE_ON_EXEC, 'unsigned int', mode) + if (result < 0) throw syscallError('openat', path) + return result +} + +export function mkdirAt(directory: number, path: string, mode: number): void { + if (bindings().mkdirAt(directory, path, mode) < 0) throw syscallError('mkdirat', path) +} + +export function unlinkAt(directory: number, path: string): void { + if (bindings().unlinkAt(directory, path, 0) < 0) throw syscallError('unlinkat', path) +} + +export function renameAt( + sourceDirectory: number, + source: string, + targetDirectory: number, + target: string, +): void { + if (bindings().renameAt(sourceDirectory, source, targetDirectory, target) < 0) { + throw syscallError('renameat', target) + } +} + +export function linkAt( + sourceDirectory: number, + source: string, + targetDirectory: number, + target: string, +): void { + if (bindings().linkAt(sourceDirectory, source, targetDirectory, target, 0) < 0) { + throw syscallError('linkat', target) + } +} diff --git a/src/adapters/persistence/safe-file-descriptor.ts b/src/adapters/persistence/safe-file-descriptor.ts index 3e2dec4..5bfe7e2 100644 --- a/src/adapters/persistence/safe-file-descriptor.ts +++ b/src/adapters/persistence/safe-file-descriptor.ts @@ -1,12 +1,16 @@ -import { closeSync, constants, fstatSync, mkdirSync, openSync, unlinkSync } from 'node:fs' +import { closeSync, constants, fstatSync, openSync } from 'node:fs' import { join, parse, resolve, sep } from 'node:path' +import { + mkdirAt as mkdirRelative, + openAt as openRelative, + unlinkAt as unlinkRelative, +} from './posix-at.js' const DIRECTORY_FLAGS = constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW const LEAF_FLAGS = constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW -// Node does not expose openat/unlinkat/renameat directly. Linux procfs and macOS devfs -// provide descriptor-relative path namespaces, so every component is opened below the -// descriptor acquired for its parent; unsupported platforms reject before path I/O. +// Koffi calls the operating system's openat family. Every component remains below +// the descriptor acquired for its parent; unsupported platforms reject before path I/O. export type SafeFileErrorCode = | 'SAFE_FILE_INVALID_LIMIT' @@ -42,9 +46,8 @@ export interface OpenParent { readonly leafPath: string } -function descriptorRoot(): string { - if (process.platform === 'linux') return '/proc/self/fd' - if (process.platform === 'darwin') return '/dev/fd' +function assertSupportedPlatform(): void { + if (process.platform === 'linux' || process.platform === 'darwin') return throw new SafeFileError( 'SAFE_FILE_PATH_RACE_UNSUPPORTED', `Descriptor-relative private-file access is unavailable on ${process.platform}; refusing the unsafe path operation`, @@ -58,7 +61,7 @@ export function errorCode(error: unknown): string | undefined { } export function safePath(path: string): SafePath { - descriptorRoot() + assertSupportedPlatform() if (path.includes('\u0000')) { throw new SafeFileError( 'SAFE_FILE_INVALID_PATH', @@ -81,7 +84,7 @@ export function componentPath(path: SafePath, count: number): string { return join(path.root, ...path.components.slice(0, count)) } -export function childPath(directoryFd: number, leaf: string): string { +function assertLeaf(leaf: string): void { if ( leaf.length === 0 || leaf === '.' || @@ -94,7 +97,6 @@ export function childPath(directoryFd: number, leaf: string): string { 'Private storage path contains an invalid component', ) } - return `${descriptorRoot()}/${directoryFd}/${leaf}` } export function normalizePathError(error: unknown, path: string): unknown { @@ -135,7 +137,8 @@ export function openDirectoryComponents(path: SafePath, count: number): number { const nextPath = join(currentPath, component) let nextFd: number try { - nextFd = openSync(childPath(currentFd, component), DIRECTORY_FLAGS) + assertLeaf(component) + nextFd = openRelative(currentFd, component, DIRECTORY_FLAGS) } catch (error) { throw normalizePathError(error, nextPath) } @@ -184,16 +187,27 @@ export function openParent(path: string): OpenParent { } } -export function openLeaf(parent: OpenParent, flags: number, mode?: number): number { +export function openChild( + directoryFd: number, + leaf: string, + path: string, + flags: number, + mode?: number, +): number { + assertLeaf(leaf) try { return mode === undefined - ? openSync(childPath(parent.fd, parent.leaf), flags) - : openSync(childPath(parent.fd, parent.leaf), flags, mode) + ? openRelative(directoryFd, leaf, flags) + : openRelative(directoryFd, leaf, flags, mode) } catch (error) { - throw normalizePathError(error, parent.leafPath) + throw normalizePathError(error, path) } } +export function openLeaf(parent: OpenParent, flags: number, mode?: number): number { + return openChild(parent.fd, parent.leaf, parent.leafPath, flags, mode) +} + export function openExistingLeaf(path: string): number { const parsed = safePath(path) if (parsed.components.length === 0) { @@ -226,8 +240,9 @@ export function openExistingLeaf(path: string): number { } export function unlinkAt(parentFd: number, leaf: string, path: string): void { + assertLeaf(leaf) try { - unlinkSync(childPath(parentFd, leaf)) + unlinkRelative(parentFd, leaf) } catch (error) { if (errorCode(error) !== 'ENOENT') throw normalizePathError(error, path) } @@ -277,12 +292,13 @@ export function ensurePrivateDirectory(path: string, mode = 0o700): void { let nextFd: number | undefined for (;;) { try { - nextFd = openSync(childPath(currentFd, component), DIRECTORY_FLAGS) + assertLeaf(component) + nextFd = openRelative(currentFd, component, DIRECTORY_FLAGS) break } catch (error) { if (errorCode(error) !== 'ENOENT') throw normalizePathError(error, nextPath) try { - mkdirSync(childPath(currentFd, component), { mode }) + mkdirRelative(currentFd, component, mode) } catch (mkdirError) { if (errorCode(mkdirError) !== 'EEXIST') { throw normalizePathError(mkdirError, nextPath) @@ -290,7 +306,12 @@ export function ensurePrivateDirectory(path: string, mode = 0o700): void { } } } - requireDirectory(nextFd, nextPath) + try { + requireDirectory(nextFd, nextPath) + } catch (error) { + closeSync(nextFd) + throw error + } const previousFd = currentFd currentFd = nextFd currentPath = nextPath diff --git a/src/adapters/persistence/safe-file-io.ts b/src/adapters/persistence/safe-file-io.ts index 0a36496..f899bf9 100644 --- a/src/adapters/persistence/safe-file-io.ts +++ b/src/adapters/persistence/safe-file-io.ts @@ -5,20 +5,18 @@ import { fchmodSync, fstatSync, fsyncSync, - linkSync, - openSync, readSync, - renameSync, writeSync, } from 'node:fs' import { join } from 'node:path' +import { linkAt, renameAt } from './posix-at.js' import { - childPath, componentPath, errorCode, normalizePathError, type OpenParent, + openChild, openDirectoryComponents, openExistingLeaf, openLeaf, @@ -91,7 +89,7 @@ export function readAt( let handle: number | undefined try { try { - handle = openSync(childPath(directoryFd, leaf), safeLeafFlags()) + handle = openChild(directoryFd, leaf, path, safeLeafFlags()) } catch (error) { if (errorCode(error) === 'ENOENT') return undefined throw normalizePathError(error, path) @@ -134,15 +132,16 @@ function writeTemporary( bytes: Buffer, onPhase?: PrivateFileWriteOptions['onPhase'], ): void { + const temporaryPath = join( + componentPath(parent.path, parent.path.components.length - 1), + temporary, + ) let handle: number | undefined try { try { - handle = openSync(childPath(parent.fd, temporary), CREATE_FLAGS, 0o600) + handle = openChild(parent.fd, temporary, temporaryPath, CREATE_FLAGS, 0o600) } catch (error) { - throw normalizePathError( - error, - join(componentPath(parent.path, parent.path.components.length - 1), temporary), - ) + throw normalizePathError(error, temporaryPath) } requireRegularFile(handle, `${parent.leafPath}.${temporary}`) writeAll(handle, bytes) @@ -255,11 +254,9 @@ export function replacePrivateFile( ) } options.verify?.(written) - const source = childPath(parent.fd, temporary) - const target = childPath(parent.fd, parent.leaf) try { - if (options.overwrite) renameSync(source, target) - else linkSync(source, target) + if (options.overwrite) renameAt(parent.fd, temporary, parent.fd, parent.leaf) + else linkAt(parent.fd, temporary, parent.fd, parent.leaf) } catch (error) { throw normalizePathError(error, parent.leafPath) } diff --git a/src/adapters/persistence/safe-file.ts b/src/adapters/persistence/safe-file.ts index bcd68c7..b744c1d 100644 --- a/src/adapters/persistence/safe-file.ts +++ b/src/adapters/persistence/safe-file.ts @@ -1,8 +1,8 @@ -import { closeSync, constants, fsyncSync, openSync, writeSync } from 'node:fs' +import { closeSync, constants, fsyncSync, writeSync } from 'node:fs' import { - childPath, errorCode, normalizePathError, + openLeaf, openParent, requireRegularFile, SafeFileError, @@ -21,9 +21,9 @@ export { export { ensurePrivateFile, fsyncDirectory, - removePrivateFile, type PrivateFileWriteOptions, readNoFollow, + removePrivateFile, replacePrivateFile, writePrivateFile, } from './safe-file-io.js' @@ -47,7 +47,7 @@ export function acquirePrivateFileLock(path: string, description = 'Private stor let handle: number | undefined try { try { - handle = openSync(childPath(parent.fd, parent.leaf), CREATE_FLAGS, 0o600) + handle = openLeaf(parent, CREATE_FLAGS, 0o600) } catch (error) { if (errorCode(error) !== 'EEXIST') throw normalizePathError(error, parent.leafPath) const bytes = readAt(parent.fd, parent.leaf, parent.leafPath, 128) diff --git a/src/adapters/storage/sqlite-bound-open.ts b/src/adapters/storage/sqlite-bound-open.ts index 243727d..4a50f10 100644 --- a/src/adapters/storage/sqlite-bound-open.ts +++ b/src/adapters/storage/sqlite-bound-open.ts @@ -1,5 +1,6 @@ -import { constants, closeSync, fchmodSync, fstatSync, openSync, unlinkSync } from 'node:fs' +import { closeSync, constants, fchmodSync, fstatSync, openSync, readdirSync } from 'node:fs' import { basename, dirname, resolve } from 'node:path' +import { descriptorPath, openAt, unlinkAt } from '../persistence/posix-at.js' import type { SqliteDatabase, SqliteDatabaseFactory } from './sqlite-driver.js' import { StorageError } from './sqlite-errors.js' @@ -16,14 +17,6 @@ function unsupported(message: string): StorageError { return new StorageError('STORAGE_PATH_RACE_UNSUPPORTED', message) } -function descriptorPath(fileDescriptor: number): string { - if (process.platform === 'linux') return `/proc/self/fd/${fileDescriptor}` - if (process.platform === 'darwin' || process.platform === 'freebsd') { - return `/dev/fd/${fileDescriptor}` - } - throw unsupported('This platform has no inode-bound SQLite descriptor path') -} - function sameInode( left: ReturnType, right: ReturnType, @@ -31,6 +24,41 @@ function sameInode( return left.dev === right.dev && left.ino === right.ino } +function liveFileDescriptors(): ReadonlySet { + const directory = process.platform === 'linux' ? '/proc/self/fd' : '/dev/fd' + const descriptors = new Set() + for (const name of readdirSync(directory)) { + if (!/^\d+$/u.test(name)) continue + const descriptor = Number(name) + try { + fstatSync(descriptor) + descriptors.add(descriptor) + } catch { + // readdir can expose its own descriptor after it closes. + } + } + return descriptors +} + +function assertDatabaseDescriptorIdentity( + before: ReadonlySet, + expected: ReturnType, + path: string, +): void { + for (const descriptor of liveFileDescriptors()) { + if (before.has(descriptor)) continue + try { + if (sameInode(expected, fstatSync(descriptor))) return + } catch { + // A concurrent close cannot establish the required identity. + } + } + throw new StorageError( + 'STORAGE_PATH_RACE', + `SQLite did not open the validated database file: ${path}`, + ) +} + function assertOwnedDirectory(metadata: ReturnType, path: string): void { if (!metadata.isDirectory()) throw new StorageError('STORAGE_PATH', `Not a directory: ${path}`) if (typeof process.getuid === 'function' && metadata.uid !== process.getuid()) { @@ -59,7 +87,7 @@ function openDirectoryChain(path: string): number { let descriptor = openSync('/', REQUIRED_PARENT_FLAGS) try { for (const component of resolve(path).split('/').filter(Boolean)) { - const next = openSync(`${descriptorPath(descriptor)}/${component}`, REQUIRED_PARENT_FLAGS) + const next = openAt(descriptor, component, REQUIRED_PARENT_FLAGS) closeSync(descriptor) descriptor = next } @@ -74,22 +102,25 @@ function openDatabaseFile( parentDescriptor: number, path: string, ): { readonly fileDescriptor: number; readonly newDatabase: boolean } { - const boundPath = `${descriptorPath(parentDescriptor)}/${basename(path)}` + const name = basename(path) const createFlags = REQUIRED_FILE_FLAGS | constants.O_CREAT | constants.O_EXCL try { - return { fileDescriptor: openSync(boundPath, createFlags, 0o600), newDatabase: true } + return { fileDescriptor: openAt(parentDescriptor, name, createFlags, 0o600), newDatabase: true } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error - return { fileDescriptor: openSync(boundPath, REQUIRED_FILE_FLAGS), newDatabase: false } + return { + fileDescriptor: openAt(parentDescriptor, name, REQUIRED_FILE_FLAGS), + newDatabase: false, + } } } /** - * Opens SQLite through an inode-bound descriptor path. + * Opens SQLite from a safely validated database descriptor. * - * The native driver only accepts a filename, so the filename must refer to a - * descriptor we already opened with O_NOFOLLOW. The descriptor remains open - * until the owning storage closes the database. + * The native driver only accepts a filename. Linux reopens the descriptor. + * Darwin resolves the descriptor because SQLite needs a normal sidecar path. + * The validated descriptor remains open until the owning storage closes. */ export function openBoundSqliteDatabase( path: string, @@ -132,7 +163,9 @@ export function openBoundSqliteDatabase( ) } fchmodSync(fileDescriptor, 0o600) + const descriptorsBefore = liveFileDescriptors() database = factory(descriptorPath(fileDescriptor), { timeout }) + assertDatabaseDescriptorIdentity(descriptorsBefore, metadata, normalizedPath) return { database, fileDescriptor, newDatabase: opened.newDatabase } } catch (error) { try { @@ -143,14 +176,12 @@ export function openBoundSqliteDatabase( if (fileDescriptor !== undefined) closeSync(fileDescriptor) if (created) { try { - const currentDescriptor = openSync( - `${descriptorPath(parentDescriptor)}/${basename(normalizedPath)}`, - REQUIRED_FILE_FLAGS, - ) + const name = basename(normalizedPath) + const currentDescriptor = openAt(parentDescriptor, name, REQUIRED_FILE_FLAGS) try { const current = fstatSync(currentDescriptor) if (openedMetadata !== undefined && sameInode(openedMetadata, current)) - unlinkSync(`${descriptorPath(parentDescriptor)}/${basename(normalizedPath)}`) + unlinkAt(parentDescriptor, name) } finally { closeSync(currentDescriptor) } diff --git a/test/cli-startup.test.ts b/test/cli-startup.test.ts index 743c2cb..d9386a1 100644 --- a/test/cli-startup.test.ts +++ b/test/cli-startup.test.ts @@ -120,6 +120,7 @@ test('startup responsibilities stay split into bounded modules', async () => { assert.match(startupBuild, /minifySyntax: true/u) assert.match(startupBuild, /minifyWhitespace: true/u) assert.match(startupBuild, /minifyIdentifiers: true/u) + assert.match(startupBuild, /['"]koffi['"]/u) }) test('verification signals capture more than one atomic semantic frame', async () => { diff --git a/test/scripts.test.ts b/test/scripts.test.ts index 7a6b4f2..61f186c 100644 --- a/test/scripts.test.ts +++ b/test/scripts.test.ts @@ -623,6 +623,12 @@ test('protected live and semantic checks stay unavailable instead of becoming lo test('release keys stay isolated while publication uses the installed product', async () => { const workflow = await readFile('.github/workflows/release.yml', 'utf8') + assert.deepEqual(packageJson.os, ['darwin', 'linux']) + assert.deepEqual(REQUIRED_RELEASE_TARGETS, [ + { id: 'linux-x64', platform: 'linux', architecture: 'x64' }, + { id: 'macos-arm64', platform: 'darwin', architecture: 'arm64' }, + ]) + assert.doesNotMatch(workflow, /windows-x64|platform: win32/u) const job = (name: string, next: string) => { const start = workflow.indexOf(`\n ${name}:`) const end = workflow.indexOf(`\n ${next}:`, start + 1) @@ -834,8 +840,8 @@ test('the final release proof requires matching candidate and registry smokes on } const augmented = await applyPublicationProof({ evidence, artifactRoot, packageProof }) assert.equal(augmented.evidence.finishedAt, completedAt) - assert.equal(augmented.evidence.requirements['VR-10'].artifacts.length, 9) - assert.equal(augmented.evidence.artifacts.length, 9) + assert.equal(augmented.evidence.requirements['VR-10'].artifacts.length, 7) + assert.equal(augmented.evidence.artifacts.length, 7) const registryPath = join(artifactRoot, 'publication', 'registry', 'linux-x64.json') const mismatched = JSON.parse(await readFile(registryPath, 'utf8')) diff --git a/test/security.test.ts b/test/security.test.ts index 7479b9e..07184bf 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -1,5 +1,16 @@ import assert from 'node:assert/strict' -import { closeSync, constants, lstatSync, mkdtempSync, openSync, readFileSync } from 'node:fs' +import { + closeSync, + constants, + fstatSync, + lstatSync, + mkdtempSync, + openSync, + readdirSync, + readFileSync, + renameSync, + symlinkSync, +} from 'node:fs' import { chmod, link, @@ -16,6 +27,7 @@ import { tmpdir } from 'node:os' import { join, parse, resolve, sep } from 'node:path' import test from 'node:test' import { Worker } from 'node:worker_threads' +import koffi from 'koffi' // @ts-expect-error The scanner is a JavaScript release helper without a product declaration. import { assertNoSecretArtifacts, scanSecretArtifacts } from '../scripts/scan-secret-artifacts.mjs' import { @@ -30,6 +42,7 @@ import { type NativeKeyringEntry, type NativeKeyringEntryFactory, } from '../src/adapters/credentials/os.js' +import { openAt } from '../src/adapters/persistence/posix-at.js' import { acquirePrivateFileLock, assertNoSymlinkPath, @@ -45,7 +58,12 @@ import { } from '../src/adapters/persistence/safe-file.js' import { componentPath, safePath } from '../src/adapters/persistence/safe-file-descriptor.js' import { openSqliteStorage } from '../src/adapters/storage/sqlite.js' +import { + closeBoundSqliteDatabase, + openBoundSqliteDatabase, +} from '../src/adapters/storage/sqlite-bound-open.js' import { assertPersistablePayload } from '../src/adapters/storage/sqlite-crypto.js' +import type { SqliteDatabase } from '../src/adapters/storage/sqlite-driver.js' import { StorageError } from '../src/adapters/storage/sqlite-errors.js' import { prepareConversationImport } from '../src/app/conversation-import-document.js' import { providerEventFor } from '../src/app/run-event-mapper.js' @@ -54,6 +72,21 @@ import { TerminalControlSanitizer } from '../src/domain/terminal-sanitizer.js' import { credentialRef } from '../src/ports/credentials.js' import type { JsonValue } from '../src/ports/storage.js' +function liveDescriptorCount(): number { + const directory = process.platform === 'linux' ? '/proc/self/fd' : '/dev/fd' + let count = 0 + for (const name of readdirSync(directory)) { + if (!/^\d+$/u.test(name)) continue + try { + fstatSync(Number(name)) + count += 1 + } catch { + // readdir can expose its own descriptor after it closes. + } + } + return count +} + test('terminal control sanitization remains safe when hostile sequences split across chunks', () => { const sanitizer = new TerminalControlSanitizer() assert.equal(sanitizer.push('before\u001b]0;owned'), 'before') @@ -133,6 +166,83 @@ test('private artifact writes are mode-600, no-clobber, and symlink-safe', async } }) +test('POSIX file descriptors close on exec and failed directory checks do not leak', async () => { + if (process.platform !== 'linux' && process.platform !== 'darwin') return + const root = await mkdtemp(join(tmpdir(), 'braid-posix-descriptors-')) + const regularFile = join(root, 'regular-file') + await writeFile(regularFile, 'not a directory') + let parentDescriptor: number | undefined + let fileDescriptor: number | undefined + try { + parentDescriptor = openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)) + fileDescriptor = openAt(parentDescriptor, 'regular-file', constants.O_RDONLY) + const fcntl = koffi.load(null).func('int fcntl(int fileDescriptor, int command, ...)') as ( + descriptor: number, + command: number, + ) => number + assert.equal(fcntl(fileDescriptor, 1) & 1, 1) + closeSync(fileDescriptor) + fileDescriptor = undefined + closeSync(parentDescriptor) + parentDescriptor = undefined + + const before = liveDescriptorCount() + for (let attempt = 0; attempt < 25; attempt += 1) { + assert.throws( + () => ensurePrivateDirectory(join(regularFile, 'child')), + (error: unknown) => + error instanceof SafeFileError && error.code === 'SAFE_FILE_NOT_DIRECTORY', + ) + } + assert.equal(liveDescriptorCount(), before) + } finally { + if (fileDescriptor !== undefined) closeSync(fileDescriptor) + if (parentDescriptor !== undefined) closeSync(parentDescriptor) + await rm(root, { recursive: true, force: true }) + } +}) + +test('macOS SQLite rejects a canonical path swapped after descriptor validation', async () => { + if (process.platform !== 'darwin') return + const root = await mkdtemp(join(tmpdir(), 'braid-darwin-sqlite-swap-')) + const active = join(root, 'active') + const parked = join(root, 'parked') + const evil = join(root, 'evil') + await mkdir(active, { mode: 0o700 }) + await mkdir(evil, { mode: 0o700 }) + await writeFile(join(active, 'braid.sqlite'), 'safe', { mode: 0o600 }) + await writeFile(join(evil, 'braid.sqlite'), 'evil', { mode: 0o600 }) + let opened: ReturnType | undefined + try { + assert.throws( + () => { + opened = openBoundSqliteDatabase( + join(active, 'braid.sqlite'), + (filename) => { + renameSync(active, parked) + symlinkSync(evil, active, 'dir') + const descriptor = openSync(filename, constants.O_RDWR) + let closed = false + return { + open: true, + close: () => { + if (closed) return + closed = true + closeSync(descriptor) + }, + } as unknown as SqliteDatabase + }, + 5_000, + ) + }, + (error: unknown) => error instanceof StorageError && error.code === 'STORAGE_PATH_RACE', + ) + } finally { + if (opened !== undefined) closeBoundSqliteDatabase(opened) + await rm(root, { recursive: true, force: true }) + } +}) + test('safe component paths preserve anchors and round-trip across supported platforms', () => { if (process.platform !== 'linux' && process.platform !== 'darwin') { assert.throws(