From 49d11edd451fbe42b08a9f15dcebfe3cce33545b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 19:50:15 +0100 Subject: [PATCH] ci: add benchmarks and coverage gates --- .github/workflows/benchmarks.yml | 48 + .github/workflows/coverage.yml | 70 ++ package.json | 3 + pnpm-lock.yaml | 487 ++++++++++ scripts/benchmark.mjs | 302 +++++++ test/api-coverage.test.ts | 930 ++++++++++++++++++++ test/coverage-gaps.test.ts | 373 ++++++++ test/pinned-write-fallback-coverage.test.ts | 101 +++ test/platform-fallback-coverage.test.ts | 75 ++ vitest.config.ts | 20 + 10 files changed, 2409 insertions(+) create mode 100644 .github/workflows/benchmarks.yml create mode 100644 .github/workflows/coverage.yml create mode 100644 scripts/benchmark.mjs create mode 100644 test/api-coverage.test.ts create mode 100644 test/coverage-gaps.test.ts create mode 100644 test/pinned-write-fallback-coverage.test.ts create mode 100644 test/platform-fallback-coverage.test.ts diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..52bacaa --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,48 @@ +name: benchmarks + +on: + pull_request: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + benchmark: + name: Node 22 benchmarks + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Enable pnpm + run: | + corepack enable + corepack prepare pnpm@10.33.2 --activate + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Benchmark + env: + FS_SAFE_BENCHMARK_SAMPLES: ${{ github.event_name == 'schedule' && '3' || '1' }} + run: | + mkdir -p dist/benchmarks + pnpm benchmark -- --json dist/benchmarks/results.json --markdown dist/benchmarks/summary.md + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + with: + name: fs-safe-benchmark-${{ github.run_id }} + path: dist/benchmarks/ diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..f80126e --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,70 @@ +name: coverage + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: "43 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + coverage: + name: Node 22 coverage + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Enable pnpm + run: | + corepack enable + corepack prepare pnpm@10.33.2 --activate + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Coverage + run: pnpm test:coverage + + - name: Summarize coverage + if: always() + run: | + node <<'NODE' + const fs = require("node:fs"); + const summaryPath = "coverage/coverage-summary.json"; + if (!fs.existsSync(summaryPath)) process.exit(0); + const total = JSON.parse(fs.readFileSync(summaryPath, "utf8")).total; + const pct = (key) => total[key].pct.toFixed(2); + fs.appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + [ + "# fs-safe coverage", + "", + "| Metric | Percent |", + "|---|---:|", + `| Lines | ${pct("lines")}% |`, + `| Statements | ${pct("statements")}% |`, + `| Functions | ${pct("functions")}% |`, + `| Branches | ${pct("branches")}% |`, + "", + ].join("\n"), + ); + NODE + + - name: Upload coverage results + if: always() + uses: actions/upload-artifact@v4 + with: + name: fs-safe-coverage-${{ github.run_id }} + path: coverage/ diff --git a/package.json b/package.json index 9820ddb..b08bd5b 100644 --- a/package.json +++ b/package.json @@ -88,9 +88,11 @@ } }, "scripts": { + "benchmark": "node scripts/benchmark.mjs", "build": "tsc -p tsconfig.json", "prepack": "pnpm build", "test": "vitest run", + "test:coverage": "vitest run --coverage", "check": "pnpm build && pnpm test", "docs:site": "node scripts/build-docs-site.mjs" }, @@ -100,6 +102,7 @@ }, "devDependencies": { "@types/node": "^22.15.19", + "@vitest/coverage-v8": "3.2.4", "typescript": "^5.8.3", "vitest": "^3.1.4" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e79579a..d0c675b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@types/node': specifier: ^22.15.19 version: 22.19.17 + '@vitest/coverage-v8': + specifier: 3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@22.19.17)) typescript: specifier: ^5.8.3 version: 5.9.3 @@ -27,6 +30,31 @@ importers: packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -183,13 +211,35 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@rollup/rollup-android-arm-eabi@4.60.2': resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} cpu: [arm] @@ -340,6 +390,15 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -369,10 +428,43 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -389,9 +481,20 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -405,6 +508,15 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -429,20 +541,65 @@ packages: picomatch: optional: true + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -455,9 +612,27 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -474,9 +649,20 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -509,12 +695,29 @@ packages: safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -525,16 +728,40 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tar@7.5.13: resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} engines: {node: '>=18'} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -641,17 +868,50 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -730,12 +990,38 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 + '@istanbuljs/schema@0.1.6': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@pkgjs/parseargs@0.11.0': + optional: true + '@rollup/rollup-android-arm-eabi@4.60.2': optional: true @@ -824,6 +1110,25 @@ snapshots: dependencies: undici-types: 6.21.0 + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.17))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@22.19.17) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -866,8 +1171,36 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + cac@6.7.14: {} chai@5.3.3: @@ -882,14 +1215,32 @@ snapshots: chownr@3.0.0: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + core-util-is@1.0.3: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + debug@4.4.3: dependencies: ms: 2.1.3 deep-eql@5.0.2: {} + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + es-module-lexer@1.7.0: {} esbuild@0.27.7: @@ -931,15 +1282,66 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fsevents@2.3.3: optional: true + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + immediate@3.0.6: {} inherits@2.0.4: {} + is-fullwidth-code-point@3.0.0: {} + isarray@1.0.0: {} + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-tokens@10.0.0: {} + js-tokens@9.0.1: {} jszip@3.10.1: @@ -955,10 +1357,30 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + minipass@7.1.3: {} minizlib@3.1.0: @@ -969,8 +1391,17 @@ snapshots: nanoid@3.3.12: {} + package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + pathe@2.0.3: {} pathval@2.0.1: {} @@ -1030,24 +1461,58 @@ snapshots: safe-buffer@5.1.2: {} + semver@7.7.4: {} + setimmediate@1.0.5: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} std-env@3.10.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tar@7.5.13: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -1056,6 +1521,12 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -1151,9 +1622,25 @@ snapshots: - tsx - yaml + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + yallist@5.0.0: {} diff --git a/scripts/benchmark.mjs b/scripts/benchmark.mjs new file mode 100644 index 0000000..d20114e --- /dev/null +++ b/scripts/benchmark.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; + +const DEFAULT_ITERATIONS = 1000; +const DEFAULT_SAMPLES = 1; +const DEFAULT_WARMUP = 25; +const BYTES_PER_PAYLOAD = 128; + +function parsePositiveInteger(value, fallback) { + if (value === undefined) { + return fallback; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseArgs(argv) { + const args = { + iterations: parsePositiveInteger(process.env.FS_SAFE_BENCHMARK_ITERATIONS, DEFAULT_ITERATIONS), + samples: parsePositiveInteger(process.env.FS_SAFE_BENCHMARK_SAMPLES, DEFAULT_SAMPLES), + warmup: parsePositiveInteger(process.env.FS_SAFE_BENCHMARK_WARMUP, DEFAULT_WARMUP), + json: process.env.FS_SAFE_BENCHMARK_JSON, + markdown: process.env.FS_SAFE_BENCHMARK_MARKDOWN, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--") { + continue; + } else if (arg === "--iterations") { + args.iterations = parsePositiveInteger(argv[++i], args.iterations); + } else if (arg === "--samples") { + args.samples = parsePositiveInteger(argv[++i], args.samples); + } else if (arg === "--warmup") { + args.warmup = parsePositiveInteger(argv[++i], args.warmup); + } else if (arg === "--json") { + args.json = argv[++i]; + } else if (arg === "--markdown") { + args.markdown = argv[++i]; + } else { + throw new Error(`Unknown benchmark argument: ${arg}`); + } + } + + return args; +} + +function mean(values) { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +function formatMs(value) { + return value.toFixed(2); +} + +function formatRatio(value) { + return value.toFixed(2); +} + +async function ensureDistIsBuilt() { + const required = ["dist/root.js", "dist/regular-file.js", "dist/atomic.js", "dist/json.js"]; + const missing = required.filter((filePath) => !fsSync.existsSync(filePath)); + if (missing.length > 0) { + throw new Error(`Benchmark needs built dist files. Run pnpm build first. Missing: ${missing.join(", ")}`); + } +} + +async function timeCase(params) { + for (let i = 0; i < params.warmup; i += 1) { + await params.run(i); + } + + const sampleMs = []; + for (let sample = 0; sample < params.samples; sample += 1) { + await params.beforeSample?.(sample); + const startedAt = performance.now(); + for (let i = 0; i < params.iterations; i += 1) { + await params.run(i); + } + sampleMs.push(performance.now() - startedAt); + } + + return { + group: params.group, + name: params.name, + baseline: params.baseline, + iterations: params.iterations, + samples: params.samples, + sampleMs, + bestMs: Math.min(...sampleMs), + medianMs: median(sampleMs), + meanMs: mean(sampleMs), + }; +} + +function renderMarkdown(metadata, results) { + const baselineByGroup = new Map(); + for (const result of results) { + if (result.baseline) { + baselineByGroup.set(result.group, result.bestMs); + } + } + + const lines = [ + "# fs-safe benchmark", + "", + `Report-only microbenchmark. Each row times ${metadata.iterations} sequential iterations; lower is better.`, + "", + `Node ${metadata.node} on ${metadata.platform}/${metadata.arch}. Samples per case: ${metadata.samples}.`, + "", + "| Group | Case | Best ms | Median ms | Mean ms | vs raw best | Samples |", + "|---|---:|---:|---:|---:|---:|---|", + ]; + + for (const result of results) { + const baseline = baselineByGroup.get(result.group) ?? result.bestMs; + const ratio = baseline > 0 ? result.bestMs / baseline : 1; + lines.push( + `| ${result.group} | ${result.name} | ${formatMs(result.bestMs)} | ${formatMs(result.medianMs)} | ${formatMs(result.meanMs)} | ${formatRatio(ratio)}x | ${result.sampleMs.map(formatMs).join(", ")} |`, + ); + } + + return `${lines.join("\n")}\n`; +} + +async function writeFileEnsuringDir(filePath, content) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); +} + +async function main() { + await ensureDistIsBuilt(); + const [ + { replaceFileAtomic }, + { readRegularFile }, + { root }, + { tryReadJson, writeJson }, + ] = await Promise.all([ + import("../dist/atomic.js"), + import("../dist/regular-file.js"), + import("../dist/root.js"), + import("../dist/json.js"), + ]); + const args = parseArgs(process.argv.slice(2)); + const iterations = args.iterations; + const samples = args.samples; + const warmup = Math.min(args.warmup, iterations); + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "fs-safe-benchmark-")); + const payload = Buffer.from("x".repeat(BYTES_PER_PAYLOAD)); + const jsonPayload = { ok: true, count: 42, label: "fs-safe benchmark" }; + const jsonText = `${JSON.stringify(jsonPayload, null, 2)}\n`; + + try { + const safe = await root(workspace, { mkdir: true, hardlinks: "allow" }); + const readPath = path.join(workspace, "read.txt"); + const readRelPath = "read.txt"; + const jsonPath = path.join(workspace, "state.json"); + await fs.writeFile(readPath, payload); + await fs.writeFile(jsonPath, jsonText); + + const cases = [ + { + group: "read file", + name: "raw fs.readFile", + baseline: true, + run: async () => { + await fs.readFile(readPath); + }, + }, + { + group: "read file", + name: "readRegularFile", + run: async () => { + await readRegularFile({ filePath: readPath }); + }, + }, + { + group: "read file", + name: "root.readBytes", + run: async () => { + await safe.readBytes(readRelPath); + }, + }, + { + group: "write file", + name: "raw fs.writeFile", + baseline: true, + run: async (i) => { + await fs.writeFile(path.join(workspace, "raw-write.txt"), `${i}:${payload.toString("utf8")}`); + }, + }, + { + group: "write file", + name: "replaceFileAtomic", + run: async (i) => { + await replaceFileAtomic({ + filePath: path.join(workspace, "atomic-write.txt"), + content: `${i}:${payload.toString("utf8")}`, + }); + }, + }, + { + group: "write file", + name: "root.write", + run: async (i) => { + await safe.write("root-write.txt", `${i}:${payload.toString("utf8")}`); + }, + }, + { + group: "read json", + name: "raw readFile + JSON.parse", + baseline: true, + run: async () => { + JSON.parse(await fs.readFile(jsonPath, "utf8")); + }, + }, + { + group: "read json", + name: "tryReadJson", + run: async () => { + await tryReadJson(jsonPath); + }, + }, + { + group: "write json", + name: "raw writeFile + stringify", + baseline: true, + run: async (i) => { + await fs.writeFile( + path.join(workspace, "raw-json.json"), + `${JSON.stringify({ ...jsonPayload, count: i }, null, 2)}\n`, + ); + }, + }, + { + group: "write json", + name: "writeJson", + run: async (i) => { + await writeJson(path.join(workspace, "safe-json.json"), { ...jsonPayload, count: i }, { + trailingNewline: true, + }); + }, + }, + ]; + + const results = []; + for (const benchCase of cases) { + console.error(`benchmark: ${benchCase.group} / ${benchCase.name}`); + const result = await timeCase({ + ...benchCase, + iterations, + samples, + warmup, + }); + console.error(`benchmark: ${benchCase.name} best=${formatMs(result.bestMs)}ms`); + results.push(result); + } + + const metadata = { + iterations, + samples, + warmup, + payloadBytes: payload.byteLength, + node: process.version, + platform: process.platform, + arch: process.arch, + date: new Date().toISOString(), + }; + const output = { + metadata, + results, + }; + const markdown = renderMarkdown(metadata, results); + process.stdout.write(markdown); + + if (args.json) { + await writeFileEnsuringDir(args.json, `${JSON.stringify(output, null, 2)}\n`); + } + if (args.markdown) { + await writeFileEnsuringDir(args.markdown, markdown); + } + if (process.env.GITHUB_STEP_SUMMARY) { + await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, `\n${markdown}`); + } + } finally { + await fs.rm(workspace, { recursive: true, force: true }).catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack : error); + process.exitCode = 1; +}); diff --git a/test/api-coverage.test.ts b/test/api-coverage.test.ts new file mode 100644 index 0000000..9795684 --- /dev/null +++ b/test/api-coverage.test.ts @@ -0,0 +1,930 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Readable } from "node:stream"; +import JSZip from "jszip"; +import * as tar from "tar"; +import { afterEach, describe, expect, it } from "vitest"; +import { extractArchive } from "../src/archive.js"; +import { loadZipArchiveWithPreflight, readZipCentralDirectoryEntryCount } from "../src/archive-zip-preflight.js"; +import { copyIntoRoot, fileStore } from "../src/file-store.js"; +import { + assertCanonicalPathWithinBase, + resolveSafeInstallDir, + safeDirName, + safePathSegmentHashed, +} from "../src/install-path.js"; +import { + createAsyncLock, + readJson, + readJsonIfExists, + readJsonSync, + tryReadJson, + tryReadJsonSync, + writeJson, + writeJsonSync, + writeText, +} from "../src/json.js"; +import { jsonStore } from "../src/json-store.js"; +import { + assertNoWindowsNetworkPath, + basenameFromMediaSource, + hasEncodedFileUrlSeparator, + safeFileURLToPath, + trySafeFileURLToPath, +} from "../src/local-file-access.js"; +import { resolveLocalPathFromRootsSync } from "../src/local-roots.js"; +import { + hasNodeErrorCode, + isNotFoundPathError, + isPathInside, + isPathInsideWithRealpath, + isSymlinkOpenError, + normalizeWindowsPathForComparison, + resolveSafeBaseDir, + resolveSafeRelativePath, + safeRealpathSync, + safeStatSync, + splitSafeRelativePath, +} from "../src/path.js"; +import { assertNoHardlinkedFinalPath, assertNoPathAliasEscape } from "../src/path-policy.js"; +import { + privateFileStore, + readPrivateJsonSync, + readPrivateTextSync, + writePrivateJsonAtomicSync, + writePrivateTextAtomicSync, +} from "../src/private-file-store.js"; +import { ROOT_PATH_ALIAS_POLICIES, resolveRootPath, resolveRootPathSync } from "../src/root-path.js"; +import { + ensureDirectoryWithinRoot, + pathScope, + resolveExistingPathsWithinRoot, + resolvePathsWithinRoot, + resolvePathWithinRoot, + resolveStrictExistingPathsWithinRoot, + resolveWritablePathWithinRoot, +} from "../src/root-paths.js"; +import { replaceDirectoryAtomic } from "../src/replace-directory.js"; +import { openLocalFileSafely, readLocalFileSafely, root as openRoot } from "../src/root.js"; +import { + loadSecretFileSync, + readSecretFileSync, + tryReadSecretFileSync, + writePrivateSecretFileAtomic, +} from "../src/secret-file.js"; +import { resolveSecureTempRoot } from "../src/secure-temp-dir.js"; +import { assertNoSymlinkParents, assertNoSymlinkParentsSync } from "../src/symlink-parents.js"; +import { + appendRegularFile, + appendRegularFileSync, + readRegularFile, + readRegularFileSync, + resolveRegularFileAppendFlags, + statRegularFile, + statRegularFileSync, +} from "../src/regular-file.js"; +import { + buildRandomTempFilePath, + sanitizeTempFileName, + tempFile, + withTempFile, +} from "../src/temp-target.js"; +import { + tempWorkspace, + tempWorkspaceSync, + withTempWorkspace, + withTempWorkspaceSync, +} from "../src/private-temp-workspace.js"; +import { withTimeout } from "../src/timing.js"; + +const tempDirs = new Set(); + +async function tempRoot(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.add(dir); + return dir; +} + +afterEach(async () => { + for (const dir of tempDirs) { + await fs.rm(dir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +describe("root handle coverage", () => { + it("covers root reads, absolute reads, append newline logic, and writable handles", async () => { + const rootDir = await tempRoot("fs-safe-root-api-"); + const scoped = await openRoot(rootDir, { mkdir: true, mode: 0o640 }); + + await expect(scoped.resolve("nested/file.txt")).resolves.toBe( + path.join(await fs.realpath(rootDir), "nested/file.txt"), + ); + await scoped.ensureRoot(); + await scoped.write("nested/file.txt", "alpha"); + await scoped.append("nested/file.txt", "beta", { prependNewlineIfNeeded: true }); + await scoped.append("nested/file.txt", Buffer.from("gamma"), { prependNewlineIfNeeded: true }); + await expect(fs.readFile(path.join(rootDir, "nested/file.txt"), "utf8")).resolves.toBe( + "alpha\nbeta\ngamma", + ); + + const absolute = path.join(rootDir, "nested/file.txt"); + await expect(scoped.readAbsolute(absolute)).resolves.toMatchObject({ + realPath: await fs.realpath(absolute), + }); + await expect(scoped.reader()(absolute)).resolves.toEqual(Buffer.from("alpha\nbeta\ngamma")); + await expect(scoped.readText("nested/file.txt", { encoding: "utf8" })).resolves.toBe( + "alpha\nbeta\ngamma", + ); + + const writable = await scoped.openWritable("nested/file.txt", { truncateExisting: false }); + try { + expect(writable.createdForWrite).toBe(false); + await writable.handle.appendFile("!"); + } finally { + await writable.handle.close(); + } + const appended = await scoped.openWritable("nested/file.txt", { append: true }); + try { + await appended.handle.appendFile("?"); + } finally { + await appended[Symbol.asyncDispose](); + } + const truncated = await scoped.openWritable("nested/file.txt"); + try { + await truncated.handle.writeFile("reset"); + } finally { + await truncated.handle.close(); + } + await expect(scoped.readText("nested/file.txt", { encoding: "utf8" })).resolves.toBe("reset"); + }); + + it("covers root copy, create, JSON, missing, and type rejection paths", async () => { + const rootDir = await tempRoot("fs-safe-root-copy-"); + const sourceDir = await tempRoot("fs-safe-root-source-"); + const source = path.join(sourceDir, "source.txt"); + await fs.writeFile(source, "copy me", "utf8"); + const scoped = await openRoot(rootDir); + + await scoped.copyIn("copied/source.txt", source, { maxBytes: 16, mode: 0o600 }); + await expect(scoped.readText("copied/source.txt")).resolves.toBe("copy me"); + await expect(scoped.copyIn("too-large.txt", source, { maxBytes: 3 })).rejects.toMatchObject({ + code: "too-large", + }); + await expect(scoped.copyIn("bad.txt", sourceDir)).rejects.toMatchObject({ code: "not-file" }); + + await scoped.writeJson("json/state.json", { ok: true }, { trailingNewline: false }); + await expect(scoped.readJson("json/state.json")).resolves.toEqual({ ok: true }); + await scoped.createJson("json/new.json", { value: 1 }, { space: 0 }); + await expect(scoped.create("json/new.json", "again")).rejects.toMatchObject({ + code: "already-exists", + }); + await expect(scoped.read("missing.txt")).rejects.toMatchObject({ code: "not-found" }); + await expect(scoped.open("json")).rejects.toMatchObject({ code: "not-file" }); + await expect(scoped.mkdir(".")).rejects.toMatchObject({ code: "outside-workspace" }); + }); + + it.runIf(process.platform !== "win32")("covers root symlink, hardlink, local read, and writable rejection paths", async () => { + const rootDir = await tempRoot("fs-safe-root-errors-"); + const outside = await tempRoot("fs-safe-root-errors-outside-"); + const scoped = await openRoot(rootDir); + const inside = path.join(rootDir, "inside.txt"); + const linkInside = path.join(rootDir, "inside-link.txt"); + const hardlink = path.join(rootDir, "inside-hardlink.txt"); + await fs.writeFile(inside, "inside", "utf8"); + await fs.symlink(inside, linkInside); + + await expect(scoped.readText("inside-link.txt", { symlinks: "follow-within-root" })).resolves + .toBe("inside"); + await fs.link(inside, hardlink); + await expect(scoped.open("inside-hardlink.txt")).rejects.toMatchObject({ code: "hardlink" }); + const hardlinkOpen = await scoped.open("inside-hardlink.txt", { hardlinks: "allow" }); + expect(hardlinkOpen.realPath).toBe(await fs.realpath(hardlink)); + await hardlinkOpen.handle.close(); + await expect(scoped.copyIn("copied-hardlink.txt", hardlink)).resolves.toBeUndefined(); + await expect(scoped.copyIn("copied-hardlink.txt", hardlink, { sourceHardlinks: "allow" })) + .resolves + .toBeUndefined(); + await expect(readLocalFileSafely({ filePath: inside, maxBytes: 16 })).resolves.toMatchObject({ + realPath: await fs.realpath(inside), + }); + const opened = await openLocalFileSafely({ filePath: inside }); + await opened[Symbol.asyncDispose](); + + await fs.mkdir(path.join(rootDir, "dir")); + await expect(scoped.openWritable("dir")).rejects.toMatchObject({ code: "EISDIR" }); + await expect(scoped.openWritable("inside-hardlink.txt")).rejects.toMatchObject({ + code: "path-alias", + }); + await expect(scoped.openWritable("inside-link.txt")).rejects.toMatchObject({ + code: "path-alias", + }); + + const outsideFile = path.join(outside, "outside.txt"); + await fs.writeFile(outsideFile, "outside", "utf8"); + await fs.symlink(outsideFile, path.join(rootDir, "outside-link.txt")); + await expect(scoped.openWritable("outside-link.txt")).rejects.toMatchObject({ + code: "path-alias", + }); + }); +}); + +describe("path helpers", () => { + it("covers Windows and POSIX path decisions", async () => { + const root = await tempRoot("fs-safe-path-"); + const file = path.join(root, "file.txt"); + await fs.writeFile(file, "ok", "utf8"); + const cache = new Map(); + + expect(normalizeWindowsPathForComparison("\\\\?\\UNC\\Server\\Share\\A/../B")).toContain( + "\\\\server\\share", + ); + expect(hasNodeErrorCode(Object.assign(new Error("x"), { code: "ENOENT" }), "ENOENT")).toBe( + true, + ); + expect(isNotFoundPathError(Object.assign(new Error("x"), { code: "ENOTDIR" }))).toBe(true); + expect(isSymlinkOpenError(Object.assign(new Error("x"), { code: "ELOOP" }))).toBe(true); + expect(isPathInside(root, file)).toBe(true); + expect(resolveSafeBaseDir(root)).toBe(`${path.resolve(root)}${path.sep}`); + expect(safeRealpathSync(file, cache)).toBe(await fs.realpath(file)); + expect(safeRealpathSync(file, cache)).toBe(await fs.realpath(file)); + expect(safeRealpathSync(path.join(root, "missing"), cache)).toBeNull(); + expect(isPathInsideWithRealpath(root, file, { cache })).toBe(true); + expect(isPathInsideWithRealpath(root, path.join(root, "missing"), { requireRealpath: false })) + .toBe(true); + expect(isPathInsideWithRealpath(root, path.join(root, "missing"))).toBe(false); + expect(safeStatSync(file)?.isFile()).toBe(true); + expect(safeStatSync(path.join(root, "missing"))).toBeNull(); + expect(splitSafeRelativePath("./a//b")).toEqual(["a", "b"]); + for (const bad of ["../x", "/x", "C:\\x", "a\\b", "a\0b"]) { + expect(() => splitSafeRelativePath(bad)).toThrow(); + } + expect(resolveSafeRelativePath(root, "a/b")).toBe(path.join(root, "a", "b")); + }); +}); + +describe("root path resolution helpers", () => { + it.runIf(process.platform !== "win32")("covers canonical aliases and final symlink policies", async () => { + const base = await tempRoot("fs-safe-root-path-extra-"); + const root = path.join(base, "root"); + const outside = path.join(base, "outside"); + await fs.mkdir(root); + await fs.mkdir(outside); + await fs.writeFile(path.join(root, "file.txt"), "ok", "utf8"); + await fs.symlink(root, path.join(outside, "root-link")); + await fs.symlink(path.join(root, "file.txt"), path.join(root, "file-link")); + + await expect( + resolveRootPath({ + rootPath: root, + absolutePath: path.join(outside, "root-link", "file.txt"), + boundaryLabel: "root", + }), + ).resolves.toMatchObject({ + exists: true, + kind: "file", + relativePath: "file.txt", + }); + await expect( + resolveRootPathSync({ + rootPath: root, + absolutePath: path.join(root, "missing", "later.txt"), + boundaryLabel: "root", + }), + ).toMatchObject({ + exists: false, + kind: "missing", + relativePath: path.join("missing", "later.txt"), + }); + await expect( + resolveRootPath({ + rootPath: root, + absolutePath: path.join(root, "file-link"), + boundaryLabel: "root", + policy: ROOT_PATH_ALIAS_POLICIES.unlinkTarget, + }), + ).resolves.toMatchObject({ + exists: true, + kind: "symlink", + relativePath: "file-link", + }); + await expect( + resolveRootPath({ + rootPath: root, + absolutePath: path.join(base, "escape.txt"), + boundaryLabel: "root", + }), + ).rejects.toThrow("escapes"); + }); + + it("covers root path list, writable, existing, and scoped wrappers", async () => { + const base = await tempRoot("fs-safe-root-paths-extra-"); + const root = path.join(base, "root"); + await fs.mkdir(root); + const file = path.join(root, "file.txt"); + await fs.writeFile(file, "ok", "utf8"); + await fs.mkdir(path.join(root, "dir")); + + expect( + resolvePathWithinRoot({ + rootDir: root, + requestedPath: " ", + scopeLabel: "uploads", + }), + ).toEqual({ ok: false, error: "path is required" }); + expect( + resolvePathWithinRoot({ + rootDir: root, + requestedPath: " ", + defaultFileName: "default.txt", + scopeLabel: "uploads", + }), + ).toEqual({ ok: true, path: path.join(root, "default.txt") }); + expect( + resolvePathsWithinRoot({ + rootDir: root, + requestedPaths: ["file.txt", "../escape.txt"], + scopeLabel: "uploads", + }), + ).toMatchObject({ ok: false }); + await expect( + resolveWritablePathWithinRoot({ + rootDir: file, + requestedPath: "new.txt", + scopeLabel: "uploads", + }), + ).resolves.toMatchObject({ ok: false }); + await expect( + resolveWritablePathWithinRoot({ + rootDir: root, + requestedPath: "dir", + scopeLabel: "uploads", + }), + ).resolves.toMatchObject({ ok: false }); + await expect( + ensureDirectoryWithinRoot({ + rootDir: root, + requestedPath: "made/nested", + scopeLabel: "uploads", + mode: 0o700, + }), + ).resolves.toMatchObject({ ok: true, path: path.join(root, "made", "nested") }); + await expect( + ensureDirectoryWithinRoot({ + rootDir: root, + requestedPath: "file.txt", + scopeLabel: "uploads", + }), + ).resolves.toMatchObject({ ok: false }); + await expect( + resolveExistingPathsWithinRoot({ + rootDir: path.join(base, "missing-root"), + requestedPaths: ["missing.txt"], + scopeLabel: "uploads", + }), + ).resolves.toMatchObject({ ok: true }); + await expect( + resolveStrictExistingPathsWithinRoot({ + rootDir: root, + requestedPaths: ["dir"], + scopeLabel: "uploads", + }), + ).resolves.toMatchObject({ ok: false }); + + const scope = pathScope(root, { label: "uploads" }); + expect(scope.resolve(" ", { defaultName: "fallback.txt" })).toEqual({ + ok: true, + path: path.join(root, "fallback.txt"), + }); + expect(scope.resolveAll(["file.txt"])).toEqual({ ok: true, paths: [file] }); + await expect(scope.existing(["missing.txt"])).resolves.toEqual({ + ok: true, + paths: [path.join(root, "missing.txt")], + }); + await expect(scope.ensureDir("scoped")).resolves.toMatchObject({ + ok: true, + path: path.join(root, "scoped"), + }); + }); +}); + +describe("URL, install, and local-root helpers", () => { + it("covers local file URL parsing and install path sanitizers", async () => { + const root = await tempRoot("fs-safe-install-"); + const file = path.join(root, "hello world.txt"); + await fs.writeFile(file, "ok", "utf8"); + const fileUrl = new URL(`file://${file}`).href; + + expect(hasEncodedFileUrlSeparator("file:///tmp/a%2Fb")).toBe(true); + expect(safeFileURLToPath(fileUrl)).toBe(file); + expect(trySafeFileURLToPath("https://example.com/file")).toBeUndefined(); + expect(basenameFromMediaSource(fileUrl)).toBe("hello world.txt"); + expect(basenameFromMediaSource("plain/name.txt")).toBe("name.txt"); + expect(() => safeFileURLToPath("file://remote/share/file.txt")).toThrow("remote hosts"); + if (process.platform === "win32") { + expect(() => assertNoWindowsNetworkPath("\\\\server\\share", "Media")).toThrow(); + } else { + expect(() => assertNoWindowsNetworkPath("\\\\server\\share", "Media")).not.toThrow(); + } + + expect(safeDirName(" bad/name? ")).toBe("bad__name?"); + expect(safePathSegmentHashed("x".repeat(200))).toHaveLength(61); + expect( + resolveSafeInstallDir({ + baseDir: root, + id: "../Plugin Name", + invalidNameMessage: "bad plugin", + }), + ).toMatchObject({ ok: true, path: path.join(root, "..__Plugin Name") }); + await expect( + assertCanonicalPathWithinBase({ + baseDir: root, + candidatePath: path.join(root, "new-file.txt"), + boundaryLabel: "install root", + }), + ).resolves.toBeUndefined(); + await expect( + assertCanonicalPathWithinBase({ + baseDir: root, + candidatePath: path.dirname(root), + boundaryLabel: "install root", + }), + ).rejects.toThrow("within"); + + expect( + resolveLocalPathFromRootsSync({ + filePath: fileUrl, + roots: [new URL(`file://${root}`).href], + label: "media roots", + requireFile: true, + }), + ).toMatchObject({ path: await fs.realpath(file) }); + expect(() => + resolveLocalPathFromRootsSync({ + filePath: "bad\0path", + roots: [root], + label: "media roots", + }), + ).toThrow("NUL"); + }); +}); + +describe("ZIP preflight", () => { + it("counts central directory entries and enforces archive limits", async () => { + const zip = new JSZip(); + zip.file("a.txt", "a"); + zip.file("b.txt", "b"); + const buffer = await zip.generateAsync({ type: "nodebuffer" }); + expect(readZipCentralDirectoryEntryCount(buffer)).toBe(2); + expect(readZipCentralDirectoryEntryCount(Buffer.from("not a zip"))).toBeNull(); + await expect(loadZipArchiveWithPreflight(buffer, { maxEntries: 1 })).rejects.toMatchObject({ + code: "archive-entry-count-exceeds-limit", + }); + await expect(loadZipArchiveWithPreflight(buffer, { maxArchiveBytes: 4 })).rejects.toMatchObject({ + code: "archive-size-exceeds-limit", + }); + await expect(loadZipArchiveWithPreflight(buffer, { maxEntries: 3 })).resolves.toBeInstanceOf( + JSZip, + ); + }); +}); + +describe("archive extraction", () => { + it("extracts tar archives and enforces tar limits", async () => { + const root = await tempRoot("fs-safe-tar-"); + const packageDir = path.join(root, "package"); + const archivePath = path.join(root, "pkg.tar"); + const destDir = path.join(root, "dest"); + await fs.mkdir(path.join(packageDir, "nested"), { recursive: true }); + await fs.mkdir(destDir); + await fs.writeFile(path.join(packageDir, "nested", "file.txt"), "tar data", "utf8"); + await tar.c({ cwd: root, file: archivePath }, ["package"]); + + await extractArchive({ + archivePath, + destDir, + kind: "tar", + stripComponents: 1, + timeoutMs: 15_000, + limits: { maxEntries: 8, maxExtractedBytes: 1024, maxEntryBytes: 1024 }, + }); + await expect(fs.readFile(path.join(destDir, "nested", "file.txt"), "utf8")).resolves.toBe( + "tar data", + ); + const smallDest = path.join(root, "small"); + await fs.mkdir(smallDest); + await expect( + extractArchive({ + archivePath, + destDir: smallDest, + kind: "tar", + timeoutMs: 15_000, + limits: { maxArchiveBytes: 4 }, + }), + ).rejects.toMatchObject({ code: "archive-size-exceeds-limit" }); + await expect( + extractArchive({ + archivePath: path.join(root, "pkg.txt"), + destDir, + timeoutMs: 1, + }), + ).rejects.toThrow("unsupported archive"); + }); +}); + +describe("JSON and regular-file helpers", () => { + it("covers JSON success, parse, read, and lock behavior", async () => { + const root = await tempRoot("fs-safe-json-extra-"); + const file = path.join(root, "state", "value.json"); + await writeJson(file, { ok: true }, { trailingNewline: true }); + await expect(readJson(file)).resolves.toEqual({ ok: true }); + await expect(readJsonIfExists(path.join(root, "missing.json"))).resolves.toBeNull(); + await expect(tryReadJson(file)).resolves.toEqual({ ok: true }); + await fs.writeFile(file, "{bad", "utf8"); + await expect(readJson(file)).rejects.toMatchObject({ reason: "parse" }); + await expect(readJson(path.join(root, "missing.json"))).rejects.toMatchObject({ + reason: "read", + }); + await expect(readJsonIfExists(file)).rejects.toMatchObject({ reason: "parse" }); + await expect(tryReadJson(file)).resolves.toBeNull(); + + const syncFile = path.join(root, "sync", "value.json"); + writeJsonSync(syncFile, { sync: true }); + expect(readJsonSync(syncFile)).toEqual({ sync: true }); + expect(tryReadJsonSync(syncFile)).toEqual({ sync: true }); + await writeText(path.join(root, "text.txt"), "text", { trailingNewline: false }); + + const calls: string[] = []; + const lock = createAsyncLock(); + await Promise.all([ + lock(async () => { + calls.push("first"); + }), + lock(async () => { + calls.push("second"); + return "value"; + }), + ]); + expect(calls).toEqual(["first", "second"]); + }); + + it("covers json store fallback, unlocked writes, locked writes, and updates", async () => { + const root = await tempRoot("fs-safe-json-store-extra-"); + const fallback = { count: 1 }; + const store = jsonStore({ + filePath: path.join(root, "state.json"), + fallback, + lock: { + managerKey: `coverage-json-store-${Date.now()}-${Math.random()}`, + staleMs: 60_000, + timeoutMs: 1000, + }, + }); + const first = await store.read(); + expect(first).toEqual({ count: 1 }); + expect(first).not.toBe(fallback); + await store.write({ count: 2 }); + await expect(store.read()).resolves.toEqual({ count: 2 }); + await expect(store.update((current) => ({ count: current.count + 1 }))).resolves.toEqual({ + count: 3, + }); + + const unlocked = jsonStore({ filePath: path.join(root, "unlocked.json"), fallback: 0 }); + await expect(unlocked.read()).resolves.toBe(0); + await unlocked.write(4); + await expect(unlocked.update((value) => value + 1)).resolves.toBe(5); + }); + + it("covers regular file read, stat, append, and limit behavior", async () => { + const root = await tempRoot("fs-safe-regular-extra-"); + const file = path.join(root, "file.txt"); + const dir = path.join(root, "dir"); + await fs.mkdir(dir); + await fs.writeFile(file, "abc", "utf8"); + + expect(resolveRegularFileAppendFlags({ O_APPEND: 8, O_CREAT: 512, O_WRONLY: 1 })).toBe(521); + await expect(statRegularFile(path.join(root, "missing.txt"))).resolves.toEqual({ + missing: true, + }); + expect(statRegularFileSync(path.join(root, "missing.txt"))).toEqual({ missing: true }); + await expect(statRegularFile(dir)).rejects.toThrow("regular file"); + expect(() => statRegularFileSync(dir)).toThrow("regular file"); + await expect(readRegularFile({ filePath: file, maxBytes: 8 })).resolves.toMatchObject({ + buffer: Buffer.from("abc"), + }); + await expect(readRegularFile({ filePath: file, maxBytes: 2 })).rejects.toThrow("exceeds"); + expect(readRegularFileSync({ filePath: file, maxBytes: 8 }).buffer).toEqual(Buffer.from("abc")); + expect(() => readRegularFileSync({ filePath: file, maxBytes: 2 })).toThrow("exceeds"); + + await appendRegularFile({ filePath: file, content: "d", maxFileBytes: 10 }); + await appendRegularFile({ filePath: file, content: "skip", maxFileBytes: 2 }); + appendRegularFileSync({ filePath: file, content: Buffer.from("e"), maxFileBytes: 10 }); + appendRegularFileSync({ filePath: file, content: "skip", maxFileBytes: 2 }); + await expect(fs.readFile(file, "utf8")).resolves.toBe("abcde"); + }); +}); + +describe("temporary workspace and symlink parent helpers", () => { + it("covers async and sync temporary workspace operations", async () => { + const root = await tempRoot("fs-safe-workspace-extra-"); + const source = path.join(root, "source.txt"); + await fs.writeFile(source, "copy", "utf8"); + + const workspace = await tempWorkspace({ rootDir: root, prefix: "bad prefix!" }); + expect(() => workspace.file("../bad")).toThrow("Invalid temp workspace"); + const privateFile = await workspace.writePrivate("private.bin", Buffer.from("private")); + const textFile = await workspace.writeText("text.txt", "text"); + const jsonFile = await workspace.writeJson("data.json", { ok: true }, { + trailingNewline: false, + }); + await expect(workspace.copyIn("copy.txt", source)).resolves.toBe(workspace.path("copy.txt")); + await expect(workspace.read("text.txt")).resolves.toEqual(Buffer.from("text")); + expect(path.basename(privateFile)).toBe("private.bin"); + expect(path.basename(textFile)).toBe("text.txt"); + await expect(fs.readFile(jsonFile, "utf8")).resolves.toBe('{\n "ok": true\n}'); + await workspace.cleanup(); + + await expect( + withTempWorkspace({ rootDir: root, prefix: "." }, async (scoped) => { + await scoped.writeText("value.txt", "value"); + return (await scoped.read("value.txt")).toString("utf8"); + }), + ).resolves.toBe("value"); + + const syncWorkspace = tempWorkspaceSync({ rootDir: root, prefix: ".." }); + try { + expect(() => syncWorkspace.file("bad/name")).toThrow("Invalid temp workspace"); + expect(syncWorkspace.writePrivate("private.bin", Buffer.from("private"))).toContain( + "private.bin", + ); + expect(syncWorkspace.writeText("text.txt", "text")).toContain("text.txt"); + expect(syncWorkspace.writeJson("data.json", { ok: true }, { trailingNewline: false })) + .toContain("data.json"); + expect(syncWorkspace.read("text.txt")).toEqual(Buffer.from("text")); + } finally { + syncWorkspace[Symbol.dispose](); + } + expect( + withTempWorkspaceSync({ rootDir: root, prefix: "sync" }, (scoped) => { + scoped.writeText("value.txt", "value"); + return scoped.read("value.txt").toString("utf8"); + }), + ).toBe("value"); + }); + + it.runIf(process.platform !== "win32")("covers symlink parent policies", async () => { + const root = await tempRoot("fs-safe-symlink-parent-extra-"); + const outside = await tempRoot("fs-safe-symlink-parent-outside-"); + await fs.mkdir(path.join(root, "real")); + await fs.symlink(outside, path.join(root, "link")); + await fs.writeFile(path.join(root, "file.txt"), "x", "utf8"); + + await expect( + assertNoSymlinkParents({ + rootDir: root, + targetPath: path.join(root, "missing", "file.txt"), + }), + ).resolves.toBeUndefined(); + await expect( + assertNoSymlinkParents({ + rootDir: root, + targetPath: path.join(root, "link", "file.txt"), + }), + ).rejects.toThrow("symlinked"); + await expect( + assertNoSymlinkParents({ + rootDir: root, + targetPath: path.join(root, "link", "file.txt"), + allowRootChildSymlink: true, + }), + ).resolves.toBeUndefined(); + await expect( + assertNoSymlinkParents({ + rootDir: root, + targetPath: path.join(root, "file.txt", "child"), + requireDirectories: true, + }), + ).rejects.toThrow("directories"); + await expect( + assertNoSymlinkParents({ + rootDir: root, + targetPath: path.join(outside, "file.txt"), + allowOutsideRoot: true, + }), + ).resolves.toBeUndefined(); + await expect( + assertNoSymlinkParents({ + rootDir: root, + targetPath: path.join(outside, "file.txt"), + }), + ).rejects.toThrow("must stay"); + + expect(() => + assertNoSymlinkParentsSync({ + rootDir: root, + targetPath: path.join(root, "link", "file.txt"), + }), + ).toThrow("symlinked"); + expect(() => + assertNoSymlinkParentsSync({ + rootDir: root, + targetPath: path.join(root, "missing", "file.txt"), + }), + ).not.toThrow(); + }); +}); + +describe("file stores and private stores", () => { + it("writes, streams, copies, reads, removes, and prunes file-store entries", async () => { + const root = await tempRoot("fs-safe-store-"); + const sourceRoot = await tempRoot("fs-safe-store-source-"); + const source = path.join(sourceRoot, "source.txt"); + await fs.writeFile(source, "copy", "utf8"); + const store = fileStore({ rootDir: root, maxBytes: 16 }); + + expect(store.path("a/b.txt")).toBe(path.join(root, "a", "b.txt")); + await expect(store.write("a/b.txt", "data")).resolves.toBe(path.join(root, "a", "b.txt")); + await expect(store.readBytes("a/b.txt")).resolves.toEqual(Buffer.from("data")); + await expect(store.write("too-large.txt", Buffer.alloc(17))).rejects.toMatchObject({ + code: "too-large", + }); + await store.writeStream("stream.txt", Readable.from(["hello"])); + await expect(fs.readFile(path.join(root, "stream.txt"), "utf8")).resolves.toBe("hello"); + await expect(store.writeStream("stream-too-large.txt", Readable.from(["123", "456"]), { + maxBytes: 4, + })).rejects.toMatchObject({ code: "too-large" }); + await expect(store.copyIn("copied.txt", source)).resolves.toBe(path.join(root, "copied.txt")); + await expect(copyIntoRoot({ rootDir: root, relativePath: "bad.txt", sourcePath: sourceRoot })) + .rejects + .toMatchObject({ code: "not-file" }); + await expect(store.exists("copied.txt")).resolves.toBe(true); + await store.remove("copied.txt"); + await expect(store.exists("copied.txt")).resolves.toBe(false); + + const old = path.join(root, "old", "stale.txt"); + await fs.mkdir(path.dirname(old), { recursive: true }); + await fs.writeFile(old, "old", "utf8"); + const stale = new Date(Date.now() - 60_000); + await fs.utimes(old, stale, stale); + await store.pruneExpired({ ttlMs: 1, recursive: true, pruneEmptyDirs: true }); + await expect(fs.stat(old)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("covers private store sync and async helpers", async () => { + const root = await tempRoot("fs-safe-private-store-"); + const store = privateFileStore(root); + + await store.writeText("nested/value.txt", "secret"); + await expect(store.readText("nested/value.txt")).resolves.toBe("secret"); + await store.writeJson("nested/value.json", { ok: true }, { trailingNewline: true }); + await expect(store.readJson("nested/value.json")).resolves.toEqual({ ok: true }); + expect(store.path("nested/value.txt")).toBe(path.join(root, "nested", "value.txt")); + expect(() => store.path("../escape.txt")).toThrow("stay under"); + await expect(store.readText("missing.txt")).resolves.toBeNull(); + + const syncText = path.join(root, "sync", "value.txt"); + writePrivateTextAtomicSync({ rootDir: root, filePath: syncText, content: "sync" }); + expect(readPrivateTextSync({ rootDir: root, filePath: syncText })).toBe("sync"); + const syncJson = path.join(root, "sync", "value.json"); + writePrivateJsonAtomicSync({ + rootDir: root, + filePath: syncJson, + value: { ok: true }, + trailingNewline: true, + }); + expect(readPrivateJsonSync({ rootDir: root, filePath: syncJson })).toEqual({ ok: true }); + expect(readPrivateTextSync({ rootDir: root, filePath: path.join(root, "missing.txt") })).toBe( + null, + ); + expect(() => readPrivateTextSync({ rootDir: root, filePath: path.dirname(root) })).toThrow( + "stay under", + ); + }); +}); + +describe("secret files and temp roots", () => { + it("covers secret read failures and private write validation", async () => { + const root = await tempRoot("fs-safe-secret-extra-"); + const empty = path.join(root, "empty.txt"); + await fs.writeFile(empty, " \n", "utf8"); + expect(loadSecretFileSync("", "Token")).toMatchObject({ ok: false }); + expect(loadSecretFileSync(path.join(root, "missing.txt"), "Token")).toMatchObject({ + ok: false, + }); + expect(loadSecretFileSync(root, "Token")).toMatchObject({ ok: false }); + expect(loadSecretFileSync(empty, "Token")).toMatchObject({ ok: false }); + expect(tryReadSecretFileSync("", "Token")).toBeUndefined(); + expect(tryReadSecretFileSync(path.join(root, "missing.txt"), "Token")).toBeUndefined(); + + const big = path.join(root, "big.txt"); + await fs.writeFile(big, "12345", "utf8"); + expect(loadSecretFileSync(big, "Token", { maxBytes: 2 })).toMatchObject({ ok: false }); + + const target = path.join(root, "private", "token.txt"); + await writePrivateSecretFileAtomic({ rootDir: root, filePath: target, content: "secret\n" }); + expect(readSecretFileSync(target, "Token")).toBe("secret"); + await fs.mkdir(path.join(root, "dir-target")); + await expect( + writePrivateSecretFileAtomic({ + rootDir: root, + filePath: path.join(root, "dir-target"), + content: "bad", + }), + ).rejects.toThrow("regular file"); + }); + + it("covers secure temp root resolution and timeout behavior", async () => { + const root = await tempRoot("fs-safe-temp-root-"); + const secure = path.join(root, "secure"); + expect(resolveSecureTempRoot({ fallbackPrefix: "fallback", preferredDir: secure })).toBe( + path.resolve(secure), + ); + expect( + resolveSecureTempRoot({ + fallbackPrefix: "fallback", + preferredDir: secure, + skipPreferredOnWindows: true, + platform: "win32", + tmpdir: () => root, + getuid: () => undefined, + }), + ).toBe(path.win32.join(root, "fallback")); + await expect(withTimeout(Promise.resolve("ok"), 10, { message: "slow" })).resolves.toBe("ok"); + await expect(withTimeout(new Promise(() => undefined), 1, { message: "slow" })) + .rejects + .toThrow("slow"); + }); + + it("covers temp target sanitizing, disposable files, and cleanup", async () => { + const root = await tempRoot("fs-safe-temp-file-"); + expect(sanitizeTempFileName("../bad name?.txt")).toBe("bad-name-.txt"); + expect( + buildRandomTempFilePath({ + rootDir: root, + prefix: "bad prefix!", + extension: "log", + now: 1.9, + uuid: " fixed ", + }), + ).toBe(path.join(root, "bad-prefix-1-fixed.log")); + const tmp = await tempFile({ rootDir: root, prefix: "download", fileName: "../x.bin" }); + await fs.writeFile(tmp.path, "ok", "utf8"); + await expect(fs.readFile(tmp.file("alt?.txt"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + await tmp[Symbol.asyncDispose](); + await expect(fs.stat(tmp.dir)).rejects.toMatchObject({ code: "ENOENT" }); + + await expect( + withTempFile({ rootDir: root, prefix: "scoped", fileName: "x.txt" }, async (tmpPath) => { + await fs.writeFile(tmpPath, "scoped", "utf8"); + return await fs.readFile(tmpPath, "utf8"); + }), + ).resolves.toBe("scoped"); + }); +}); + +describe("policy and directory replacement helpers", () => { + it("covers alias policy and atomic directory replacement outcomes", async () => { + const root = await tempRoot("fs-safe-policy-"); + const file = path.join(root, "file.txt"); + await fs.writeFile(file, "ok", "utf8"); + await expect( + assertNoPathAliasEscape({ rootPath: root, absolutePath: file, boundaryLabel: "root" }), + ).resolves.toBeUndefined(); + await expect( + assertNoPathAliasEscape({ + rootPath: root, + absolutePath: path.join(path.dirname(root), "outside.txt"), + boundaryLabel: "root", + }), + ).rejects.toThrow("outside"); + + await expect( + assertNoHardlinkedFinalPath({ filePath: file, root, boundaryLabel: "root" }), + ).resolves.toBeUndefined(); + if (process.platform !== "win32") { + const hardlink = path.join(root, "hardlink.txt"); + await fs.link(file, hardlink); + await expect( + assertNoHardlinkedFinalPath({ filePath: hardlink, root, boundaryLabel: "root" }), + ).rejects.toThrow("Hardlinked"); + } + + const next = path.join(root, "next"); + await fs.mkdir(next); + await fs.writeFile(path.join(next, "new.txt"), "new", "utf8"); + const target = path.join(root, "target"); + await fs.mkdir(target); + await fs.writeFile(path.join(target, "old.txt"), "old", "utf8"); + await replaceDirectoryAtomic({ stagedDir: next, targetDir: target }); + await expect(fs.readFile(path.join(target, "new.txt"), "utf8")).resolves.toBe("new"); + await expect(fs.stat(next)).rejects.toMatchObject({ code: "ENOENT" }); + + const notDir = path.join(root, "not-dir"); + await fs.writeFile(notDir, "x", "utf8"); + await expect(replaceDirectoryAtomic({ sourceDir: notDir, targetDir: target })).rejects + .toThrow(); + }); +}); diff --git a/test/coverage-gaps.test.ts b/test/coverage-gaps.test.ts new file mode 100644 index 0000000..14a53c7 --- /dev/null +++ b/test/coverage-gaps.test.ts @@ -0,0 +1,373 @@ +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + assertAbsolutePathInput, + canonicalPathFromExistingAncestor, + findExistingAncestor, + resolveAbsolutePathForRead, + resolveAbsolutePathForWrite, +} from "../src/absolute-path.js"; +import { + createTarEntryPreflightChecker, + readTarEntryInfo, +} from "../src/archive-tar.js"; +import { resolveArchiveKind, resolvePackedRootDir } from "../src/archive-kind.js"; +import { pathExists, pathExistsSync } from "../src/fs.js"; +import { + expandHomePrefix, + resolveEffectiveHomeDir, + resolveHomeRelativePath, + resolveOsHomeDir, + resolveOsHomeRelativePath, + resolveRequiredHomeDir, + resolveRequiredOsHomeDir, + resolveUserPath, +} from "../src/home-dir.js"; +import { movePathWithCopyFallback } from "../src/move-path.js"; +import { createSidecarLockManager, withSidecarLock } from "../src/sidecar-lock.js"; +import { + hasNonEmptyString, + localeLowercasePreservingWhitespace, + lowercasePreservingWhitespace, + normalizeFastMode, + normalizeLowercaseStringOrEmpty, + normalizeNullableString, + normalizeOptionalLowercaseString, + normalizeOptionalString, + normalizeOptionalStringifiedId, + normalizeOptionalThreadValue, + normalizeStringifiedOptionalString, + readStringValue, + resolvePrimaryStringValue, +} from "../src/string-coerce.js"; +import { movePathToTrash } from "../src/trash.js"; + +const tempDirs = new Set(); + +async function tempRoot(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.add(dir); + return dir; +} + +afterEach(async () => { + for (const dir of tempDirs) { + await fs.rm(dir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +describe("string coercion helpers", () => { + it("normalizes optional string-like values", () => { + expect(readStringValue("x")).toBe("x"); + expect(readStringValue(1)).toBeUndefined(); + expect(normalizeNullableString(" hi ")).toBe("hi"); + expect(normalizeNullableString(" ")).toBeNull(); + expect(normalizeOptionalString(" ok ")).toBe("ok"); + expect(normalizeOptionalString(null)).toBeUndefined(); + expect(normalizeStringifiedOptionalString(42)).toBe("42"); + expect(normalizeStringifiedOptionalString(true)).toBe("true"); + expect(normalizeStringifiedOptionalString(12n)).toBe("12"); + expect(normalizeStringifiedOptionalString({})).toBeUndefined(); + expect(normalizeOptionalLowercaseString(" YES ")).toBe("yes"); + expect(normalizeLowercaseStringOrEmpty(undefined)).toBe(""); + expect(lowercasePreservingWhitespace(" A B ")).toBe(" a b "); + expect(localeLowercasePreservingWhitespace(" A B ")).toBe(" a b "); + expect(resolvePrimaryStringValue({ primary: " value " })).toBe("value"); + expect(resolvePrimaryStringValue({ primary: " " })).toBeUndefined(); + expect(resolvePrimaryStringValue(" direct ")).toBe("direct"); + expect(normalizeOptionalThreadValue(4.9)).toBe(4); + expect(normalizeOptionalThreadValue(Number.NaN)).toBeUndefined(); + expect(normalizeOptionalStringifiedId(7)).toBe("7"); + expect(hasNonEmptyString(" x ")).toBe(true); + expect(hasNonEmptyString(" ")).toBe(false); + }); + + it("parses fast mode aliases", () => { + for (const value of [true, "on", "true", "yes", "1", "enable", "enabled", "fast"]) { + expect(normalizeFastMode(value)).toBe(true); + } + for (const value of [false, "off", "false", "no", "0", "disable", "disabled", "normal"]) { + expect(normalizeFastMode(value)).toBe(false); + } + expect(normalizeFastMode(null)).toBeUndefined(); + expect(normalizeFastMode("maybe")).toBeUndefined(); + }); +}); + +describe("home directory helpers", () => { + it("resolves explicit and OS home values", () => { + const env = { + OPENCLAW_HOME: "~/openclaw", + HOME: "/home/tester", + USERPROFILE: "/users/fallback", + }; + expect(resolveEffectiveHomeDir(env, () => "/os/home")).toBe(path.resolve("/home/tester/openclaw")); + expect(resolveOsHomeDir(env, () => "/os/home")).toBe(path.resolve("/home/tester")); + expect(resolveRequiredHomeDir({}, () => "")).toBe(path.resolve(process.cwd())); + expect(resolveRequiredOsHomeDir({}, () => "")).toBe(path.resolve(process.cwd())); + expect(expandHomePrefix("~/file", { home: "/home/tester" })).toBe( + path.join("/home/tester", "file"), + ); + expect(expandHomePrefix("plain", { home: "/home/tester" })).toBe("plain"); + expect(expandHomePrefix("~other/file", { home: "/home/tester" })).toBe("~other/file"); + }); + + it("resolves user paths through legacy and explicit option shapes", () => { + const env = { OPENCLAW_HOME: "/configured", HOME: "/home/tester" }; + expect(resolveHomeRelativePath("~/state", { env })).toBe(path.resolve("/configured/state")); + expect(resolveOsHomeRelativePath("~/state", { env })).toBe(path.resolve("/home/tester/state")); + expect(resolveUserPath("~/state", env)).toBe(path.resolve("/configured/state")); + expect(resolveUserPath(" ./relative ", { env })).toBe(path.resolve("./relative")); + expect(resolveHomeRelativePath(" ", { env })).toBe(""); + expect(resolveOsHomeRelativePath(" ", { env })).toBe(""); + }); + + it("ignores unusable home values", () => { + expect(resolveEffectiveHomeDir({ OPENCLAW_HOME: "undefined", HOME: "null" }, () => "/real")) + .toBe(path.resolve("/real")); + expect(resolveEffectiveHomeDir({ OPENCLAW_HOME: "~" }, () => "")).toBeUndefined(); + expect(resolveOsHomeDir({}, () => { + throw new Error("no home"); + })).toBeUndefined(); + }); +}); + +describe("archive kind and tar preflight helpers", () => { + it("detects archive kinds and packed root layouts", async () => { + expect(resolveArchiveKind("PLUGIN.ZIP")).toBe("zip"); + expect(resolveArchiveKind("pkg.tar.gz")).toBe("tar"); + expect(resolveArchiveKind("pkg.tgz")).toBe("tar"); + expect(resolveArchiveKind("pkg.txt")).toBeNull(); + + const root = await tempRoot("fs-safe-packed-"); + const packageDir = path.join(root, "package"); + await fs.mkdir(packageDir); + expect(await resolvePackedRootDir(root)).toBe(packageDir); + + await fs.rm(packageDir, { recursive: true }); + await fs.writeFile(path.join(root, "manifest.json"), "{}", "utf8"); + expect(await resolvePackedRootDir(root, { rootMarkers: [" ", "manifest.json"] })).toBe(root); + + await fs.rm(path.join(root, "manifest.json")); + await fs.mkdir(path.join(root, "only")); + expect(await resolvePackedRootDir(root)).toBe(path.join(root, "only")); + + await fs.mkdir(path.join(root, "second")); + await expect(resolvePackedRootDir(root)).rejects.toThrow("unexpected archive layout"); + }); + + it("normalizes tar entries and rejects unsafe entries", () => { + expect(readTarEntryInfo({ path: "a.txt", type: "File", size: 4.9 })).toEqual({ + path: "a.txt", + type: "File", + size: 4, + }); + expect(readTarEntryInfo({ path: "a.txt", type: "File", size: -1 })).toMatchObject({ size: 0 }); + expect(readTarEntryInfo(null)).toEqual({ path: "", type: "", size: 0 }); + + const check = createTarEntryPreflightChecker({ + rootDir: "/tmp/extract", + stripComponents: 1, + limits: { maxEntries: 2, maxEntryBytes: 10, maxExtractedBytes: 20 }, + }); + expect(() => check({ path: "package/", type: "Directory", size: 0 })).not.toThrow(); + expect(() => check({ path: "package/file.txt", type: "File", size: 4 })).not.toThrow(); + expect(() => check({ path: "package/link", type: "SymbolicLink", size: 0 })).toThrow( + "tar entry is a link", + ); + expect(() => check({ path: "../escape", type: "File", size: 1 })).toThrow(); + + const countCheck = createTarEntryPreflightChecker({ + rootDir: "/tmp/extract", + limits: { maxEntries: 1 }, + }); + countCheck({ path: "one.txt", type: "File", size: 1 }); + expect(() => countCheck({ path: "two.txt", type: "File", size: 1 })).toThrow(); + }); +}); + +describe("absolute path helpers", () => { + it("validates absolute path inputs", () => { + expect(() => assertAbsolutePathInput("")).toThrow("path is required"); + expect(() => assertAbsolutePathInput("relative")).toThrow("path must be absolute"); + expect(() => assertAbsolutePathInput(`${path.sep}tmp\0bad`)).toThrow("NUL"); + expect(assertAbsolutePathInput(path.join(path.sep, "tmp", "..", "tmp", "x"))).toBe( + path.join(path.sep, "tmp", "x"), + ); + }); + + it("finds ancestors and resolves reads/writes", async () => { + const root = await fs.realpath(await tempRoot("fs-safe-absolute-")); + const nested = path.join(root, "nested"); + const filePath = path.join(nested, "file.txt"); + await fs.mkdir(nested); + await fs.writeFile(filePath, "ok", "utf8"); + + expect(await findExistingAncestor(path.join(nested, "missing", "file.txt"))).toBe(nested); + expect(await canonicalPathFromExistingAncestor(path.join(nested, "missing", "file.txt"))) + .toBe(path.join(await fs.realpath(nested), "missing", "file.txt")); + await expect(resolveAbsolutePathForRead(filePath)).resolves.toMatchObject({ + path: filePath, + canonicalPath: await fs.realpath(filePath), + }); + await expect(resolveAbsolutePathForWrite(path.join(nested, "new.txt"))).resolves.toMatchObject({ + path: path.join(nested, "new.txt"), + parentDir: nested, + parentExists: true, + }); + await expect(resolveAbsolutePathForRead(path.join(root, "missing.txt"))).rejects.toMatchObject({ + code: "not-found", + }); + }); + + it.runIf(process.platform !== "win32")("rejects symlinked absolute paths by default", async () => { + const root = await tempRoot("fs-safe-absolute-link-"); + const realDir = path.join(root, "real"); + const linkDir = path.join(root, "link"); + await fs.mkdir(realDir); + await fs.writeFile(path.join(realDir, "file.txt"), "ok", "utf8"); + await fs.symlink(realDir, linkDir); + + await expect(resolveAbsolutePathForRead(path.join(linkDir, "file.txt"))).rejects.toMatchObject({ + code: "symlink", + }); + await expect( + resolveAbsolutePathForRead(path.join(linkDir, "file.txt"), { symlinks: "follow" }), + ).resolves.toMatchObject({ canonicalPath: await fs.realpath(path.join(realDir, "file.txt")) }); + await expect(resolveAbsolutePathForWrite(path.join(linkDir, "new.txt"))).rejects.toMatchObject({ + code: "symlink", + }); + }); +}); + +describe("filesystem utility helpers", () => { + it("checks path existence through stat semantics", async () => { + const root = await tempRoot("fs-safe-exists-"); + const filePath = path.join(root, "file.txt"); + await fs.writeFile(filePath, "ok", "utf8"); + await expect(pathExists(filePath)).resolves.toBe(true); + await expect(pathExists(path.join(root, "missing.txt"))).resolves.toBe(false); + expect(pathExistsSync(filePath)).toBe(true); + expect(pathExistsSync(path.join(root, "missing.txt"))).toBe(false); + }); + + it("moves paths with rename and copy fallback semantics", async () => { + const root = await tempRoot("fs-safe-move-"); + const from = path.join(root, "from.txt"); + const to = path.join(root, "to.txt"); + await fs.writeFile(from, "ok", "utf8"); + await movePathWithCopyFallback({ from, to }); + await expect(fs.readFile(to, "utf8")).resolves.toBe("ok"); + await expect(fs.stat(from)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + +describe("trash helper", () => { + it("moves allowed temp paths to trash and rejects disallowed roots", async () => { + const root = await tempRoot("fs-safe-trash-"); + const filePath = path.join(root, "delete-me.txt"); + await fs.writeFile(filePath, "trash", "utf8"); + + await expect(movePathToTrash(path.join(root, "missing.txt"), { allowedRoots: [root] })) + .rejects + .toThrow(); + await expect(movePathToTrash(filePath, { allowedRoots: [path.join(root, "other")] })) + .rejects + .toThrow("outside allowed roots"); + + const dest = await movePathToTrash(filePath, { allowedRoots: [root] }); + try { + expect(path.basename(dest)).toBe("delete-me.txt"); + expect(fsSync.existsSync(dest)).toBe(true); + expect(fsSync.existsSync(filePath)).toBe(false); + } finally { + await fs.rm(path.dirname(dest), { recursive: true, force: true }); + } + }); +}); + +describe("sidecar lock manager", () => { + it("acquires, reenters, lists, force releases, and drains locks", async () => { + const root = await tempRoot("fs-safe-sidecar-"); + const targetPath = path.join(root, "state.json"); + const manager = createSidecarLockManager(`coverage-${Date.now()}-${Math.random()}`); + + const lock = await manager.acquire({ + targetPath, + staleMs: 60_000, + allowReentrant: true, + metadata: { test: true }, + payload: () => ({ owner: "coverage" }), + }); + const reentrant = await manager.acquire({ + targetPath, + staleMs: 60_000, + allowReentrant: true, + payload: () => ({ owner: "coverage" }), + }); + expect(manager.heldEntries()).toHaveLength(1); + expect(manager.heldEntries()[0]?.metadata).toEqual({ test: true }); + await reentrant.release(); + expect(await manager.heldEntries()[0]?.forceRelease()).toBe(true); + await lock.release(); + expect(manager.heldEntries()).toEqual([]); + + const value = await manager.withLock( + { + targetPath, + staleMs: 60_000, + payload: () => ({ owner: "coverage" }), + }, + async () => 42, + ); + expect(value).toBe(42); + await manager.drain(); + manager.reset(); + }); + + it("times out and reclaims stale locks", async () => { + const root = await tempRoot("fs-safe-sidecar-timeout-"); + const targetPath = path.join(root, "state.json"); + const lockPath = `${targetPath}.lock`; + const manager = createSidecarLockManager(`coverage-timeout-${Date.now()}-${Math.random()}`); + await fs.writeFile(lockPath, "{\"createdAt\":\"2000-01-01T00:00:00.000Z\"}\n", "utf8"); + + const reclaimed = await manager.acquire({ + targetPath, + lockPath, + staleMs: 1, + payload: () => ({ owner: "coverage" }), + }); + await reclaimed.release(); + + await fs.writeFile(lockPath, "{\"createdAt\":\"2999-01-01T00:00:00.000Z\"}\n", "utf8"); + await expect( + manager.acquire({ + targetPath, + lockPath, + staleMs: 60_000, + timeoutMs: 1, + retry: { retries: 0, minTimeout: 1, maxTimeout: 1 }, + shouldReclaim: () => false, + payload: () => ({ owner: "coverage" }), + }), + ).rejects.toMatchObject({ code: "file_lock_timeout" }); + await fs.rm(lockPath, { force: true }); + + await expect( + withSidecarLock( + targetPath, + { + managerKey: `coverage-wrapper-${Date.now()}-${Math.random()}`, + staleMs: 60_000, + payload: () => ({ owner: "coverage" }), + }, + async () => "locked", + ), + ).resolves.toBe("locked"); + }); +}); diff --git a/test/pinned-write-fallback-coverage.test.ts b/test/pinned-write-fallback-coverage.test.ts new file mode 100644 index 0000000..7b32b62 --- /dev/null +++ b/test/pinned-write-fallback-coverage.test.ts @@ -0,0 +1,101 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", () => { + return { + spawn: () => { + const child = new EventEmitter() as EventEmitter & { + kill(signal?: NodeJS.Signals): void; + stdout: EventEmitter & { setEncoding: () => void }; + stderr: EventEmitter & { setEncoding: () => void }; + }; + child.stdout = Object.assign(new EventEmitter(), { setEncoding: () => undefined }); + child.stderr = Object.assign(new EventEmitter(), { setEncoding: () => undefined }); + child.kill = () => undefined; + queueMicrotask(() => child.emit("close", 0, null)); + return child; + }, + }; +}); + +const tempDirs = new Set(); + +async function tempRoot(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.add(dir); + return dir; +} + +afterEach(async () => { + for (const dir of tempDirs) { + await fs.rm(dir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +describe("pinned write fallback coverage", () => { + it("writes buffers, creates only when missing, streams, and enforces limits", async () => { + const { runPinnedWriteHelper } = await import("../src/pinned-write.js"); + const root = await tempRoot("fs-safe-pinned-write-fallback-"); + + const created = await runPinnedWriteHelper({ + rootPath: root, + relativeParentPath: "nested", + basename: "created.txt", + mkdir: true, + mode: 0o600, + overwrite: false, + input: { kind: "buffer", data: "created", encoding: "utf8" }, + }); + expect(created.ino).toBeGreaterThan(0); + await expect(fs.readFile(path.join(root, "nested", "created.txt"), "utf8")).resolves.toBe( + "created", + ); + await expect( + runPinnedWriteHelper({ + rootPath: root, + relativeParentPath: "nested", + basename: "created.txt", + mkdir: true, + mode: 0o600, + overwrite: false, + input: { kind: "buffer", data: "again" }, + }), + ).rejects.toMatchObject({ code: "EEXIST" }); + + const streamed = await runPinnedWriteHelper({ + rootPath: root, + relativeParentPath: "nested", + basename: "streamed.txt", + mkdir: true, + mode: 0o600, + overwrite: true, + maxBytes: 16, + input: { kind: "stream", stream: Readable.from(["stream", "ed"]) }, + }); + expect(streamed.dev).toBeGreaterThan(0); + await expect(fs.readFile(path.join(root, "nested", "streamed.txt"), "utf8")).resolves.toBe( + "streamed", + ); + + await expect( + runPinnedWriteHelper({ + rootPath: root, + relativeParentPath: "nested", + basename: "too-large.txt", + mkdir: true, + mode: 0o600, + overwrite: true, + maxBytes: 2, + input: { kind: "buffer", data: Buffer.from("large") }, + }), + ).rejects.toMatchObject({ code: "too-large" }); + await expect(fs.stat(path.join(root, "nested", "too-large.txt"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +}); diff --git a/test/platform-fallback-coverage.test.ts b/test/platform-fallback-coverage.test.ts new file mode 100644 index 0000000..ec7b302 --- /dev/null +++ b/test/platform-fallback-coverage.test.ts @@ -0,0 +1,75 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const tempDirs = new Set(); +const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + +async function tempRoot(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.add(dir); + return dir; +} + +async function importRootForPlatform(platform: NodeJS.Platform) { + vi.resetModules(); + Object.defineProperty(process, "platform", { + configurable: true, + enumerable: true, + value: platform, + }); + return await import("../src/root.js"); +} + +afterEach(async () => { + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } + vi.resetModules(); + for (const dir of tempDirs) { + await fs.rm(dir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +describe("platform fallback coverage", () => { + it("exercises root write, copy, mkdir, and remove fallbacks used on Windows", async () => { + const { root: openRoot } = await importRootForPlatform("win32"); + const rootDir = await tempRoot("fs-safe-win-fallback-"); + const sourceDir = await tempRoot("fs-safe-win-fallback-source-"); + const source = path.join(sourceDir, "source.txt"); + await fs.writeFile(source, "copied", "utf8"); + const scoped = await openRoot(rootDir, { mkdir: true }); + + await scoped.mkdir("nested"); + await scoped.write("nested/file.txt", "first"); + await expect(fs.readFile(path.join(rootDir, "nested", "file.txt"), "utf8")).resolves.toBe( + "first", + ); + + await scoped.write("nested/file.txt", Buffer.from("second")); + await expect(fs.readFile(path.join(rootDir, "nested", "file.txt"), "utf8")).resolves.toBe( + "second", + ); + await expect(scoped.create("nested/file.txt", "third")).rejects.toMatchObject({ + code: "already-exists", + }); + await scoped.create("nested/created.txt", "created"); + await expect(fs.readFile(path.join(rootDir, "nested", "created.txt"), "utf8")).resolves.toBe( + "created", + ); + + await scoped.copyIn("nested/copied.txt", source, { maxBytes: 16 }); + await expect(fs.readFile(path.join(rootDir, "nested", "copied.txt"), "utf8")).resolves.toBe( + "copied", + ); + await expect(scoped.copyIn("nested/too-large.txt", source, { maxBytes: 3 })).rejects + .toMatchObject({ code: "too-large" }); + + await scoped.remove("nested/copied.txt"); + await expect(fs.stat(path.join(rootDir, "nested", "copied.txt"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 6a5174e..d74f7a3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,25 @@ export default defineConfig({ test: { include: ["test/**/*.test.ts"], pool: "forks", + coverage: { + provider: "v8", + reporter: ["text", "json-summary", "html", "lcov"], + reportsDirectory: "coverage", + include: ["src/**/*.ts"], + exclude: [ + "src/index.ts", + "src/atomic.ts", + "src/temp.ts", + "src/types.ts", + "src/file-url.ts", + "src/test-hooks.ts", + ], + thresholds: { + lines: 84, + functions: 95, + statements: 84, + branches: 78, + }, + }, }, });