From 989831d06392566525bc3de90a50f88b991bf879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 8 Aug 2026 12:16:40 +0200 Subject: [PATCH 1/3] fix: harden artifact ingestion boundaries --- .github/workflows/publish-mcp-registry.yml | 13 +- package.json | 10 +- pnpm-lock.yaml | 398 +++++++++++++----- src/__tests__/npm-package-scripts.test.ts | 40 ++ .../__tests__/resumable-upload-range.test.ts | 24 ++ src/daemon/__tests__/resumable-upload.test.ts | 107 ++++- src/daemon/__tests__/upload.test.ts | 9 +- src/daemon/artifact-archive.ts | 111 ++--- src/daemon/artifact-download.ts | 43 +- src/daemon/resumable-upload-range.ts | 50 +++ src/daemon/resumable-upload-transfer.ts | 99 +++++ src/daemon/resumable-upload.ts | 300 ++++++------- .../__tests__/install-source-download.test.ts | 138 ++++++ .../install-source-network-transport.test.ts | 39 ++ .../__tests__/install-source.test.ts | 129 ++++-- .../apple/core/__tests__/apps.test.ts | 242 +++++------ .../__tests__/install-artifact.fixtures.ts | 24 ++ src/platforms/apple/core/install-artifact.ts | 32 +- .../install-artifact-archive-context.ts | 22 + src/platforms/install-source-archive.ts | 142 +++++++ src/platforms/install-source-download.ts | 192 +++++++++ .../install-source-network-transport.ts | 127 ++++++ src/platforms/install-source-network.ts | 109 +++++ src/platforms/install-source.ts | 325 ++------------ src/utils/__tests__/archive-safety.test.ts | 81 ++++ src/utils/__tests__/byte-limit-stream.test.ts | 58 +++ src/utils/archive-extraction-tar.ts | 151 +++++++ src/utils/archive-extraction-zip.ts | 127 ++++++ src/utils/archive-extraction.ts | 48 +++ src/utils/archive-safety.ts | 201 +++++++++ src/utils/artifact-limits.ts | 4 + src/utils/byte-limit-stream.ts | 32 ++ .../daemon-http-resumable-upload.test.ts | 96 +++++ website/docs/docs/commands.md | 3 + 34 files changed, 2700 insertions(+), 826 deletions(-) create mode 100644 src/daemon/__tests__/resumable-upload-range.test.ts create mode 100644 src/daemon/resumable-upload-range.ts create mode 100644 src/daemon/resumable-upload-transfer.ts create mode 100644 src/platforms/__tests__/install-source-download.test.ts create mode 100644 src/platforms/__tests__/install-source-network-transport.test.ts create mode 100644 src/platforms/apple/core/__tests__/install-artifact.fixtures.ts create mode 100644 src/platforms/install-artifact-archive-context.ts create mode 100644 src/platforms/install-source-archive.ts create mode 100644 src/platforms/install-source-download.ts create mode 100644 src/platforms/install-source-network-transport.ts create mode 100644 src/platforms/install-source-network.ts create mode 100644 src/utils/__tests__/archive-safety.test.ts create mode 100644 src/utils/__tests__/byte-limit-stream.test.ts create mode 100644 src/utils/archive-extraction-tar.ts create mode 100644 src/utils/archive-extraction-zip.ts create mode 100644 src/utils/archive-extraction.ts create mode 100644 src/utils/archive-safety.ts create mode 100644 src/utils/artifact-limits.ts create mode 100644 src/utils/byte-limit-stream.ts create mode 100644 test/integration/provider-scenarios/daemon-http-resumable-upload.test.ts diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml index db90001062..c2114a9a0b 100644 --- a/.github/workflows/publish-mcp-registry.yml +++ b/.github/workflows/publish-mcp-registry.yml @@ -24,6 +24,10 @@ jobs: name: Publish MCP Registry runs-on: ubuntu-latest timeout-minutes: 10 + env: + MCP_PUBLISHER_VERSION: v1.8.1 + MCP_PUBLISHER_ASSET: mcp-publisher_linux_amd64.tar.gz + MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -64,7 +68,14 @@ jobs: - name: Install mcp-publisher run: | set -euo pipefail - curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher + PUBLISHER_ARCHIVE="$RUNNER_TEMP/$MCP_PUBLISHER_ASSET" + curl --fail --show-error --location --retry 3 \ + --output "$PUBLISHER_ARCHIVE" \ + "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/${MCP_PUBLISHER_ASSET}" + printf '%s %s\n' "$MCP_PUBLISHER_SHA256" "$PUBLISHER_ARCHIVE" | sha256sum --check --strict + tar -xzf "$PUBLISHER_ARCHIVE" -C "$RUNNER_TEMP" mcp-publisher + install -m 0755 "$RUNNER_TEMP/mcp-publisher" ./mcp-publisher + ./mcp-publisher --version shell: bash - name: Authenticate to MCP Registry diff --git a/package.json b/package.json index ae30dc550a..0df2ae35bf 100644 --- a/package.json +++ b/package.json @@ -245,18 +245,22 @@ ], "dependencies": { "@limrun/api": "^0.24.5", - "yaml": "^2.9.0" + "ipaddr.js": "^2.5.0", + "tar-stream": "^3.2.0", + "undici": "7.29.0", + "yaml": "^2.9.0", + "yauzl": "^3.4.0" }, "devDependencies": { "@agent-device/ad-replay": "workspace:*", "@agent-device/ad-script": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*", - "@agent-device/selectors": "workspace:*", "@agent-device/maestro": "workspace:*", "@agent-device/provider-limrun": "workspace:*", "@agent-device/provider-webdriver": "workspace:*", "@agent-device/replay-test": "workspace:*", + "@agent-device/selectors": "workspace:*", "@agent-device/xml": "workspace:*", "@arethetypeswrong/cli": "^0.18.5", "@chenglou/freerange": "^0.0.1", @@ -264,6 +268,8 @@ "@stryker-mutator/vitest-runner": "9.6.1", "@types/node": "^22.19.21", "@types/pngjs": "^6.0.5", + "@types/tar-stream": "^3.1.4", + "@types/yauzl": "^2.10.3", "@vitest/coverage-v8": "4.1.8", "fallow": "^2.95.0", "fast-check": "^4.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e5543d9ef..9cf9f466c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,21 @@ importers: '@limrun/api': specifier: ^0.24.5 version: 0.24.5(supports-color@7.2.0) + ipaddr.js: + specifier: ^2.5.0 + version: 2.5.0 + tar-stream: + specifier: ^3.2.0 + version: 3.2.0 + undici: + specifier: ^7.29.0 + version: 7.29.0 yaml: specifier: ^2.9.0 version: 2.9.0 + yauzl: + specifier: ^3.4.0 + version: 3.4.0 devDependencies: '@agent-device/ad-replay': specifier: workspace:* @@ -72,6 +84,12 @@ importers: '@types/pngjs': specifier: ^6.0.5 version: 6.0.5 + '@types/tar-stream': + specifier: ^3.1.4 + version: 3.1.4 + '@types/yauzl': + specifier: ^2.10.3 + version: 2.10.3 '@vitest/coverage-v8': specifier: 4.1.8 version: 4.1.8(vitest@4.1.8) @@ -208,10 +226,10 @@ importers: devDependencies: '@callstack/rspress-preset': specifier: ^0.6.6 - version: 0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@rspress/core': specifier: ^2.0.12 - version: 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) + version: 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) packages: @@ -1459,12 +1477,18 @@ packages: '@types/react@19.2.13': resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} + '@types/tar-stream@3.1.4': + resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1841,6 +1865,14 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -1848,6 +1880,43 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.8.0: + resolution: {integrity: sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.7: + resolution: {integrity: sha512-o8CRCiJtib+ycO3mE4A5UChtGX4dDP2XxsWVu9P+Zc3H8tcmKwNVEDoDTXmwN+uuMhfKeT7/i7Y26xS8W7ohoA==} + baseline-browser-mapping@2.11.4: resolution: {integrity: sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==} engines: {node: '>=6.0.0'} @@ -2097,6 +2166,9 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + eventsource-client@1.2.0: resolution: {integrity: sha512-kDI75RSzO3TwyG/K9w1ap8XwqSPcwi6jaMkNulfVeZmSeUM49U8kUzk1s+vKNt0tGrXgK47i+620Yasn1ccFiw==} engines: {node: '>=18.0.0'} @@ -2128,6 +2200,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -2288,6 +2363,10 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + is-absolute-url@4.0.1: resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2794,6 +2873,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3091,6 +3173,9 @@ packages: std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3120,6 +3205,15 @@ packages: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -3437,6 +3531,10 @@ packages: resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} engines: {node: '>=10'} + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + yoctocolors@2.2.0: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} @@ -3494,7 +3592,7 @@ snapshots: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 @@ -3529,14 +3627,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 '@babel/traverse': 7.29.7(supports-color@7.2.0) semver: 6.3.1 transitivePeerDependencies: @@ -3544,24 +3642,24 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.29.7(supports-color@7.2.0)': + '@babel/helper-member-expression-to-functions@7.29.7': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.7(supports-color@7.2.0) transitivePeerDependencies: @@ -3573,16 +3671,16 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 '@babel/traverse': 7.29.7(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@7.2.0)': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 @@ -3612,10 +3710,10 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: @@ -3636,7 +3734,7 @@ snapshots: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 @@ -3644,41 +3742,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: - supports-color @@ -3714,11 +3812,11 @@ snapshots: '@braidai/lang@1.1.2': {} - '@callstack/rspress-preset@0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@callstack/rspress-preset@0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@callstack/rspress-theme': 0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) - '@rspress/plugin-sitemap': 2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0)) + '@callstack/rspress-theme': 0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) + '@rspress/plugin-sitemap': 2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2)) '@vercel/analytics': 2.0.1(react@19.2.7) rsbuild-plugin-open-graph: 1.1.2(@rsbuild/core@2.0.11) zod: 4.3.6 @@ -3734,9 +3832,9 @@ snapshots: - vue - vue-router - '@callstack/rspress-theme@0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@callstack/rspress-theme@0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) + '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -3958,7 +4056,7 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 - '@mdx-js/mdx@3.1.1(supports-color@7.2.0)': + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.8 '@types/estree-jsx': 1.0.5 @@ -3970,14 +4068,14 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-scope: 1.0.0 estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) + hast-util-to-jsx-runtime: 2.3.6 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 recma-jsx: 1.0.1(acorn@8.16.0) recma-stringify: 1.0.0 - rehype-recma: 1.0.0(supports-color@7.2.0) - remark-mdx: 3.1.1(supports-color@7.2.0) - remark-parse: 11.0.0(supports-color@7.2.0) + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 remark-rehype: 11.1.2 source-map: 0.7.6 unified: 11.0.5 @@ -4380,9 +4478,9 @@ snapshots: optionalDependencies: '@rspack/core': 2.0.6(@swc/helpers@0.5.23) - '@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0)': + '@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2)': dependencies: - '@mdx-js/mdx': 3.1.1(supports-color@7.2.0) + '@mdx-js/mdx': 3.1.1 '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@19.2.7) '@rsbuild/core': 2.0.11 '@rsbuild/plugin-react': 2.0.0(@rsbuild/core@2.0.11)(@rspack/core@2.0.6(@swc/helpers@0.5.23)) @@ -4395,9 +4493,9 @@ snapshots: copy-to-clipboard: 3.3.3 flexsearch: 0.8.212 hast-util-heading-rank: 3.0.0 - hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) - mdast-util-mdx: 3.0.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + hast-util-to-jsx-runtime: 2.3.6 + mdast-util-mdx: 3.0.0 + mdast-util-mdxjs-esm: 2.0.1 medium-zoom: 1.1.0 nprogress: 0.2.0 react: 19.2.7 @@ -4408,11 +4506,11 @@ snapshots: react-router-dom: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) rehype-external-links: 3.0.0 rehype-raw: 7.0.0 - remark-cjk-friendly: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5) - remark-cjk-friendly-gfm-strikethrough: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5) - remark-gfm: 4.0.1(supports-color@7.2.0) - remark-mdx: 3.1.1(supports-color@7.2.0) - remark-parse: 11.0.0(supports-color@7.2.0) + remark-cjk-friendly: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) + remark-cjk-friendly-gfm-strikethrough: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) + remark-gfm: 4.0.1 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 remark-stringify: 11.0.0 scroll-into-view-if-needed: 3.1.0 shiki: 4.0.2 @@ -4430,9 +4528,9 @@ snapshots: - micromark-util-types - supports-color - '@rspress/plugin-sitemap@2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))': + '@rspress/plugin-sitemap@2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))': dependencies: - '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) + '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) '@rspress/shared@2.0.12': dependencies: @@ -4544,9 +4642,9 @@ snapshots: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/generator': 7.29.7 '@babel/parser': 7.29.3 - '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@7.2.0)) '@stryker-mutator/api': 9.6.1 '@stryker-mutator/util': 9.6.1 angular-html-parser: 10.4.0 @@ -4617,10 +4715,18 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/tar-stream@3.1.4': + dependencies: + '@types/node': 22.19.21 + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.19.21 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -4858,10 +4964,41 @@ snapshots: astring@1.9.0: {} + b4a@1.8.1: {} + bail@2.0.2: {} balanced-match@4.0.4: {} + bare-events@2.9.1: {} + + bare-fs@4.8.0: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.7 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.7: + dependencies: + bare-path: 3.1.1 + baseline-browser-mapping@2.11.4: {} body-scroll-lock@4.0.0-beta.0: {} @@ -5084,6 +5221,12 @@ snapshots: dependencies: '@types/estree': 1.0.8 + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + eventsource-client@1.2.0: dependencies: eventsource-parser: 3.1.0 @@ -5128,6 +5271,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -5239,7 +5384,7 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 - hast-util-to-estree@3.1.3(supports-color@7.2.0): + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.8 '@types/estree-jsx': 1.0.5 @@ -5249,9 +5394,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -5274,7 +5419,7 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 @@ -5283,9 +5428,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -5351,6 +5496,8 @@ snapshots: inline-style-parser@0.2.7: {} + ipaddr.js@2.5.0: {} + is-absolute-url@4.0.1: {} is-alphabetical@2.0.1: {} @@ -5500,14 +5647,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3(supports-color@7.2.0): + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2(supports-color@7.2.0) + micromark: 4.0.2 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -5525,67 +5672,67 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): + mdast-util-gfm-footnote@2.1.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): + mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0(supports-color@7.2.0): + mdast-util-gfm-table@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): + mdast-util-gfm-task-list-item@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0(supports-color@7.2.0): + mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) - mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0): + mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 @@ -5593,7 +5740,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -5602,23 +5749,23 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0(supports-color@7.2.0): + mdast-util-mdx@3.0.0: dependencies: - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0): + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -5677,11 +5824,11 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)): + micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2): dependencies: devlop: 1.1.0 get-east-asian-width: 1.5.0 - micromark: 4.0.2(supports-color@7.2.0) + micromark: 4.0.2 micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) micromark-util-character: 2.1.1 micromark-util-chunked: 2.0.1 @@ -5698,10 +5845,10 @@ snapshots: optionalDependencies: micromark-util-types: 2.0.2 - micromark-extension-cjk-friendly@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)): + micromark-extension-cjk-friendly@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2): dependencies: devlop: 1.1.0 - micromark: 4.0.2(supports-color@7.2.0) + micromark: 4.0.2 micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) micromark-util-chunked: 2.0.1 micromark-util-resolve-all: 2.0.1 @@ -5932,7 +6079,7 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2(supports-color@7.2.0): + micromark@4.0.2: dependencies: '@types/debug': 4.1.13 debug: 4.4.3(supports-color@7.2.0) @@ -6121,6 +6268,8 @@ snapshots: pathe@2.0.3: {} + pend@1.2.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -6249,17 +6398,17 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 - rehype-recma@1.0.0(supports-color@7.2.0): + rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 - hast-util-to-estree: 3.1.3(supports-color@7.2.0) + hast-util-to-estree: 3.1.3 transitivePeerDependencies: - supports-color - remark-cjk-friendly-gfm-strikethrough@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5): + remark-cjk-friendly-gfm-strikethrough@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): dependencies: - micromark-extension-cjk-friendly-gfm-strikethrough: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)) + micromark-extension-cjk-friendly-gfm-strikethrough: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2) unified: 11.0.5 optionalDependencies: '@types/mdast': 4.0.4 @@ -6267,9 +6416,9 @@ snapshots: - micromark - micromark-util-types - remark-cjk-friendly@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5): + remark-cjk-friendly@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): dependencies: - micromark-extension-cjk-friendly: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)) + micromark-extension-cjk-friendly: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2) unified: 11.0.5 optionalDependencies: '@types/mdast': 4.0.4 @@ -6277,28 +6426,28 @@ snapshots: - micromark - micromark-util-types - remark-gfm@4.0.1(supports-color@7.2.0): + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0(supports-color@7.2.0) + mdast-util-gfm: 3.1.0 micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0(supports-color@7.2.0) + remark-parse: 11.0.0 remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@3.1.1(supports-color@7.2.0): + remark-mdx@3.1.1: dependencies: - mdast-util-mdx: 3.0.0(supports-color@7.2.0) + mdast-util-mdx: 3.0.0 micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - remark-parse@11.0.0(supports-color@7.2.0): + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -6471,6 +6620,15 @@ snapshots: std-env@4.0.0: {} + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -6505,6 +6663,30 @@ snapshots: has-flag: 4.0.0 supports-color: 7.2.0 + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.8.0 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -6777,6 +6959,10 @@ snapshots: y18n: 5.0.8 yargs-parser: 20.2.9 + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + yoctocolors@2.2.0: {} yuku-ast@0.1.7: diff --git a/src/__tests__/npm-package-scripts.test.ts b/src/__tests__/npm-package-scripts.test.ts index e46006cbd9..fc87cedeeb 100644 --- a/src/__tests__/npm-package-scripts.test.ts +++ b/src/__tests__/npm-package-scripts.test.ts @@ -16,6 +16,10 @@ const packagedCliWorkflow = fs.readFileSync( path.join(repoRoot, '.github', 'workflows', 'ci.yml'), 'utf8', ); +const mcpRegistryWorkflow = fs.readFileSync( + path.join(repoRoot, '.github', 'workflows', 'publish-mcp-registry.yml'), + 'utf8', +); function script(name: string): string { const value = packageJson.scripts[name]; @@ -110,3 +114,39 @@ test('publishing cannot skip the package gate', () => { /run: node --experimental-strip-types scripts\/check-package\.ts/, ); }); + +test('MCP registry publishing verifies an immutable publisher before OIDC login', () => { + assert.match(mcpRegistryWorkflow, /MCP_PUBLISHER_VERSION: v1\.8\.1/); + assert.match(mcpRegistryWorkflow, /MCP_PUBLISHER_ASSET: mcp-publisher_linux_amd64\.tar\.gz/); + assert.match( + mcpRegistryWorkflow, + /MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc/, + ); + assert.doesNotMatch(mcpRegistryWorkflow, /releases\/latest/); + assert.doesNotMatch(mcpRegistryWorkflow, /curl[^\n]*\|[^\n]*tar/); + + const downloadIndex = mcpRegistryWorkflow.indexOf( + '/releases/download/${MCP_PUBLISHER_VERSION}/${MCP_PUBLISHER_ASSET}', + ); + const verificationIndex = mcpRegistryWorkflow.indexOf('sha256sum --check --strict'); + const extractionIndex = mcpRegistryWorkflow.indexOf('tar -xzf'); + const smokeCheckIndex = mcpRegistryWorkflow.indexOf('./mcp-publisher --version'); + const loginIndex = mcpRegistryWorkflow.indexOf('./mcp-publisher login github-oidc'); + const publishIndex = mcpRegistryWorkflow.indexOf('./mcp-publisher publish server.json'); + + for (const [label, index] of [ + ['versioned download', downloadIndex], + ['checksum verification', verificationIndex], + ['archive extraction', extractionIndex], + ['version smoke check', smokeCheckIndex], + ['OIDC login', loginIndex], + ['registry publish', publishIndex], + ] as const) { + assert.notEqual(index, -1, `workflow must contain ${label}`); + } + assert.ok(downloadIndex < verificationIndex, 'download must precede checksum verification'); + assert.ok(verificationIndex < extractionIndex, 'verification must precede extraction'); + assert.ok(extractionIndex < smokeCheckIndex, 'installation must precede the version smoke check'); + assert.ok(smokeCheckIndex < loginIndex, 'version smoke check must precede OIDC login'); + assert.ok(loginIndex < publishIndex, 'OIDC login must precede publish'); +}); diff --git a/src/daemon/__tests__/resumable-upload-range.test.ts b/src/daemon/__tests__/resumable-upload-range.test.ts new file mode 100644 index 0000000000..5c7ea4b6a1 --- /dev/null +++ b/src/daemon/__tests__/resumable-upload-range.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { parseUploadContentLength, parseUploadContentRange } from '../resumable-upload-range.ts'; + +test('content ranges are bounded by the declared upload size', () => { + assert.deepEqual(parseUploadContentRange('bytes 2-4/5', 5), { + start: 2, + end: 4, + size: 5, + span: 3, + }); + for (const value of ['bytes 0-5/5', 'bytes 5-5/5', 'bytes 0-0/0']) { + assert.throws(() => parseUploadContentRange(value, Number(value.split('/')[1]))); + } +}); + +test('content range and length numbers use decimal safe-integer grammar', () => { + for (const value of ['+1', '1e3', '0x10', '1.5', '-1', '9007199254740992']) { + assert.throws(() => parseUploadContentLength(value), value); + } + assert.equal(parseUploadContentLength('0'), 0); + assert.equal(parseUploadContentLength('123'), 123); + assert.equal(parseUploadContentLength(undefined), undefined); +}); diff --git a/src/daemon/__tests__/resumable-upload.test.ts b/src/daemon/__tests__/resumable-upload.test.ts index 5e7666d2dc..d126feec81 100644 --- a/src/daemon/__tests__/resumable-upload.test.ts +++ b/src/daemon/__tests__/resumable-upload.test.ts @@ -1,7 +1,15 @@ -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { PassThrough, Readable } from 'node:stream'; +import type { IncomingMessage } from 'node:http'; import { AppError } from '@agent-device/kernel/errors'; -import { finalizeResumableUpload } from '../resumable-upload.ts'; +import { + beginResumableUpload, + finalizeResumableUpload, + receiveResumableUploadChunk, +} from '../resumable-upload.ts'; test('finalizing an unknown upload reports expiry with a recovery hint', async () => { const error = await finalizeResumableUpload('missing-upload-id').then( @@ -15,3 +23,98 @@ test('finalizing an unknown upload reports expiry with a recovery hint', async ( assert.equal(appError.details?.reason, 'RESOURCE_EXPIRED'); assert.equal(typeof appError.details?.hint, 'string'); }); + +test('oversized ranged chunks roll back atomically and can be retried and finalized', async () => { + const bytes = Buffer.from('ABCDE'); + const uploadId = beginUpload(bytes).uploadId; + await assert.rejects( + receiveResumableUploadChunk({ + uploadId, + req: request(Buffer.from('ABC'), { 'content-range': 'bytes 0-1/5' }), + }), + /permitted byte range/i, + ); + assert.deepEqual( + await receiveResumableUploadChunk({ + uploadId, + req: request(Buffer.from('AB'), { 'content-range': 'bytes 0-1/5' }), + }), + { complete: false, offset: 2 }, + ); + await receiveResumableUploadChunk({ + uploadId, + req: request(Buffer.from('CDE'), { 'content-range': 'bytes 2-4/5' }), + }); + const finalized = await finalizeResumableUpload(uploadId); + try { + assert.equal(fs.readFileSync(finalized.artifactPath, 'utf8'), 'ABCDE'); + } finally { + fs.rmSync(finalized.tempDir, { recursive: true, force: true }); + } +}); + +test('an early finalize keeps the upload resumable', async () => { + const bytes = Buffer.from('resume'); + const uploadId = beginUpload(bytes).uploadId; + await assert.rejects(finalizeResumableUpload(uploadId), /incomplete/i); + await receiveResumableUploadChunk({ uploadId, req: request(bytes) }); + const finalized = await finalizeResumableUpload(uploadId); + fs.rmSync(finalized.tempDir, { recursive: true, force: true }); +}); + +test('per-ticket operations serialize while an earlier chunk is paused', async () => { + const bytes = Buffer.from('ABCD'); + const uploadId = beginUpload(bytes).uploadId; + const first = requestStream({ 'content-range': 'bytes 0-1/4' }); + const second = requestStream({ 'content-range': 'bytes 2-3/4' }); + const firstResult = receiveResumableUploadChunk({ uploadId, req: first }); + const secondResult = receiveResumableUploadChunk({ uploadId, req: second }); + second.end('CD'); + let secondSettled = false; + void secondResult.finally(() => { + secondSettled = true; + }); + await Promise.resolve(); + assert.equal(secondSettled, false); + first.end('AB'); + assert.deepEqual(await firstResult, { complete: false, offset: 2 }); + assert.deepEqual(await secondResult, { complete: true, offset: 4 }); + const finalized = await finalizeResumableUpload(uploadId); + fs.rmSync(finalized.tempDir, { recursive: true, force: true }); +}); + +test('expiry aborts an active receive and invalidates the ticket after rollback', async () => { + vi.useFakeTimers(); + try { + const bytes = Buffer.from('AB'); + const uploadId = beginUpload(bytes).uploadId; + const body = requestStream(); + const receiving = receiveResumableUploadChunk({ uploadId, req: body }); + body.write('A'); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + await assert.rejects(receiving, /expired/i); + await assert.rejects(finalizeResumableUpload(uploadId), /not found or expired/i); + } finally { + vi.useRealTimers(); + } +}); + +function beginUpload(bytes: Buffer): ReturnType { + return beginResumableUpload({ + baseUrl: 'http://127.0.0.1:1234', + tokenHeaders: {}, + uploadAttemptId: crypto.randomUUID(), + sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + fileName: 'artifact.bin', + sizeBytes: bytes.length, + artifactType: 'file', + }); +} + +function request(body: Buffer, headers: Record = {}): IncomingMessage { + return Object.assign(Readable.from(body), { headers }) as IncomingMessage; +} + +function requestStream(headers: Record = {}): PassThrough & IncomingMessage { + return Object.assign(new PassThrough(), { headers }) as PassThrough & IncomingMessage; +} diff --git a/src/daemon/__tests__/upload.test.ts b/src/daemon/__tests__/upload.test.ts index 406870bc8d..e4c31e903c 100644 --- a/src/daemon/__tests__/upload.test.ts +++ b/src/daemon/__tests__/upload.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { Readable } from 'node:stream'; import type { IncomingMessage } from 'node:http'; import { receiveUpload } from '../upload.ts'; -import { streamReadableToFile } from '../artifact-download.ts'; +import { streamReadableToFile, validateArtifactContentLength } from '../artifact-download.ts'; import { runCmdSync } from '../../utils/exec.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; @@ -23,6 +23,13 @@ test('receiveUpload rejects uploads that exceed the configured content-length li await assert.rejects(async () => await receiveUpload(req), /Upload exceeds maximum size/i); }); +test('artifact content-length accepts decimal integers only', () => { + for (const value of ['+1', '1e3', '0x10', '1.5', '-1']) { + assert.throws(() => validateArtifactContentLength(value), value); + } + assert.doesNotThrow(() => validateArtifactContentLength('0')); +}); + test('receiveUpload rejects app bundle archives containing symlinks', async () => { const tempRoot = mkdtempForTestSync('agent-device-upload-archive-'); const appDir = path.join(tempRoot, 'Sample.app'); diff --git a/src/daemon/artifact-archive.ts b/src/daemon/artifact-archive.ts index 8cda6a8f80..5b04b3d9d6 100644 --- a/src/daemon/artifact-archive.ts +++ b/src/daemon/artifact-archive.ts @@ -1,7 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { runCmd } from '../utils/exec.ts'; +import { extractArchiveSafely } from '../utils/archive-extraction.ts'; +import type { ArchiveManifestEntry } from '../utils/archive-safety.ts'; export async function extractTarInstallableArtifact(params: { archivePath: string; @@ -9,10 +10,19 @@ export async function extractTarInstallableArtifact(params: { platform: 'ios' | 'android'; expectedRootName?: string; }): Promise { - const rootName = await resolveTarArchiveRootName(params); - await runCmd('tar', ['xf', params.archivePath, '-C', params.tempDir]); - const installablePath = path.join(params.tempDir, rootName); + const outputRoot = path.join(params.tempDir, 'extracted'); + let rootName = ''; + await extractArchiveSafely({ + archivePath: params.archivePath, + outputRoot, + type: 'tar', + validateManifest: (manifest) => { + rootName = resolveArchiveRootName(manifest, params.platform, params.expectedRootName); + }, + }); + const installablePath = path.join(outputRoot, rootName); if (!fs.existsSync(installablePath)) { + fs.rmSync(outputRoot, { recursive: true, force: true }); throw new AppError( 'INVALID_ARGS', `Expected extracted bundle "${rootName}" not found in archive`, @@ -21,70 +31,41 @@ export async function extractTarInstallableArtifact(params: { return installablePath; } -async function resolveTarArchiveRootName(params: { - archivePath: string; - platform: 'ios' | 'android'; - expectedRootName?: string; -}): Promise { - const entriesResult = await runCmd('tar', ['-tf', params.archivePath], { allowFailure: true }); - if (entriesResult.exitCode !== 0) { - throw new AppError('INVALID_ARGS', 'Artifact is not a valid tar archive', { - archivePath: params.archivePath, - stdout: entriesResult.stdout, - stderr: entriesResult.stderr, - exitCode: entriesResult.exitCode, - }); - } - - const entries = entriesResult.stdout - .split(/\r?\n/) - .map((entry) => entry.trim()) - .filter(Boolean); - if (entries.length === 0) { +function resolveArchiveRootName( + manifest: readonly ArchiveManifestEntry[], + platform: 'ios' | 'android', + expectedRootName?: string, +): string { + if (manifest.length === 0) { throw new AppError('INVALID_ARGS', 'Uploaded app bundle archive is empty'); } - - const normalizedEntries = entries.map(normalizeArchiveEntry); - const rootName = - params.expectedRootName ?? resolveArchiveRootName(normalizedEntries, params.platform); - const hasExpectedRoot = normalizedEntries.some( - (entry) => entry === rootName || entry.startsWith(`${rootName}/`), + const roots = new Set( + manifest + .map((entry) => entry.name.split('/')[0]) + .filter((entry): entry is string => Boolean(entry)), ); - if (!hasExpectedRoot) { + const rootName = expectedRootName ?? inferArchiveRootName([...roots], platform); + if (!roots.has(rootName)) { throw new AppError( 'INVALID_ARGS', `Uploaded archive must contain a top-level "${rootName}" bundle`, ); } - - for (const entry of normalizedEntries) { - validateArchiveEntryPath(entry, rootName); - } - - const verboseResult = await runCmd('tar', ['-tvf', params.archivePath]); - for (const line of verboseResult.stdout.split(/\r?\n/).filter(Boolean)) { - if (line[0] === 'l' || line[0] === 'h') { + for (const entry of manifest) { + if (entry.name !== rootName && !entry.name.startsWith(`${rootName}/`)) { throw new AppError( 'INVALID_ARGS', - 'Uploaded app bundle archive cannot contain symlinks or hard links', + `Archive entry must stay inside top-level "${rootName}" bundle: ${entry.name}`, ); } } - return rootName; } -function resolveArchiveRootName(entries: string[], platform: 'ios' | 'android'): string { - const roots = new Set(); - for (const entry of entries) { - const [root] = entry.split('/'); - if (root) roots.add(root); - } - const rootEntries = [...roots]; +function inferArchiveRootName(roots: string[], platform: 'ios' | 'android'): string { if (platform === 'ios') { - const appRoots = rootEntries.filter((entry) => entry.toLowerCase().endsWith('.app')); - const appRoot = appRoots[0]; - if (appRoot !== undefined && appRoots.length === 1) return appRoot; + const appRoots = roots.filter((entry) => entry.toLowerCase().endsWith('.app')); + if (appRoots.length === 1) return appRoots[0]!; if (appRoots.length === 0) { throw new AppError( 'INVALID_ARGS', @@ -96,33 +77,9 @@ function resolveArchiveRootName(entries: string[], platform: 'ios' | 'android'): `iOS app bundle archives must contain exactly one top-level .app directory, found: ${appRoots.join(', ')}`, ); } - const rootEntry = rootEntries[0]; - if (rootEntry !== undefined && rootEntries.length === 1) return rootEntry; + if (roots.length === 1) return roots[0]!; throw new AppError( 'INVALID_ARGS', - `Archive must contain a single top-level bundle, found: ${rootEntries.join(', ')}`, + `Archive must contain a single top-level bundle, found: ${roots.join(', ')}`, ); } - -function normalizeArchiveEntry(entry: string): string { - if (entry.includes('\0')) { - throw new AppError('INVALID_ARGS', `Invalid archive entry: ${entry}`); - } - if (path.posix.isAbsolute(entry)) { - throw new AppError('INVALID_ARGS', `Archive entry must be relative: ${entry}`); - } - const normalized = path.posix.normalize(entry).replace(/^(\.\/)+/, ''); - if (!normalized || normalized === '.' || normalized.startsWith('../')) { - throw new AppError('INVALID_ARGS', `Archive entry escapes bundle root: ${entry}`); - } - return normalized; -} - -function validateArchiveEntryPath(entry: string, rootName: string): void { - if (entry !== rootName && !entry.startsWith(`${rootName}/`)) { - throw new AppError( - 'INVALID_ARGS', - `Archive entry must stay inside top-level "${rootName}" bundle: ${entry}`, - ); - } -} diff --git a/src/daemon/artifact-download.ts b/src/daemon/artifact-download.ts index 723ecefbaf..b9d507f30e 100644 --- a/src/daemon/artifact-download.ts +++ b/src/daemon/artifact-download.ts @@ -2,11 +2,11 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { once } from 'node:events'; -import { Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { AppError } from '@agent-device/kernel/errors'; +import { MAX_ARTIFACT_COMPRESSED_BYTES } from '../utils/artifact-limits.ts'; +import { createByteLimitStream } from '../utils/byte-limit-stream.ts'; -const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB const TEMP_PREFIX = 'agent-device-artifact-'; const REQUEST_IDLE_TIMEOUT_MS = 60_000; @@ -26,12 +26,18 @@ export function createArtifactTempDir(requestId?: string): string { export function validateArtifactContentLength(rawLength: string | number | undefined): void { if (rawLength === undefined) return; - const parsed = Number(rawLength); - // Ignore malformed content-length values; the streaming byte cap still enforces the hard limit. - if (Number.isFinite(parsed) && parsed > MAX_ARTIFACT_BYTES) { + const raw = String(rawLength); + if (!/^\d+$/.test(raw)) { + throw new AppError('INVALID_ARGS', 'Invalid content-length header'); + } + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed)) { + throw new AppError('INVALID_ARGS', 'Invalid content-length header'); + } + if (parsed > MAX_ARTIFACT_COMPRESSED_BYTES) { throw new AppError( 'INVALID_ARGS', - `Upload exceeds maximum size of ${MAX_ARTIFACT_BYTES} bytes`, + `Upload exceeds maximum size of ${MAX_ARTIFACT_COMPRESSED_BYTES} bytes`, ); } } @@ -42,7 +48,6 @@ export function streamReadableToFile( ): Promise { return new Promise((resolve, reject) => { let settled = false; - let bytesWritten = 0; let timeoutHandle: ReturnType | undefined; const output = fs.createWriteStream(destPath); @@ -74,23 +79,15 @@ export function streamReadableToFile( }, REQUEST_IDLE_TIMEOUT_MS); }; - const byteLimit = new Transform({ - transform(chunk: Buffer | string, encoding, callback) { - armTimeout(); - const size = Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk, encoding); - bytesWritten += size; - if (bytesWritten > MAX_ARTIFACT_BYTES) { - callback( - new AppError( - 'INVALID_ARGS', - `Upload exceeds maximum size of ${MAX_ARTIFACT_BYTES} bytes`, - ), - ); - return; - } - callback(null, chunk); - }, + const byteLimit = createByteLimitStream({ + maxBytes: MAX_ARTIFACT_COMPRESSED_BYTES, + createLimitError: () => + new AppError( + 'INVALID_ARGS', + `Upload exceeds maximum size of ${MAX_ARTIFACT_COMPRESSED_BYTES} bytes`, + ), }); + byteLimit.on('data', armTimeout); source.on('aborted', () => { settle(new AppError('COMMAND_FAILED', 'Artifact transfer was interrupted')); diff --git a/src/daemon/resumable-upload-range.ts b/src/daemon/resumable-upload-range.ts new file mode 100644 index 0000000000..d5b8247d0b --- /dev/null +++ b/src/daemon/resumable-upload-range.ts @@ -0,0 +1,50 @@ +import { AppError } from '@agent-device/kernel/errors'; + +export type UploadContentRange = { + start: number; + end: number; + size: number; + span: number; +}; + +export function parseUploadContentRange( + value: string | string[] | undefined, + expectedSize: number, +): UploadContentRange | undefined { + const raw = Array.isArray(value) ? value[0] : value; + if (!raw) return undefined; + const match = raw.match(/^bytes (\d+)-(\d+)\/(\d+)$/); + if (!match) invalidRange(); + const start = Number(match[1]); + const end = Number(match[2]); + const size = Number(match[3]); + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + !Number.isSafeInteger(size) || + start < 0 || + end < start || + end >= size || + size < 1 || + size !== expectedSize + ) { + invalidRange(); + } + return { start, end, size, span: end - start + 1 }; +} + +export function parseUploadContentLength(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + if (!/^\d+$/.test(value)) { + throw new AppError('INVALID_ARGS', 'Invalid content-length header'); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new AppError('INVALID_ARGS', 'Invalid content-length header'); + } + return parsed; +} + +function invalidRange(): never { + throw new AppError('INVALID_ARGS', 'Invalid content-range header'); +} diff --git a/src/daemon/resumable-upload-transfer.ts b/src/daemon/resumable-upload-transfer.ts new file mode 100644 index 0000000000..1b7417c357 --- /dev/null +++ b/src/daemon/resumable-upload-transfer.ts @@ -0,0 +1,99 @@ +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import type { IncomingMessage } from 'node:http'; +import { pipeline } from 'node:stream/promises'; +import { AppError } from '@agent-device/kernel/errors'; +import { createByteLimitStream } from '../utils/byte-limit-stream.ts'; +import { parseUploadContentLength, parseUploadContentRange } from './resumable-upload-range.ts'; + +export type ResumableTransferEntry = { + payloadPath: string; + sizeBytes: number; +}; + +export async function receiveResumableTransfer(params: { + entry: ResumableTransferEntry; + req: IncomingMessage; + invalidate: (error: unknown) => Promise; +}): Promise<{ complete: boolean; offset: number }> { + const oldSize = await currentOffset(params.entry); + const range = parseUploadContentRange( + params.req.headers['content-range'], + params.entry.sizeBytes, + ); + if (range && range.start !== oldSize) return { complete: false, offset: oldSize }; + if (!range && oldSize > 0) return { complete: false, offset: oldSize }; + const remaining = params.entry.sizeBytes - oldSize; + const bodyLimit = range?.span ?? remaining; + const contentLength = parseUploadContentLength(params.req.headers['content-length']); + if (contentLength !== undefined && contentLength > bodyLimit) { + throw new AppError('INVALID_ARGS', 'Upload chunk exceeds its permitted byte range'); + } + if (range && contentLength !== undefined && contentLength !== range.span) { + throw new AppError('INVALID_ARGS', 'Upload chunk length does not match content-range'); + } + + const limiter = createByteLimitStream({ + maxBytes: bodyLimit, + createLimitError: () => + new AppError('INVALID_ARGS', 'Upload chunk exceeds its permitted byte range'), + }); + try { + await pipeline( + params.req, + limiter, + fs.createWriteStream(params.entry.payloadPath, { flags: 'a' }), + ); + if (range && limiter.bytesSeen !== range.span) { + throw new AppError('INVALID_ARGS', 'Upload chunk length does not match content-range'); + } + if (contentLength !== undefined && limiter.bytesSeen !== contentLength) { + throw new AppError('INVALID_ARGS', 'Upload chunk length does not match content-length'); + } + } catch (error) { + await rollback(params.entry.payloadPath, oldSize, error, params.invalidate); + throw error; + } + const offset = await currentOffset(params.entry); + return { complete: offset === params.entry.sizeBytes, offset }; +} + +export async function computeUploadHash(filePath: string): Promise { + const hash = crypto.createHash('sha256'); + for await (const chunk of fs.createReadStream(filePath)) hash.update(chunk); + return hash.digest('hex'); +} + +async function currentOffset(entry: ResumableTransferEntry): Promise { + const stat = await fs.promises.stat(entry.payloadPath).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + const size = stat?.size ?? 0; + if (size > entry.sizeBytes) { + throw new AppError('COMMAND_FAILED', 'Upload state is corrupt', { + reason: 'UPLOAD_STATE_CORRUPT', + }); + } + return size; +} + +async function rollback( + payloadPath: string, + oldSize: number, + originalError: unknown, + invalidate: (error: unknown) => Promise, +): Promise { + try { + await fs.promises.truncate(payloadPath, oldSize); + } catch (cleanupError) { + const failure = new AppError( + 'COMMAND_FAILED', + 'Upload rollback failed; the upload ticket was invalidated', + { reason: 'UPLOAD_ROLLBACK_FAILED', originalError: String(originalError) }, + cleanupError, + ); + await invalidate(failure); + throw failure; + } +} diff --git a/src/daemon/resumable-upload.ts b/src/daemon/resumable-upload.ts index e582383c38..229cd4b544 100644 --- a/src/daemon/resumable-upload.ts +++ b/src/daemon/resumable-upload.ts @@ -2,7 +2,6 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import type { IncomingMessage } from 'node:http'; -import { pipeline } from 'node:stream/promises'; import { AppError } from '@agent-device/kernel/errors'; import { extractTarInstallableArtifact } from './artifact-archive.ts'; import { requireTenantOwnedEntry, type TenantOwnedResourceKind } from './tenant-owned-entry.ts'; @@ -11,19 +10,16 @@ import { sanitizeArtifactFilename, validateArtifactContentLength, } from './artifact-download.ts'; +import { computeUploadHash, receiveResumableTransfer } from './resumable-upload-transfer.ts'; const RESUMABLE_UPLOAD_CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; - const RESUMABLE_UPLOAD_RESOURCE: TenantOwnedResourceKind = { label: 'Upload', expiredHint: `Resumable uploads expire ${RESUMABLE_UPLOAD_CLEANUP_TIMEOUT_MS / 60_000} minutes after the last received chunk. Start the upload again from the beginning.`, }; -const RESUMABLE_UPLOAD_HASH_ALGORITHM = 'sha256'; const RESUMABLE_UPLOADS_BY_ID = new Map(); const RESUMABLE_UPLOADS_BY_KEY = new Map(); -type UploadArtifactType = 'file' | 'app-bundle'; - export type BeginResumableUploadOptions = { baseUrl: string; tokenHeaders: Record; @@ -31,7 +27,7 @@ export type BeginResumableUploadOptions = { sha256: string; fileName: string; sizeBytes: number; - artifactType: UploadArtifactType; + artifactType: 'file' | 'app-bundle'; platform?: string; contentType?: string; tenantId?: string; @@ -45,19 +41,21 @@ type ResumableUploadEntry = { fileName: string; sizeBytes: number; sha256: string; - artifactType: UploadArtifactType; + artifactType: 'file' | 'app-bundle'; platform?: string; tenantId?: string; - timer: ReturnType; + timer?: ReturnType; + generation: number; + tail: Promise; + activeReceive?: IncomingMessage; + closed: boolean; + finalized: boolean; }; export function beginResumableUpload(options: BeginResumableUploadOptions): { uploadId: string; cacheHit: false; - upload: { - url: string; - headers: Record; - }; + upload: { url: string; headers: Record }; } { validateResumableUploadOptions(options); const key = buildResumableUploadKey(options); @@ -65,12 +63,14 @@ export function beginResumableUpload(options: BeginResumableUploadOptions): { const existing = existingId ? RESUMABLE_UPLOADS_BY_ID.get(existingId) : undefined; const entry = existing ?? createResumableUploadEntry(options, key); refreshResumableUploadTimer(entry); - return { uploadId: entry.id, cacheHit: false, upload: { - url: new URL(`upload/direct/${entry.id}`, ensureTrailingSlash(options.baseUrl)).toString(), + url: new URL( + `upload/direct/${entry.id}`, + options.baseUrl.endsWith('/') ? options.baseUrl : `${options.baseUrl}/`, + ).toString(), headers: { ...options.tokenHeaders, 'content-type': options.contentType || 'application/octet-stream', @@ -85,21 +85,15 @@ export async function receiveResumableUploadChunk(params: { tenantId?: string; }): Promise<{ complete: boolean; offset: number }> { const entry = requireResumableUpload(params.uploadId, params.tenantId); - const currentOffset = currentResumableUploadOffset(entry); - const contentRange = parseContentRange(params.req.headers['content-range'], entry.sizeBytes); - if (contentRange && contentRange.start !== currentOffset) { - return { complete: false, offset: currentOffset }; - } - if (!contentRange && currentOffset > 0) { - return { complete: false, offset: currentOffset }; - } - - validateArtifactContentLength(params.req.headers['content-length']); - await pipeline(params.req, fs.createWriteStream(entry.payloadPath, { flags: 'a' })); - refreshResumableUploadTimer(entry); - - const offset = currentResumableUploadOffset(entry); - return { complete: offset >= entry.sizeBytes, offset }; + return await runExclusive(entry, 'receive', params.req, async () => { + const result = await receiveResumableTransfer({ + entry, + req: params.req, + invalidate: async () => await terminateEntry(entry), + }); + if (!entry.closed) refreshResumableUploadTimer(entry); + return result; + }); } export async function finalizeResumableUpload( @@ -107,56 +101,31 @@ export async function finalizeResumableUpload( tenantId?: string, ): Promise<{ artifactPath: string; tempDir: string }> { const entry = requireResumableUpload(uploadId, tenantId); - const offset = currentResumableUploadOffset(entry); - if (offset !== entry.sizeBytes) { - throw new AppError('INVALID_ARGS', 'Upload is incomplete', { - uploadId, - offset, - sizeBytes: entry.sizeBytes, - }); - } - const actualHash = await computeFileHash(entry.payloadPath); - if (actualHash !== entry.sha256) { - cleanupResumableUpload(entry.id); - throw new AppError('INVALID_ARGS', 'Upload hash mismatch', { - uploadId, - expectedSha256: entry.sha256, - actualSha256: actualHash, - }); - } - - RESUMABLE_UPLOADS_BY_ID.delete(entry.id); - RESUMABLE_UPLOADS_BY_KEY.delete(entry.key); - clearTimeout(entry.timer); - - if (entry.artifactType === 'file') { - const artifactPath = path.join(entry.tempDir, entry.fileName); - fs.renameSync(entry.payloadPath, artifactPath); - return { artifactPath, tempDir: entry.tempDir }; - } - - const artifactPath = await extractTarInstallableArtifact({ - archivePath: entry.payloadPath, - tempDir: entry.tempDir, - platform: entry.platform === 'android' ? 'android' : 'ios', - expectedRootName: entry.fileName, + return await runExclusive(entry, 'finalize', undefined, async () => { + const offset = fs.statSync(entry.payloadPath).size; + if (offset !== entry.sizeBytes) { + throw new AppError('INVALID_ARGS', 'Upload is incomplete', { + uploadId, + offset, + sizeBytes: entry.sizeBytes, + }); + } + try { + const actualHash = await computeUploadHash(entry.payloadPath); + if (actualHash !== entry.sha256) { + throw new AppError('INVALID_ARGS', 'Upload hash mismatch', { + uploadId, + expectedSha256: entry.sha256, + actualSha256: actualHash, + }); + } + const artifactPath = await materializeFinalArtifact(entry); + markFinalized(entry); + return { artifactPath, tempDir: entry.tempDir }; + } catch (error) { + throw await cleanupTerminalFinalizeFailure(entry, error); + } }); - fs.rmSync(entry.payloadPath, { force: true }); - return { artifactPath, tempDir: entry.tempDir }; -} - -function validateResumableUploadOptions(options: BeginResumableUploadOptions): void { - if (!/^[a-f0-9]{64}$/i.test(options.sha256)) { - throw new AppError('INVALID_ARGS', 'Invalid upload sha256'); - } - if (!Number.isSafeInteger(options.sizeBytes) || options.sizeBytes < 0) { - throw new AppError('INVALID_ARGS', 'Invalid upload sizeBytes'); - } - validateArtifactContentLength(String(options.sizeBytes)); - sanitizeArtifactFilename(options.fileName); - if (!options.uploadAttemptId.trim()) { - throw new AppError('INVALID_ARGS', 'uploadAttemptId is required'); - } } function createResumableUploadEntry( @@ -165,41 +134,129 @@ function createResumableUploadEntry( ): ResumableUploadEntry { const id = crypto.randomUUID(); const tempDir = createArtifactTempDir('upload'); + const payloadPath = path.join(tempDir, 'payload'); + fs.writeFileSync(payloadPath, ''); const entry: ResumableUploadEntry = { id, key, tempDir, - payloadPath: path.join(tempDir, 'payload'), + payloadPath, fileName: sanitizeArtifactFilename(options.fileName), sizeBytes: options.sizeBytes, sha256: options.sha256.toLowerCase(), artifactType: options.artifactType, platform: options.platform, tenantId: options.tenantId, - timer: setTimeout(() => cleanupResumableUpload(id), RESUMABLE_UPLOAD_CLEANUP_TIMEOUT_MS), + generation: 0, + tail: Promise.resolve(), + closed: false, + finalized: false, }; - entry.timer.unref(); RESUMABLE_UPLOADS_BY_ID.set(id, entry); RESUMABLE_UPLOADS_BY_KEY.set(key, id); return entry; } function refreshResumableUploadTimer(entry: ResumableUploadEntry): void { - clearTimeout(entry.timer); + if (entry.timer) clearTimeout(entry.timer); + entry.generation += 1; + const generation = entry.generation; entry.timer = setTimeout( - () => cleanupResumableUpload(entry.id), + () => expireEntry(entry, generation), RESUMABLE_UPLOAD_CLEANUP_TIMEOUT_MS, ); entry.timer.unref(); } -function cleanupResumableUpload(uploadId: string): void { - const entry = RESUMABLE_UPLOADS_BY_ID.get(uploadId); - if (!entry) return; - clearTimeout(entry.timer); - RESUMABLE_UPLOADS_BY_ID.delete(uploadId); +function expireEntry(entry: ResumableUploadEntry, generation: number): void { + if (entry.generation !== generation || entry.closed) return; + closeEntry(entry); + const activeReceive = entry.activeReceive; + activeReceive?.once('error', () => {}); + activeReceive?.destroy( + new AppError('COMMAND_FAILED', 'Upload expired while receiving data', { + reason: 'RESOURCE_EXPIRED', + }), + ); + const cleanup = entry.tail.then(async () => { + if (!entry.finalized) await fs.promises.rm(entry.tempDir, { recursive: true, force: true }); + }); + entry.tail = cleanup.catch(() => {}); +} + +async function runExclusive( + entry: ResumableUploadEntry, + kind: 'receive' | 'finalize', + req: IncomingMessage | undefined, + action: () => Promise, +): Promise { + const operation = entry.tail + .catch(() => {}) + .then(async () => { + if (entry.closed) requireResumableUpload(entry.id, entry.tenantId); + if (kind === 'receive') entry.activeReceive = req; + try { + return await action(); + } finally { + if (kind === 'receive') entry.activeReceive = undefined; + } + }); + entry.tail = operation.then( + () => {}, + () => {}, + ); + return await operation; +} + +async function materializeFinalArtifact(entry: ResumableUploadEntry): Promise { + if (entry.artifactType === 'file') { + const artifactPath = path.join(entry.tempDir, entry.fileName); + await fs.promises.rename(entry.payloadPath, artifactPath); + return artifactPath; + } + const artifactPath = await extractTarInstallableArtifact({ + archivePath: entry.payloadPath, + tempDir: entry.tempDir, + platform: entry.platform === 'android' ? 'android' : 'ios', + expectedRootName: entry.fileName, + }); + await fs.promises.rm(entry.payloadPath, { force: true }); + return artifactPath; +} + +function markFinalized(entry: ResumableUploadEntry): void { + entry.finalized = true; + closeEntry(entry); +} + +async function cleanupTerminalFinalizeFailure( + entry: ResumableUploadEntry, + originalError: unknown, +): Promise { + closeEntry(entry); + try { + await fs.promises.rm(entry.tempDir, { recursive: true, force: true }); + return originalError; + } catch (cleanupError) { + return new AppError( + 'COMMAND_FAILED', + 'Upload finalize cleanup failed', + { reason: 'UPLOAD_FINALIZE_CLEANUP_FAILED', originalError: String(originalError) }, + cleanupError, + ); + } +} + +async function terminateEntry(entry: ResumableUploadEntry): Promise { + closeEntry(entry); + await fs.promises.rm(entry.tempDir, { recursive: true, force: true }).catch(() => {}); +} + +function closeEntry(entry: ResumableUploadEntry): void { + entry.closed = true; + if (entry.timer) clearTimeout(entry.timer); + RESUMABLE_UPLOADS_BY_ID.delete(entry.id); RESUMABLE_UPLOADS_BY_KEY.delete(entry.key); - fs.rmSync(entry.tempDir, { recursive: true, force: true }); } function requireResumableUpload( @@ -214,46 +271,18 @@ function requireResumableUpload( ); } -function currentResumableUploadOffset(entry: ResumableUploadEntry): number { - if (!fs.existsSync(entry.payloadPath)) return 0; - return Math.min(fs.statSync(entry.payloadPath).size, entry.sizeBytes); -} - -function parseContentRange( - value: string | string[] | undefined, - sizeBytes: number, -): { start: number; end: number } | undefined { - const raw = Array.isArray(value) ? value[0] : value; - if (!raw) return undefined; - const range = readContentRange(raw); - if (!range || !isValidContentRange(range, sizeBytes)) { - throw new AppError('INVALID_ARGS', 'Invalid content-range header'); +function validateResumableUploadOptions(options: BeginResumableUploadOptions): void { + if (!/^[a-f0-9]{64}$/i.test(options.sha256)) { + throw new AppError('INVALID_ARGS', 'Invalid upload sha256'); + } + if (!Number.isSafeInteger(options.sizeBytes) || options.sizeBytes < 0) { + throw new AppError('INVALID_ARGS', 'Invalid upload sizeBytes'); + } + validateArtifactContentLength(String(options.sizeBytes)); + sanitizeArtifactFilename(options.fileName); + if (!options.uploadAttemptId.trim()) { + throw new AppError('INVALID_ARGS', 'uploadAttemptId is required'); } - return { start: range.start, end: range.end }; -} - -function readContentRange(raw: string): { start: number; end: number; size: number } | null { - const match = raw.match(/^bytes (\d+)-(\d+)\/(\d+)$/); - if (!match) return null; - return { - start: Number(match[1]), - end: Number(match[2]), - size: Number(match[3]), - }; -} - -function isValidContentRange( - range: { start: number; end: number; size: number }, - sizeBytes: number, -): boolean { - return ( - Number.isSafeInteger(range.start) && - Number.isSafeInteger(range.end) && - Number.isSafeInteger(range.size) && - range.start >= 0 && - range.end >= range.start && - range.size === sizeBytes - ); } function buildResumableUploadKey(options: BeginResumableUploadOptions): string { @@ -267,18 +296,3 @@ function buildResumableUploadKey(options: BeginResumableUploadOptions): string { options.platform ?? '', ].join('\0'); } - -function ensureTrailingSlash(value: string): string { - return value.endsWith('/') ? value : `${value}/`; -} - -async function computeFileHash(filePath: string): Promise { - const hash = crypto.createHash(RESUMABLE_UPLOAD_HASH_ALGORITHM); - await pipeline(fs.createReadStream(filePath), async function* (source) { - for await (const chunk of source) { - hash.update(chunk); - yield chunk; - } - }); - return hash.digest('hex'); -} diff --git a/src/platforms/__tests__/install-source-download.test.ts b/src/platforms/__tests__/install-source-download.test.ts new file mode 100644 index 0000000000..ef6b51ebf0 --- /dev/null +++ b/src/platforms/__tests__/install-source-download.test.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import dns from 'node:dns/promises'; +import fs from 'node:fs/promises'; +import { Readable } from 'node:stream'; +import { test, vi } from 'vitest'; +import { mkdtempForTest } from '../../__tests__/test-utils/tmp-dir.ts'; +import { downloadInstallSource } from '../install-source-download.ts'; +import * as networkTransport from '../install-source-network-transport.ts'; + +test('download redirects revalidate destinations and strip sensitive cross-origin headers', async () => { + const tempRoot = await mkdtempForTest('agent-device-download-redirect-'); + const lookup = vi + .spyOn(dns, 'lookup') + .mockImplementation( + async () => + [{ address: '93.184.216.34', family: 4 }] as unknown as Awaited< + ReturnType + >, + ); + const requestMock = vi + .spyOn(networkTransport, 'requestApprovedUrl') + .mockResolvedValueOnce( + response(302, Buffer.alloc(0), { location: 'https://cdn.example.net/app.apk' }), + ) + .mockResolvedValueOnce(response(200, Buffer.from('apk'))); + try { + const result = await downloadInstallSource({ + tempDir: tempRoot, + url: 'https://example.com/start', + headers: { + authorization: 'secret', + accept: 'application/octet-stream', + connection: 'x-private', + 'x-private': 'remove-me', + 'user-agent': 'agent-device-test', + }, + signal: new AbortController().signal, + }); + assert.equal(await fs.readFile(result, 'utf8'), 'apk'); + assert.equal(lookup.mock.calls.length, 2); + const first = requestMock.mock.calls[0]![0]; + const second = requestMock.mock.calls[1]![0]; + assert.equal(first.headers['accept-encoding'], 'identity'); + assert.equal(second.headers.authorization, undefined); + assert.equal(second.headers['x-private'], undefined); + assert.equal(second.headers.accept, 'application/octet-stream'); + } finally { + requestMock.mockRestore(); + lookup.mockRestore(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('download errors do not disclose URL credentials or query values', async () => { + const tempRoot = await mkdtempForTest('agent-device-download-redaction-'); + const lookup = vi + .spyOn(dns, 'lookup') + .mockImplementation( + async () => + [{ address: '93.184.216.34', family: 4 }] as unknown as Awaited< + ReturnType + >, + ); + const requestMock = vi + .spyOn(networkTransport, 'requestApprovedUrl') + .mockResolvedValue(response(503)); + try { + const error = await downloadInstallSource({ + tempDir: tempRoot, + url: 'https://private-user:private-pass@example.com/app?token=private-query', + headers: { authorization: 'private-header' }, + signal: new AbortController().signal, + }).catch((caught: unknown) => caught); + const serialized = JSON.stringify(error); + for (const secret of ['private-user', 'private-pass', 'private-query', 'private-header']) { + assert.equal(serialized.includes(secret), false, secret); + } + } finally { + requestMock.mockRestore(); + lookup.mockRestore(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('download rejects non-identity content encoding and malformed lengths', async () => { + const tempRoot = await mkdtempForTest('agent-device-download-metadata-'); + const lookup = vi + .spyOn(dns, 'lookup') + .mockImplementation( + async () => + [{ address: '93.184.216.34', family: 4 }] as unknown as Awaited< + ReturnType + >, + ); + const requestMock = vi.spyOn(networkTransport, 'requestApprovedUrl'); + try { + requestMock.mockResolvedValueOnce( + response(200, Buffer.from('body'), { 'content-encoding': 'gzip' }), + ); + await assert.rejects( + downloadInstallSource({ + tempDir: tempRoot, + url: 'https://example.com/app', + signal: new AbortController().signal, + }), + /content encoding/i, + ); + requestMock.mockResolvedValueOnce( + response(200, Buffer.from('body'), { 'content-length': '+4' }), + ); + await assert.rejects( + downloadInstallSource({ + tempDir: tempRoot, + url: 'https://example.com/app', + signal: new AbortController().signal, + }), + /content-length/i, + ); + } finally { + requestMock.mockRestore(); + lookup.mockRestore(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + +function response( + statusCode: number, + body: Buffer = Buffer.alloc(0), + headers: Record = {}, +): networkTransport.InstallSourceNetworkResponse { + return { + statusCode, + statusText: String(statusCode), + headers, + body: Readable.from(body), + close: async () => {}, + }; +} diff --git a/src/platforms/__tests__/install-source-network-transport.test.ts b/src/platforms/__tests__/install-source-network-transport.test.ts new file mode 100644 index 0000000000..258d9f8d4e --- /dev/null +++ b/src/platforms/__tests__/install-source-network-transport.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { matchesNoProxy, resolveProxyForUrl } from '../install-source-network-transport.ts'; + +test('lowercase proxy variables override uppercase even when empty', () => { + assert.equal( + resolveProxyForUrl(new URL('http://example.net'), { + http_proxy: '', + HTTP_PROXY: 'http://uppercase-proxy', + }), + undefined, + ); + assert.equal( + resolveProxyForUrl(new URL('https://example.net'), { + https_proxy: '', + HTTPS_PROXY: 'http://uppercase-proxy', + http_proxy: 'http://fallback-proxy', + }), + 'http://fallback-proxy', + ); +}); + +test('HTTPS proxy selection falls back to the selected HTTP proxy', () => { + assert.equal( + resolveProxyForUrl(new URL('https://example.net'), { http_proxy: 'http://proxy' }), + 'http://proxy', + ); +}); + +test('NO_PROXY matches exact hosts, subdomains, ports, wildcards, and bracketed IPv6', () => { + assert.equal(matchesNoProxy(new URL('https://example.com'), 'example.com'), true); + assert.equal(matchesNoProxy(new URL('https://sub.example.com'), 'example.com'), true); + assert.equal(matchesNoProxy(new URL('https://example.com:444'), 'example.com:443'), false); + assert.equal( + matchesNoProxy(new URL('https://[2001:4860:4860::8888]'), '[2001:4860:4860::8888]'), + true, + ); + assert.equal(matchesNoProxy(new URL('https://elsewhere.example'), '*'), true); +}); diff --git a/src/platforms/__tests__/install-source.test.ts b/src/platforms/__tests__/install-source.test.ts index c2e9bf4f3e..a7d2d7c9b2 100644 --- a/src/platforms/__tests__/install-source.test.ts +++ b/src/platforms/__tests__/install-source.test.ts @@ -1,20 +1,19 @@ import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; import dns from 'node:dns/promises'; import fsSync from 'node:fs'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { withCommandExecutorOverride } from '../../utils/exec.ts'; +import { Readable } from 'node:stream'; +import { runCmdSync, withCommandExecutorOverride } from '../../utils/exec.ts'; import { ARCHIVE_EXTENSIONS, - isBlockedIpAddress, - isBlockedSourceHostname, isTrustedInstallSourceUrl, materializeInstallablePath, validateDownloadSourceUrl, } from '../install-source.ts'; +import { isBlockedIpAddress, isBlockedSourceHostname } from '../install-source-network.ts'; import * as androidManifest from '../android/manifest.ts'; import { prepareAndroidInstallArtifact } from '../android/install-artifact.ts'; import { prepareIosInstallArtifact } from '../apple/core/install-artifact.ts'; @@ -24,6 +23,7 @@ import { } from '../apple/core/tool-provider.ts'; import { ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE } from './install-source.coverage.ts'; import { mkdtempForTest } from '../../__tests__/test-utils/tmp-dir.ts'; +import * as networkTransport from '../install-source-network-transport.ts'; test('validateDownloadSourceUrl rejects localhost and private literal addresses by default', async () => { await assert.rejects( @@ -53,7 +53,25 @@ test('install-source helpers expose the SSRF and archive surface', () => { assert.equal(isBlockedSourceHostname('localhost'), true); assert.equal(isBlockedSourceHostname('example.com'), false); assert.equal(isBlockedIpAddress('127.0.0.1'), true); - assert.equal(isBlockedIpAddress('203.0.113.10'), false); + assert.equal(isBlockedIpAddress('0.0.0.0'), true); + assert.equal(isBlockedIpAddress('100.64.0.1'), true); + assert.equal(isBlockedIpAddress('203.0.113.10'), true); + assert.equal(isBlockedIpAddress('::ffff:127.0.0.1'), true); + assert.equal(isBlockedIpAddress('93.184.216.34'), false); +}); + +test('validateDownloadSourceUrl fails closed when DNS returns no public address', async () => { + const lookupMock = vi + .spyOn(dns, 'lookup') + .mockResolvedValue([] as unknown as Awaited>); + try { + await assert.rejects( + async () => await validateDownloadSourceUrl(new URL('https://example.com/app.apk')), + /could not be resolved|public address/i, + ); + } finally { + lookupMock.mockRestore(); + } }); test('isTrustedInstallSourceUrl recognizes supported artifact services', () => { @@ -121,7 +139,7 @@ test.sequential('materializeInstallablePath extracts zip archives without ditto' await fs.symlink(unzipPath, path.join(binDir, 'unzip')); await fs.mkdir(payloadDir); await fs.writeFile(apkPath, 'placeholder apk', 'utf8'); - execFileSync('zip', ['-qr', archivePath, 'payload'], { cwd: tempRoot }); + runCmdSync('zip', ['-qr', archivePath, 'payload'], { cwd: tempRoot }); process.env.PATH = binDir; const result = await materializeInstallablePath({ @@ -152,7 +170,7 @@ test('materializeInstallablePath extracts tar.gz archives', async () => { try { await fs.mkdir(payloadDir); await fs.writeFile(apkPath, 'placeholder apk', 'utf8'); - execFileSync('tar', ['-czf', archivePath, '-C', payloadDir, 'Sample.apk']); + runCmdSync('tar', ['-czf', archivePath, '-C', payloadDir, 'Sample.apk']); const result = await materializeInstallablePath({ source: { kind: 'path', path: archivePath }, @@ -193,7 +211,7 @@ test(ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE.testName, async () => { '', 'utf8', ); - execFileSync('zip', ['-q', apkPath, 'AndroidManifest.xml'], { cwd: tempRoot }); + runCmdSync('zip', ['-q', apkPath, 'AndroidManifest.xml'], { cwd: tempRoot }); const apkBytes = await fs.readFile(apkPath); await withMockedInstallSourceFetch( @@ -240,7 +258,7 @@ test('prepareAndroidInstallArtifact accepts direct AAB URL sources', async () => 'utf8', ); await fs.writeFile(path.join(tempRoot, 'BundleConfig.pb'), 'bundle-config', 'utf8'); - execFileSync('zip', ['-qr', aabPath, 'BundleConfig.pb', 'base'], { cwd: tempRoot }); + runCmdSync('zip', ['-qr', aabPath, 'BundleConfig.pb', 'base'], { cwd: tempRoot }); const aabBytes = await fs.readFile(aabPath); await withMockedInstallSourceFetch( @@ -479,7 +497,7 @@ test('prepareAndroidInstallArtifact rejects trusted artifact archives with multi const archivePath = path.join(tempRoot, 'artifact.zip'); await fs.writeFile(path.join(tempRoot, 'one.apk'), 'one', 'utf8'); await fs.writeFile(path.join(tempRoot, 'two.apk'), 'two', 'utf8'); - execFileSync('zip', ['-q', archivePath, 'one.apk', 'two.apk'], { cwd: tempRoot }); + runCmdSync('zip', ['-q', archivePath, 'one.apk', 'two.apk'], { cwd: tempRoot }); await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { await assert.rejects( @@ -498,7 +516,7 @@ test('prepareAndroidInstallArtifact rejects untrusted URL archives instead of ex const tempRoot = await mkdtempForTest('agent-device-untrusted-archive-'); const archivePath = path.join(tempRoot, 'artifact.zip'); await fs.writeFile(path.join(tempRoot, 'app.apk'), 'apk', 'utf8'); - execFileSync('zip', ['-q', archivePath, 'app.apk'], { cwd: tempRoot }); + runCmdSync('zip', ['-q', archivePath, 'app.apk'], { cwd: tempRoot }); const archiveBytes = await fs.readFile(archivePath); try { @@ -543,6 +561,8 @@ type ArchiveExtractionFixture = { populate: (outputPath: string) => Promise; }; +let generatedArchiveFixture: Buffer | undefined; + async function withArchiveFixture( fixture: { extractions: ArchiveExtractionFixture[]; @@ -550,9 +570,13 @@ async function withArchiveFixture( }, run: () => Promise, ): Promise { - let extractionIndex = 0; - await withCommandExecutorOverride((command, args) => { - if (command === 'unzip' && args[0] === '-p') { + const tempRoot = await mkdtempForTest('agent-device-archive-fixture-'); + const outerArchive = path.join(tempRoot, 'artifact.zip'); + try { + await buildArchiveFixture(fixture.extractions, 0, outerArchive, tempRoot); + generatedArchiveFixture = await fs.readFile(outerArchive); + await withCommandExecutorOverride((command, args) => { + if (command !== 'unzip' || args[0] !== '-p') return undefined; const contents = fixture.zipEntries?.[String(args[2])]; return Promise.resolve({ exitCode: contents === undefined ? 1 : 0, @@ -560,17 +584,50 @@ async function withArchiveFixture( stderr: '', stdoutBuffer: contents === undefined ? Buffer.alloc(0) : Buffer.from(contents), }); + }, run); + } finally { + generatedArchiveFixture = undefined; + await fs.rm(tempRoot, { recursive: true, force: true }); + } +} + +async function buildArchiveFixture( + extractions: ArchiveExtractionFixture[], + index: number, + archivePath: string, + tempRoot: string, +): Promise { + const extraction = extractions[index]; + assert.ok(extraction, `Missing archive fixture at index ${index}`); + const payload = path.join(tempRoot, `payload-${index}`); + await fs.mkdir(payload); + await extraction.populate(payload); + if (index + 1 < extractions.length) { + const nested = await findNestedArchive(payload); + assert.ok(nested, `Archive fixture ${index} did not create its nested archive placeholder`); + await buildArchiveFixture(extractions, index + 1, nested, tempRoot); + } + await fs.rm(archivePath, { force: true }); + if (extraction.command === 'unzip') { + runCmdSync('zip', ['-qr', archivePath, '.'], { cwd: payload }); + } else { + const compression = + archivePath.endsWith('.gz') || archivePath.endsWith('.tgz') ? '-czf' : '-cf'; + runCmdSync('tar', [compression, archivePath, '-C', payload, '.']); + } +} + +async function findNestedArchive(root: string): Promise { + for (const entry of await fs.readdir(root, { withFileTypes: true })) { + const candidate = path.join(root, entry.name); + if (entry.isDirectory()) { + const nested = await findNestedArchive(candidate); + if (nested) return nested; + } else if (/\.(?:ipa|zip|tar|tar\.gz|tgz)$/i.test(entry.name)) { + return candidate; } - if (command !== 'unzip' && command !== 'tar') return undefined; - - const extraction = fixture.extractions[extractionIndex]; - assert.ok(extraction, `Unexpected extra ${command} extraction`); - assert.equal(command, extraction.command); - extractionIndex += 1; - const outputPath = String(args[3]); - return extraction.populate(outputPath).then(() => ({ exitCode: 0, stdout: '', stderr: '' })); - }, run); - assert.equal(extractionIndex, fixture.extractions.length); + } + return undefined; } async function withIosBundleInfo( @@ -594,27 +651,29 @@ async function withMockedInstallSourceFetch( run: () => Promise, options?: { filename?: string; contentType?: string }, ): Promise { + const responseBytes = generatedArchiveFixture ?? bytes; const lookupMock = vi .spyOn(dns, 'lookup') .mockImplementation( async () => - [{ address: '203.0.113.10', family: 4 }] as unknown as Awaited< + [{ address: '93.184.216.34', family: 4 }] as unknown as Awaited< ReturnType >, ); - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(new Uint8Array(bytes), { - status: 200, - headers: { - 'content-disposition': `attachment; filename="${options?.filename ?? 'artifact.zip'}"`, - 'content-type': options?.contentType ?? 'application/zip', - }, - }), - ); + const requestMock = vi.spyOn(networkTransport, 'requestApprovedUrl').mockResolvedValue({ + statusCode: 200, + statusText: 'OK', + headers: { + 'content-disposition': `attachment; filename="${options?.filename ?? 'artifact.zip'}"`, + 'content-type': options?.contentType ?? 'application/zip', + }, + body: Readable.from(responseBytes), + close: async () => {}, + }); try { await run(); } finally { - fetchMock.mockRestore(); + requestMock.mockRestore(); lookupMock.mockRestore(); } } diff --git a/src/platforms/apple/core/__tests__/apps.test.ts b/src/platforms/apple/core/__tests__/apps.test.ts index 071f121060..8aaa61f5c4 100644 --- a/src/platforms/apple/core/__tests__/apps.test.ts +++ b/src/platforms/apple/core/__tests__/apps.test.ts @@ -45,6 +45,7 @@ import { IOS_DEVICE_INSTALL_TIMEOUT_MS, IOS_SIMULATOR_TERMINATE_TIMEOUT_MS } fro import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { runCmd } from '../../../../utils/exec.ts'; +import { createZipFixture } from './install-artifact.fixtures.ts'; import { retryWithPolicy } from '../../../../utils/retry.ts'; import { PNG } from '../../../../utils/png.ts'; import { @@ -104,29 +105,6 @@ function isDevicectlDevice(args: string[], ...subcommand: string[]): boolean { ); } -/** - * `unzip` is spawned through `runCmd` directly (install-artifact.ts / - * install-source.ts), bypassing the Apple tool provider scope, so IPA - * extraction still needs a PATH stub even under the fake provider. - */ -async function withStubbedUnzip( - unzipScript: string, - run: (tmpDir: string) => Promise, -): Promise { - const tmpDir = await mkdtempForTest('agent-device-ios-unzip-stub-'); - const unzipPath = path.join(tmpDir, 'unzip'); - await fs.writeFile(unzipPath, unzipScript, 'utf8'); - await fs.chmod(unzipPath, 0o755); - - const previousPath = process.env.PATH; - process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; - try { - await run(tmpDir); - } finally { - process.env.PATH = previousPath; - } -} - test('resolveMacOsHelperPackageRootFrom finds helper package from source and dist-like paths', async () => { const repoRoot = await mkdtempForTest('agent-device-helper-root-'); const helperRoot = path.join(repoRoot, 'apple', 'macos-helper'); @@ -668,94 +646,67 @@ test('installIosInstallablePath on iOS physical device uses extended devicectl i }); test('installIosApp on iOS physical device accepts .ipa and installs extracted .app payload', async () => { - await withStubbedUnzip( - '#!/bin/sh\nmkdir -p "$4/Payload/Sample.app"\nexit 0\n', - async (tmpDir) => { - const ipaPath = path.join(tmpDir, 'Sample.ipa'); - await fs.writeFile(ipaPath, 'placeholder', 'utf8'); - - await withFakeAppleTool( - (args) => { - if (args[0] === 'devicectl' || args[0] === 'plutil') return ''; - return unexpectedArgs(args); - }, - async ({ calls }) => { - await installIosApp(IOS_TEST_DEVICE, ipaPath); - const installCall = calls.find( - (args) => args[0] === 'devicectl' && args[2] === 'install', - ); - assert.ok(installCall); - assert.deepEqual(installCall.slice(0, 6), [ - 'devicectl', - 'device', - 'install', - 'app', - '--device', - 'ios-device-1', - ]); - const installedPath = installCall[6]; - assert.equal(typeof installedPath, 'string'); - assert.equal(installedPath?.endsWith('/Payload/Sample.app'), true); - assert.notEqual(installedPath, ipaPath); - }, - ); + const tmpDir = await mkdtempForTest('agent-device-ios-install-ipa-test-'); + const ipaPath = path.join(tmpDir, 'Sample.ipa'); + await createZipFixture(ipaPath, ['Payload/Sample.app']); + + await withFakeAppleTool( + (args) => { + if (args[0] === 'devicectl' || args[0] === 'plutil') return ''; + return unexpectedArgs(args); + }, + async ({ calls }) => { + await installIosApp(IOS_TEST_DEVICE, ipaPath); + const installCall = calls.find((args) => args[0] === 'devicectl' && args[2] === 'install'); + assert.ok(installCall); + assert.deepEqual(installCall.slice(0, 6), [ + 'devicectl', + 'device', + 'install', + 'app', + '--device', + 'ios-device-1', + ]); + const installedPath = installCall[6]; + assert.equal(typeof installedPath, 'string'); + assert.equal(installedPath?.endsWith('/Payload/Sample.app'), true); + assert.notEqual(installedPath, ipaPath); }, ); }); test('installIosApp returns bundleId and launchTarget for nested archive sources', async () => { - const unzipScript = [ - '#!/bin/sh', - 'src="$2"', - 'out="$4"', - 'case "$src" in', - ' *.zip)', - ' mkdir -p "$out/Build"', - ' printf "ipa" > "$out/Build/Sample.ipa"', - ' exit 0', - ' ;;', - ' *.ipa)', - ' mkdir -p "$out/Payload/Sample.app"', - ' exit 0', - ' ;;', - 'esac', - 'exit 1', - '', - ].join('\n'); - - await withStubbedUnzip(unzipScript, async (tmpDir) => { - const archivePath = path.join(tmpDir, 'Sample.zip'); - await fs.writeFile(archivePath, 'placeholder', 'utf8'); - - await withFakeAppleTool( - (args) => { - if (args[0] === 'plutil' && args[1] === '-convert') { - return JSON.stringify({ - CFBundleIdentifier: 'com.example.archive', - CFBundleDisplayName: 'Archive App', - CFBundleName: 'Archive App', - }); - } - if (args[0] === 'devicectl') return ''; - return unexpectedArgs(args); - }, - async ({ calls }) => { - const result = await installIosApp(IOS_TEST_DEVICE, archivePath); - assert.equal(result.archivePath, archivePath); - assert.equal(result.bundleId, 'com.example.archive'); - assert.equal(result.appName, 'Archive App'); - assert.equal(result.launchTarget, 'com.example.archive'); - assert.equal(result.installablePath.endsWith('/Payload/Sample.app'), true); - const installCall = calls.find((args) => args[0] === 'devicectl' && args[2] === 'install'); - assert.ok(installCall); - assert.equal(installCall[6]?.endsWith('/Payload/Sample.app'), true); - }, - ); - }); -}); + const tmpDir = await mkdtempForTest('agent-device-ios-install-archive-test-'); + const archivePath = path.join(tmpDir, 'Sample.zip'); + const ipaPath = path.join(tmpDir, 'Sample.ipa'); + await createZipFixture(ipaPath, ['Payload/Sample.app']); + await createZipFixture(archivePath, [], [{ source: ipaPath, target: 'Build/Sample.ipa' }]); -const MULTI_APP_UNZIP_SCRIPT = - '#!/bin/sh\nmkdir -p "$4/Payload/Sample.app"\nmkdir -p "$4/Payload/Companion.app"\nexit 0\n'; + await withFakeAppleTool( + (args) => { + if (args[0] === 'plutil' && args[1] === '-convert') { + return JSON.stringify({ + CFBundleIdentifier: 'com.example.archive', + CFBundleDisplayName: 'Archive App', + CFBundleName: 'Archive App', + }); + } + if (args[0] === 'devicectl') return ''; + return unexpectedArgs(args); + }, + async ({ calls }) => { + const result = await installIosApp(IOS_TEST_DEVICE, archivePath); + assert.equal(result.archivePath, archivePath); + assert.equal(result.bundleId, 'com.example.archive'); + assert.equal(result.appName, 'Archive App'); + assert.equal(result.launchTarget, 'com.example.archive'); + assert.equal(result.installablePath.endsWith('/Payload/Sample.app'), true); + const installCall = calls.find((args) => args[0] === 'devicectl' && args[2] === 'install'); + assert.ok(installCall); + assert.equal(installCall[6]?.endsWith('/Payload/Sample.app'), true); + }, + ); +}); function multiAppPlutilScript(args: string[]): FakeAppleToolResponse { if (args[0] === 'plutil' && args[1] === '-convert') { @@ -774,56 +725,53 @@ function multiAppPlutilScript(args: string[]): FakeAppleToolResponse { } test('installIosApp on iOS physical device resolves multi-app .ipa using bundle identifier hint', async () => { - await withStubbedUnzip(MULTI_APP_UNZIP_SCRIPT, async (tmpDir) => { - const ipaPath = path.join(tmpDir, 'Sample.ipa'); - await fs.writeFile(ipaPath, 'placeholder', 'utf8'); - - await withFakeAppleTool(multiAppPlutilScript, async ({ calls }) => { - await installIosApp(IOS_TEST_DEVICE, ipaPath, { appIdentifierHint: 'com.example.sample' }); - const installCall = calls.find((args) => args[0] === 'devicectl' && args[2] === 'install'); - assert.ok(installCall); - const installedPath = installCall[6]; - assert.equal(typeof installedPath, 'string'); - assert.equal(installedPath?.endsWith('/Payload/Sample.app'), true); - }); + const tmpDir = await mkdtempForTest('agent-device-ios-install-ipa-multi-test-'); + const ipaPath = path.join(tmpDir, 'Sample.ipa'); + await createZipFixture(ipaPath, ['Payload/Sample.app', 'Payload/Companion.app']); + + await withFakeAppleTool(multiAppPlutilScript, async ({ calls }) => { + await installIosApp(IOS_TEST_DEVICE, ipaPath, { appIdentifierHint: 'com.example.sample' }); + const installCall = calls.find((args) => args[0] === 'devicectl' && args[2] === 'install'); + assert.ok(installCall); + const installedPath = installCall[6]; + assert.equal(typeof installedPath, 'string'); + assert.equal(installedPath?.endsWith('/Payload/Sample.app'), true); }); }); test('installIosApp rejects multi-app .ipa when no hint is provided', async () => { - await withStubbedUnzip(MULTI_APP_UNZIP_SCRIPT, async (tmpDir) => { - const ipaPath = path.join(tmpDir, 'Sample.ipa'); - await fs.writeFile(ipaPath, 'placeholder', 'utf8'); - - await withFakeAppleTool(multiAppPlutilScript, async () => { - await assert.rejects( - () => installIosApp(IOS_TEST_DEVICE, ipaPath), - (error: unknown) => { - assert.equal(error instanceof AppError, true); - assert.equal((error as AppError).code, 'INVALID_ARGS'); - assert.match((error as AppError).message, /found 2 \.app bundles/i); - assert.match((error as AppError).message, /pass an app identifier|bundle name/i); - return true; - }, - ); - }); + const tmpDir = await mkdtempForTest('agent-device-ios-install-ipa-multi-missing-hint-test-'); + const ipaPath = path.join(tmpDir, 'Sample.ipa'); + await createZipFixture(ipaPath, ['Payload/Sample.app', 'Payload/Companion.app']); + + await withFakeAppleTool(multiAppPlutilScript, async () => { + await assert.rejects( + () => installIosApp(IOS_TEST_DEVICE, ipaPath), + (error: unknown) => { + assert.equal(error instanceof AppError, true); + assert.equal((error as AppError).code, 'INVALID_ARGS'); + assert.match((error as AppError).message, /found 2 \.app bundles/i); + assert.match((error as AppError).message, /pass an app identifier|bundle name/i); + return true; + }, + ); }); }); test('installIosApp rejects invalid .ipa payloads without embedded .app', async () => { - await withStubbedUnzip('#!/bin/sh\nmkdir -p "$4/NoPayload"\nexit 0\n', async (tmpDir) => { - const ipaPath = path.join(tmpDir, 'Broken.ipa'); - await fs.writeFile(ipaPath, 'placeholder', 'utf8'); - - await withFakeAppleTool( - () => '', - async () => { - await assertRejectsAppError(() => installIosApp(IOS_TEST_DEVICE, ipaPath), { - code: 'INVALID_ARGS', - message: /invalid ipa/i, - }); - }, - ); - }); + const tmpDir = await mkdtempForTest('agent-device-ios-install-ipa-invalid-test-'); + const ipaPath = path.join(tmpDir, 'Broken.ipa'); + await createZipFixture(ipaPath, ['NoPayload']); + + await withFakeAppleTool( + () => '', + async () => { + await assertRejectsAppError(() => installIosApp(IOS_TEST_DEVICE, ipaPath), { + code: 'INVALID_ARGS', + message: /invalid ipa/i, + }); + }, + ); }); test('openIosApp with app and URL on iOS device launches app bundle with payload URL', async () => { diff --git a/src/platforms/apple/core/__tests__/install-artifact.fixtures.ts b/src/platforms/apple/core/__tests__/install-artifact.fixtures.ts new file mode 100644 index 0000000000..b33d6f1ed1 --- /dev/null +++ b/src/platforms/apple/core/__tests__/install-artifact.fixtures.ts @@ -0,0 +1,24 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { runCmdSync } from '../../../../utils/exec.ts'; + +export async function createZipFixture( + archivePath: string, + directories: string[], + files: Array<{ source: string; target: string }> = [], +): Promise { + const staging = await fs.mkdtemp(path.join(path.dirname(archivePath), 'zip-fixture-')); + try { + for (const directory of directories) { + await fs.mkdir(path.join(staging, directory), { recursive: true }); + } + for (const file of files) { + const target = path.join(staging, file.target); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.copyFile(file.source, target); + } + runCmdSync('zip', ['-qr', archivePath, '.'], { cwd: staging }); + } finally { + await fs.rm(staging, { recursive: true, force: true }); + } +} diff --git a/src/platforms/apple/core/install-artifact.ts b/src/platforms/apple/core/install-artifact.ts index 30c2bcf494..e759cea7e7 100644 --- a/src/platforms/apple/core/install-artifact.ts +++ b/src/platforms/apple/core/install-artifact.ts @@ -3,7 +3,14 @@ import os from 'node:os'; import path from 'node:path'; import { readInfoPlistString } from './plist.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { runCmd } from '../../../utils/exec.ts'; +import { extractArchiveSafely } from '../../../utils/archive-extraction.ts'; +import { ArchiveBudget } from '../../../utils/archive-safety.ts'; +import { + installArtifactArchiveBudget, + installArtifactArchiveDepth, + noteInstallArtifactArchiveDepth, + withInstallArtifactArchiveScope, +} from '../../install-artifact-archive-context.ts'; import { isTrustedInstallSourceUrl, materializeInstallablePath, @@ -33,6 +40,15 @@ export type PreparedIosInstallArtifact = { export async function prepareIosInstallArtifact( source: MaterializeInstallSource, options?: InstallIosArtifactOptions, +): Promise { + return await withInstallArtifactArchiveScope( + async () => await prepareIosInstallArtifactInScope(source, options), + ); +} + +async function prepareIosInstallArtifactInScope( + source: MaterializeInstallSource, + options?: InstallIosArtifactOptions, ): Promise { if (source.kind === 'url' && !isTrustedInstallSourceUrl(source.url)) { throw new AppError( @@ -109,8 +125,18 @@ async function resolveIosInstallablePath( await fs.rm(tempDir, { recursive: true, force: true }); }; try { - await runCmd('unzip', ['-q', appPath, '-d', tempDir]); - const payloadDir = path.join(tempDir, 'Payload'); + const outputRoot = path.join(tempDir, 'extracted'); + const depth = installArtifactArchiveDepth() + 1; + const budget = installArtifactArchiveBudget() as ArchiveBudget; + await extractArchiveSafely({ + archivePath: appPath, + outputRoot, + type: 'zip', + budget, + depth, + }); + noteInstallArtifactArchiveDepth(depth); + const payloadDir = path.join(outputRoot, 'Payload'); const payloadEntries = await fs.readdir(payloadDir, { withFileTypes: true }).catch(() => { throw new AppError('INVALID_ARGS', 'Invalid IPA: missing Payload directory'); }); diff --git a/src/platforms/install-artifact-archive-context.ts b/src/platforms/install-artifact-archive-context.ts new file mode 100644 index 0000000000..23cb74c873 --- /dev/null +++ b/src/platforms/install-artifact-archive-context.ts @@ -0,0 +1,22 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { ArchiveBudget } from '../utils/archive-safety.ts'; + +type ArchiveState = { budget: ArchiveBudget; depth: number }; +const ARCHIVE_STATE = new AsyncLocalStorage(); + +export async function withInstallArtifactArchiveScope(action: () => Promise): Promise { + return await ARCHIVE_STATE.run({ budget: new ArchiveBudget(), depth: 0 }, action); +} + +export function installArtifactArchiveBudget(): object | undefined { + return ARCHIVE_STATE.getStore()?.budget; +} + +export function installArtifactArchiveDepth(): number { + return ARCHIVE_STATE.getStore()?.depth ?? 0; +} + +export function noteInstallArtifactArchiveDepth(depth: number): void { + const state = ARCHIVE_STATE.getStore(); + if (state) state.depth = Math.max(state.depth, depth); +} diff --git a/src/platforms/install-source-archive.ts b/src/platforms/install-source-archive.ts new file mode 100644 index 0000000000..6a093d73d5 --- /dev/null +++ b/src/platforms/install-source-archive.ts @@ -0,0 +1,142 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { extractArchiveSafely, archiveTypeFromPath } from '../utils/archive-extraction.ts'; +import { ArchiveBudget } from '../utils/archive-safety.ts'; + +const MAX_INSTALL_SOURCE_SEARCH_DEPTH = 5; + +type InstallableMatcher = ( + candidatePath: string, + stat: { isFile(): boolean; isDirectory(): boolean }, +) => boolean; + +export async function resolveInstallableCandidate( + candidatePath: string, + params: { + archivePath: string | undefined; + isInstallablePath: InstallableMatcher; + installableLabel: string; + allowArchiveExtraction: boolean; + registerCleanup: (cleanup: () => Promise) => void; + budget: ArchiveBudget; + archiveDepth: number; + onArchiveAccepted?: (depth: number) => void; + }, +): Promise<{ archivePath?: string; installablePath: string }> { + const stat = await fs.stat(candidatePath).catch(() => null); + if (!stat) throw new AppError('INVALID_ARGS', `App source not found: ${candidatePath}`); + if (params.isInstallablePath(candidatePath, stat)) { + return { archivePath: params.archivePath, installablePath: candidatePath }; + } + if (stat.isFile() && isArchivePath(candidatePath)) { + assertArchiveExtractionAllowed(candidatePath, params, false); + return await resolveExtractedArchive(candidatePath, params); + } + if (stat.isDirectory()) { + const installables = await collectMatchingPaths(candidatePath, params.isInstallablePath); + if (installables.length === 1) { + return { archivePath: params.archivePath, installablePath: installables[0]! }; + } + if (installables.length > 1) { + throw new AppError( + 'INVALID_ARGS', + `Found multiple ${params.installableLabel} candidates under ${candidatePath}: ${installables.join(', ')}`, + { matches: installables }, + ); + } + const archives = await collectMatchingPaths(candidatePath, (entryPath, entryStat) => + Boolean(entryStat.isFile() && isArchivePath(entryPath)), + ); + if (archives.length === 1) { + assertArchiveExtractionAllowed(archives[0]!, params, true); + return await resolveExtractedArchive(archives[0]!, params); + } + if (archives.length > 1) { + throw new AppError( + 'INVALID_ARGS', + `Found multiple nested archives under ${candidatePath}; expected one ${params.installableLabel} source`, + { matches: archives }, + ); + } + } + throw new AppError( + 'INVALID_ARGS', + `Expected ${params.installableLabel} source, but got ${candidatePath}`, + ); +} + +async function resolveExtractedArchive( + archivePath: string, + params: Parameters[1], +): ReturnType { + const extracted = await extractArchive(archivePath, params.budget, params.archiveDepth + 1); + params.onArchiveAccepted?.(params.archiveDepth + 1); + params.registerCleanup(extracted.cleanup); + return await resolveInstallableCandidate(extracted.outputPath, { + ...params, + archivePath: params.archivePath ?? archivePath, + archiveDepth: params.archiveDepth + 1, + }); +} + +async function extractArchive( + archivePath: string, + budget: ArchiveBudget, + depth: number, +): Promise<{ outputPath: string; cleanup: () => Promise }> { + const type = archiveTypeFromPath(archivePath); + if (!type) throw new AppError('INVALID_ARGS', `Unsupported archive: ${archivePath}`); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-archive-')); + const outputPath = path.join(tempDir, 'extracted'); + try { + await extractArchiveSafely({ archivePath, outputRoot: outputPath, type, budget, depth }); + return { + outputPath, + cleanup: async () => await fs.rm(tempDir, { recursive: true, force: true }), + }; + } catch (error) { + await fs.rm(tempDir, { recursive: true, force: true }); + throw error; + } +} + +async function collectMatchingPaths( + rootPath: string, + matcher: InstallableMatcher, +): Promise { + const matches: string[] = []; + const queue: Array<{ path: string; depth: number }> = [{ path: rootPath, depth: 0 }]; + while (queue.length > 0) { + const current = queue.shift(); + if (!current) continue; + const entries = await fs.readdir(current.path, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (entry.name === '__MACOSX' || entry.name.startsWith('._')) continue; + const entryPath = path.join(current.path, entry.name); + if (matcher(entryPath, entry)) matches.push(entryPath); + else if (entry.isDirectory() && current.depth < MAX_INSTALL_SOURCE_SEARCH_DEPTH) { + queue.push({ path: entryPath, depth: current.depth + 1 }); + } + } + } + return [...new Set(matches)]; +} + +function assertArchiveExtractionAllowed( + archivePath: string, + params: Parameters[1], + nested: boolean, +): void { + if (params.allowArchiveExtraction) return; + const message = nested + ? `URL sources must point directly to a ${params.installableLabel}; nested archives are not allowed` + : `URL sources must point directly to a ${params.installableLabel}; archive extraction is not allowed`; + throw new AppError('INVALID_ARGS', message, { path: archivePath }); +} + +function isArchivePath(candidatePath: string): boolean { + return archiveTypeFromPath(candidatePath) !== undefined; +} diff --git a/src/platforms/install-source-download.ts b/src/platforms/install-source-download.ts new file mode 100644 index 0000000000..82d49cb300 --- /dev/null +++ b/src/platforms/install-source-download.ts @@ -0,0 +1,192 @@ +import { createWriteStream, promises as fs } from 'node:fs'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { AppError } from '@agent-device/kernel/errors'; +import { MAX_ARTIFACT_COMPRESSED_BYTES } from '../utils/artifact-limits.ts'; +import { createByteLimitStream } from '../utils/byte-limit-stream.ts'; +import { approveDownloadSourceUrl } from './install-source-network.ts'; +import * as networkTransport from './install-source-network-transport.ts'; + +const MAX_REDIRECTS = 5; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const FORBIDDEN_HEADERS = new Set([ + 'accept-encoding', + 'connection', + 'content-length', + 'host', + 'keep-alive', + 'proxy-connection', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +export async function downloadInstallSource(params: { + tempDir: string; + url: string; + headers?: Record; + signal: AbortSignal; +}): Promise { + let currentUrl = parseSourceUrl(params.url); + let headers = sanitizeHeaders(params.headers); + for (let redirectCount = 0; ; redirectCount += 1) { + const response = await requestHop(currentUrl, headers, params.signal); + try { + const redirected = readRedirect(response, currentUrl, redirectCount); + if (redirected) { + if (redirected.origin !== currentUrl.origin) headers = crossOriginHeaders(headers); + currentUrl = redirected; + continue; + } + assertSuccessfulResponse(response); + return await writeResponse(params.tempDir, currentUrl, response); + } finally { + await response.close(); + } + } +} + +async function requestHop( + url: URL, + headers: Record, + signal: AbortSignal, +): Promise { + const approved = await approveDownloadSourceUrl(url, signal); + try { + return await networkTransport.requestApprovedUrl({ + url, + approvedAddress: approved.address, + family: approved.family, + headers: { ...headers, 'accept-encoding': 'identity' }, + signal, + }); + } catch (error) { + if (error instanceof AppError) throw error; + throw new AppError('COMMAND_FAILED', 'App source network request failed', undefined, error); + } +} + +function readRedirect( + response: networkTransport.InstallSourceNetworkResponse, + currentUrl: URL, + redirectCount: number, +): URL | undefined { + if (!REDIRECT_STATUSES.has(response.statusCode)) return undefined; + response.body.resume?.(); + const location = readHeader(response.headers, 'location'); + if (!location || redirectCount >= MAX_REDIRECTS) { + throw new AppError('COMMAND_FAILED', 'App source redirect limit was exceeded', { + status: response.statusCode, + }); + } + const redirected = new URL(location, currentUrl); + if (currentUrl.protocol === 'https:' && redirected.protocol !== 'https:') { + throw new AppError('COMMAND_FAILED', 'App source redirect downgraded HTTPS'); + } + return redirected; +} + +function assertSuccessfulResponse(response: networkTransport.InstallSourceNetworkResponse): void { + if (response.statusCode >= 200 && response.statusCode < 300) return; + throw new AppError('COMMAND_FAILED', `Failed to download app source: ${response.statusCode}`, { + status: response.statusCode, + }); +} + +async function writeResponse( + tempDir: string, + url: URL, + response: networkTransport.InstallSourceNetworkResponse, +): Promise { + const encoding = readHeader(response.headers, 'content-encoding'); + if (encoding && encoding.toLowerCase() !== 'identity') { + throw new AppError('COMMAND_FAILED', 'App source response used unsupported content encoding'); + } + validateContentLength(readHeader(response.headers, 'content-length')); + if (!response.body) throw new AppError('COMMAND_FAILED', 'Download response body was empty'); + const destinationPath = path.join(tempDir, resolveDownloadFileName(response, url)); + const byteLimit = createByteLimitStream({ + maxBytes: MAX_ARTIFACT_COMPRESSED_BYTES, + createLimitError: () => + new AppError( + 'COMMAND_FAILED', + `App source exceeds maximum size of ${MAX_ARTIFACT_COMPRESSED_BYTES} bytes`, + { reason: 'ARTIFACT_BYTE_LIMIT' }, + ), + }); + try { + await pipeline(response.body, byteLimit, createWriteStream(destinationPath, { flags: 'wx' })); + return destinationPath; + } catch (error) { + await fs.rm(destinationPath, { force: true }).catch(() => {}); + throw error; + } +} + +function validateContentLength(raw: string | null): void { + if (raw === null) return; + if (!/^\d+$/.test(raw)) { + throw new AppError('COMMAND_FAILED', 'App source response had invalid content-length'); + } + const length = Number(raw); + if (!Number.isSafeInteger(length) || length > MAX_ARTIFACT_COMPRESSED_BYTES) { + throw new AppError('COMMAND_FAILED', 'App source response exceeded the byte limit', { + reason: 'ARTIFACT_BYTE_LIMIT', + }); + } +} + +function sanitizeHeaders(input: Record | undefined): Record { + const connectionTokens = new Set( + Object.entries(input ?? {}) + .filter(([name]) => name.toLowerCase() === 'connection') + .flatMap(([, value]) => value.split(',').map((token) => token.trim().toLowerCase())), + ); + return Object.fromEntries( + Object.entries(input ?? {}).filter(([name]) => { + const lower = name.toLowerCase(); + return !FORBIDDEN_HEADERS.has(lower) && !connectionTokens.has(lower); + }), + ); +} + +function crossOriginHeaders(headers: Record): Record { + return Object.fromEntries( + Object.entries(headers).filter(([name]) => + ['accept', 'user-agent'].includes(name.toLowerCase()), + ), + ); +} + +function resolveDownloadFileName( + response: networkTransport.InstallSourceNetworkResponse, + parsedUrl: URL, +): string { + const disposition = readHeader(response.headers, 'content-disposition'); + const match = disposition?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i); + const candidate = + match?.[1]?.trim() || path.basename(parsedUrl.pathname) || 'downloaded-artifact.bin'; + return path.basename(candidate); +} + +function readHeader( + headers: Record, + name: string, +): string | null { + const value = headers[name]; + return Array.isArray(value) ? (value[0] ?? null) : (value ?? null); +} + +function parseSourceUrl(raw: string): URL { + try { + const parsed = new URL(raw); + if (parsed.username || parsed.password) { + throw new AppError('INVALID_ARGS', 'Source URL credentials are not allowed'); + } + return parsed; + } catch { + throw new AppError('INVALID_ARGS', 'Invalid source URL'); + } +} diff --git a/src/platforms/install-source-network-transport.ts b/src/platforms/install-source-network-transport.ts new file mode 100644 index 0000000000..852392bbd5 --- /dev/null +++ b/src/platforms/install-source-network-transport.ts @@ -0,0 +1,127 @@ +import net from 'node:net'; +import { Agent, ProxyAgent, request, type Dispatcher } from 'undici'; + +export type InstallSourceNetworkResponse = { + statusCode: number; + statusText: string; + headers: Record; + body: NodeJS.ReadableStream; + close: () => Promise; +}; + +export async function requestApprovedUrl(params: { + url: URL; + approvedAddress: string; + family: 4 | 6; + headers: Record; + signal: AbortSignal; +}): Promise { + const proxy = resolveProxyForUrl(params.url); + const dispatcher = proxy + ? proxyDispatcher(proxy, params.url, params.approvedAddress, params.family) + : directDispatcher(params.approvedAddress, params.family); + const dispatchUrl = proxy + ? approvedAddressUrl(params.url, params.approvedAddress, params.family) + : params.url; + try { + const requestOptions = { + dispatcher, + headers: proxy ? { ...params.headers, host: params.url.host } : params.headers, + maxRedirections: 0, + method: 'GET' as const, + signal: params.signal, + }; + const response = await request(dispatchUrl, requestOptions); + return { + statusCode: response.statusCode, + statusText: String(response.statusCode), + headers: response.headers, + body: response.body, + close: async () => await closeDispatcher(dispatcher), + }; + } catch (error) { + await closeDispatcher(dispatcher); + throw error; + } +} + +export function resolveProxyForUrl( + url: URL, + environment: NodeJS.ProcessEnv = process.env, +): string | undefined { + const noProxy = environment.no_proxy ?? environment.NO_PROXY; + if (matchesNoProxy(url, noProxy)) return undefined; + const httpProxy = environment.http_proxy ?? environment.HTTP_PROXY; + if (url.protocol === 'http:') return httpProxy || undefined; + const httpsProxy = environment.https_proxy ?? environment.HTTPS_PROXY; + return httpsProxy || httpProxy || undefined; +} + +export function matchesNoProxy(url: URL, raw: string | undefined): boolean { + if (!raw) return false; + const hostname = stripBrackets(url.hostname).toLowerCase(); + const port = url.port || (url.protocol === 'https:' ? '443' : '80'); + return raw + .split(/[\s,]+/) + .filter(Boolean) + .some((token) => { + if (token === '*') return true; + const parsed = parseNoProxyToken(token); + if (parsed.port && parsed.port !== port) return false; + const suffix = parsed.hostname.replace(/^\*?\./, '').toLowerCase(); + return hostname === suffix || hostname.endsWith(`.${suffix}`); + }); +} + +function directDispatcher(address: string, family: 4 | 6): Agent { + return new Agent({ + connect: { lookup: (_hostname, _options, callback) => callback(null, address, family) }, + }); +} + +function proxyDispatcher( + proxyUrl: string, + destination: URL, + _address: string, + _family: 4 | 6, +): ProxyAgent { + const literalDestination = net.isIP(stripBrackets(destination.hostname)) !== 0; + return new ProxyAgent({ + uri: proxyUrl, + proxyTunnel: true, + requestTls: + destination.protocol === 'https:' && !literalDestination + ? { servername: stripBrackets(destination.hostname) } + : undefined, + }); +} + +function approvedAddressUrl(original: URL, address: string, family: 4 | 6): URL { + const approved = new URL(original); + approved.hostname = family === 6 ? `[${stripBrackets(address)}]` : address; + return approved; +} + +function parseNoProxyToken(token: string): { hostname: string; port?: string } { + if (token.startsWith('[')) { + const close = token.indexOf(']'); + if (close < 0) return { hostname: token }; + return { + hostname: token.slice(1, close), + port: token[close + 1] === ':' ? token.slice(close + 2) : undefined, + }; + } + const colon = token.lastIndexOf(':'); + if (colon > -1 && token.indexOf(':') === colon) { + return { hostname: token.slice(0, colon), port: token.slice(colon + 1) }; + } + return { hostname: token }; +} + +function stripBrackets(value: string): string { + return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value; +} + +async function closeDispatcher(dispatcher: Dispatcher): Promise { + await dispatcher.close(); +} diff --git a/src/platforms/install-source-network.ts b/src/platforms/install-source-network.ts new file mode 100644 index 0000000000..d1c250aa9f --- /dev/null +++ b/src/platforms/install-source-network.ts @@ -0,0 +1,109 @@ +import dns from 'node:dns/promises'; +import net from 'node:net'; +import { AppError } from '@agent-device/kernel/errors'; +import ipaddr from 'ipaddr.js'; + +export async function approveDownloadSourceUrl( + parsedUrl: URL, + signal?: AbortSignal, +): Promise<{ + hostname: string; + address: string; + family: 4 | 6; +}> { + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new AppError('INVALID_ARGS', `Unsupported source URL protocol: ${parsedUrl.protocol}`); + } + if (parsedUrl.username || parsedUrl.password) { + throw new AppError('INVALID_ARGS', 'Source URL credentials are not allowed'); + } + throwIfAborted(signal); + const hostname = canonicalHostname(parsedUrl.hostname); + if (isBlockedSourceHostname(hostname)) blockedHost(parsedUrl.hostname); + const literalFamily = net.isIP(hostname); + if (literalFamily) return { hostname, address: hostname, family: literalFamily as 4 | 6 }; + + let resolved: Array<{ address: string; family: number }>; + try { + resolved = await lookupWithSignal(hostname, signal); + } catch (error) { + if (error instanceof AppError && error.details?.reason === 'request_canceled') throw error; + throw new AppError( + 'INVALID_ARGS', + `Source URL host could not be resolved: ${hostname}`, + { hint: 'Use a public artifact URL.' }, + error, + ); + } + if (resolved.length === 0) { + throw new AppError('INVALID_ARGS', `Source URL host could not be resolved: ${hostname}`, { + hint: 'Use a public artifact URL.', + }); + } + if (resolved.some((entry) => isBlockedIpAddress(entry.address))) blockedHost(hostname); + const selected = resolved[0]!; + return { hostname, address: selected.address, family: selected.family as 4 | 6 }; +} + +async function lookupWithSignal( + hostname: string, + signal: AbortSignal | undefined, +): Promise> { + const lookup = dns.lookup(hostname, { all: true, verbatim: true }); + if (!signal) return await lookup; + return await new Promise((resolve, reject) => { + const abort = () => reject(canceledError(signal.reason)); + signal.addEventListener('abort', abort, { once: true }); + void lookup.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw canceledError(signal.reason); +} + +function canceledError(cause: unknown): AppError { + return new AppError('COMMAND_FAILED', 'request canceled', { reason: 'request_canceled' }, cause); +} + +export function isBlockedSourceHostname(hostname: string): boolean { + let canonical: string; + try { + canonical = canonicalHostname(hostname); + } catch { + return true; + } + if (!canonical || canonical === 'localhost' || canonical.endsWith('.localhost')) return true; + return net.isIP(canonical) !== 0 && isBlockedIpAddress(canonical); +} + +export function isBlockedIpAddress(address: string): boolean { + try { + const parsed = ipaddr.process(stripAddressBrackets(address)); + return parsed.range() !== 'unicast'; + } catch { + return true; + } +} + +function canonicalHostname(hostname: string): string { + const stripped = stripAddressBrackets(hostname).toLowerCase().replace(/\.$/, ''); + if (!stripped || stripped.includes('%')) { + throw new AppError('INVALID_ARGS', 'Source URL host is not allowed', { + hint: 'Use a public artifact URL.', + }); + } + return stripped; +} + +function stripAddressBrackets(value: string): string { + return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value; +} + +function blockedHost(hostname: string): never { + throw new AppError( + 'INVALID_ARGS', + `Source URL host is not allowed because it resolves to a non-public address: ${hostname}`, + { hint: 'Use a public artifact URL.' }, + ); +} diff --git a/src/platforms/install-source.ts b/src/platforms/install-source.ts index 91a4feef97..9555ef78fc 100644 --- a/src/platforms/install-source.ts +++ b/src/platforms/install-source.ts @@ -1,13 +1,16 @@ -import dns from 'node:dns/promises'; -import net from 'node:net'; -import { createWriteStream, promises as fs } from 'node:fs'; +import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { Readable } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; import { AppError } from '@agent-device/kernel/errors'; -import { runCmd } from '../utils/exec.ts'; import { expandUserHomePath } from '../utils/path-resolution.ts'; +import { ArchiveBudget } from '../utils/archive-safety.ts'; +import { resolveInstallableCandidate } from './install-source-archive.ts'; +import { + installArtifactArchiveBudget, + noteInstallArtifactArchiveDepth, +} from './install-artifact-archive-context.ts'; +import { approveDownloadSourceUrl } from './install-source-network.ts'; +import { downloadInstallSource } from './install-source-download.ts'; export type MaterializeInstallSource = | { @@ -25,7 +28,7 @@ type MaterializeLocalSourceResult = { cleanup: () => Promise; }; -type MaterializeInstallableOptions = { +export type MaterializeInstallableOptions = { source: MaterializeInstallSource; isInstallablePath: ( candidatePath: string, @@ -49,7 +52,6 @@ const INTERNAL_ARCHIVE_EXTENSIONS = ['.zip', '.tar', '.tar.gz', '.tgz'] as const * @public Archive extensions accepted by install-source resolution. */ export const ARCHIVE_EXTENSIONS = Object.freeze([...INTERNAL_ARCHIVE_EXTENSIONS] as const); -const MAX_INSTALL_SOURCE_SEARCH_DEPTH = 5; const DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000; export async function materializeInstallablePath( @@ -70,6 +72,9 @@ export async function materializeInstallablePath( registerCleanup: (cleanup) => { cleanupTasks.push(cleanup); }, + budget: (installArtifactArchiveBudget() as ArchiveBudget | undefined) ?? new ArchiveBudget(), + archiveDepth: 0, + onArchiveAccepted: noteInstallArtifactArchiveDepth, }); return { archivePath: resolved.archivePath, @@ -114,20 +119,12 @@ async function materializeLocalSource( } } -// fallow-ignore-next-line complexity async function downloadToTempFile( tempDir: string, url: string, headers?: Record, options?: { signal?: AbortSignal; downloadTimeoutMs?: number }, ): Promise { - let parsedUrl: URL; - try { - parsedUrl = new URL(url); - } catch { - throw new AppError('INVALID_ARGS', `Invalid source URL: ${url}`); - } - await validateDownloadSourceUrl(parsedUrl); const requestSignal = options?.signal; if (requestSignal?.aborted) { throw new AppError('COMMAND_FAILED', 'request canceled', { reason: 'request_canceled' }); @@ -136,82 +133,39 @@ async function downloadToTempFile( const timeoutSignal = AbortSignal.timeout(timeoutMs); const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal; try { - const response = await fetch(parsedUrl, { - headers, - redirect: 'follow', - signal, - }); - if (!response.ok) { - throw new AppError( - 'COMMAND_FAILED', - `Failed to download app source: ${response.status} ${response.statusText}`, - { - status: response.status, - statusText: response.statusText, - url: parsedUrl.toString(), - }, - ); - } - const downloadName = resolveDownloadFileName(response, parsedUrl); - const destinationPath = path.join(tempDir, downloadName); - const body = response.body; - if (!body) { - throw new AppError('COMMAND_FAILED', 'Download response body was empty', { - url: parsedUrl.toString(), - }); - } - await pipeline( - Readable.fromWeb(body as Parameters[0]), - createWriteStream(destinationPath), - ); - return destinationPath; + return await downloadInstallSource({ tempDir, url, headers, signal }); } catch (error) { - if (requestSignal?.aborted) { - throw new AppError( - 'COMMAND_FAILED', - 'request canceled', - { reason: 'request_canceled' }, - error, - ); - } - if (timeoutSignal.aborted) { - throw new AppError( - 'COMMAND_FAILED', - `App source download timed out after ${timeoutMs}ms`, - { - timeoutMs, - url: parsedUrl.toString(), - }, - error, - ); - } - throw error; + throw classifyDownloadError(error, requestSignal, timeoutSignal, timeoutMs); } } -export async function validateDownloadSourceUrl(parsedUrl: URL): Promise { - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - throw new AppError('INVALID_ARGS', `Unsupported source URL protocol: ${parsedUrl.protocol}`); - } - const hostname = parsedUrl.hostname.toLowerCase(); - if (isBlockedSourceHostname(hostname)) { - throw new AppError('INVALID_ARGS', `Source URL host is not allowed: ${parsedUrl.hostname}`, { - hint: 'Use a public artifact URL.', - }); +function classifyDownloadError( + error: unknown, + requestSignal: AbortSignal | undefined, + timeoutSignal: AbortSignal, + timeoutMs: number, +): unknown { + if (requestSignal?.aborted) { + return new AppError( + 'COMMAND_FAILED', + 'request canceled', + { reason: 'request_canceled' }, + error, + ); } - - const resolved = await dns - .lookup(parsedUrl.hostname, { all: true, verbatim: true }) - .catch(() => []); - if (resolved.some((entry) => isBlockedIpAddress(entry.address))) { - throw new AppError( - 'INVALID_ARGS', - `Source URL host resolved to a private or loopback address: ${parsedUrl.hostname}`, - { - hint: 'Use a public artifact URL.', - }, + if (timeoutSignal.aborted) { + return new AppError( + 'COMMAND_FAILED', + `App source download timed out after ${timeoutMs}ms`, + { timeoutMs }, + error, ); } + return error; +} + +export async function validateDownloadSourceUrl(parsedUrl: URL): Promise { + await approveDownloadSourceUrl(parsedUrl); } export function isTrustedInstallSourceUrl(sourceUrl: string | URL): boolean { @@ -242,207 +196,6 @@ function isTrustedEasArtifactUrl(hostname: string, pathname: string): boolean { return /^\/(?:artifacts\/eas\/|accounts\/[^/]+\/projects\/[^/]+\/builds\/)/i.test(pathname); } -function resolveDownloadFileName(response: Response, parsedUrl: URL): string { - const contentDisposition = response.headers.get('content-disposition'); - const filenameMatch = contentDisposition?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i); - const headerName = filenameMatch?.[1]?.trim(); - if (headerName) return path.basename(headerName); - const urlName = path.basename(parsedUrl.pathname); - if (urlName) return urlName; - return 'downloaded-artifact.bin'; -} - -export function isBlockedSourceHostname(hostname: string): boolean { - if (!hostname) return true; - if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true; - return isBlockedIpAddress(hostname); -} - -export function isBlockedIpAddress(address: string): boolean { - const family = net.isIP(address); - if (family === 4) return isBlockedIpv4(address); - if (family === 6) return isBlockedIpv6(address); - return false; -} - -// fallow-ignore-next-line complexity -function isBlockedIpv4(address: string): boolean { - const octets = address.split('.').map((part) => Number.parseInt(part, 10)); - if (octets.length !== 4 || octets.some((part) => Number.isNaN(part) || part < 0 || part > 255)) { - return false; - } - const a = octets[0]; - const b = octets[1]; - if (a === undefined || b === undefined) return false; - if (a === 10 || a === 127) return true; - if (a === 169 && b === 254) return true; - if (a === 172 && b >= 16 && b <= 31) return true; - if (a === 192 && b === 168) return true; - return false; -} - -function isBlockedIpv6(address: string): boolean { - const normalized = address.toLowerCase(); - if (normalized === '::1') return true; - if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; - if (normalized.startsWith('fe80:')) return true; - return false; -} - -// fallow-ignore-next-line complexity -async function resolveInstallableCandidate( - candidatePath: string, - params: { - archivePath: string | undefined; - isInstallablePath: MaterializeInstallableOptions['isInstallablePath']; - installableLabel: string; - allowArchiveExtraction: boolean; - registerCleanup: (cleanup: () => Promise) => void; - }, -): Promise<{ archivePath?: string; installablePath: string }> { - const stat = await fs.stat(candidatePath).catch(() => null); - if (!stat) { - throw new AppError('INVALID_ARGS', `App source not found: ${candidatePath}`); - } - - if (params.isInstallablePath(candidatePath, stat)) { - return { - archivePath: params.archivePath, - installablePath: candidatePath, - }; - } - - if (stat.isFile() && isArchivePath(candidatePath)) { - if (!params.allowArchiveExtraction) { - throw new AppError( - 'INVALID_ARGS', - `URL sources must point directly to a ${params.installableLabel}; archive extraction is not allowed`, - { path: candidatePath }, - ); - } - const extracted = await extractArchive(candidatePath); - params.registerCleanup(extracted.cleanup); - return await resolveInstallableCandidate(extracted.outputPath, { - ...params, - archivePath: params.archivePath ?? candidatePath, - }); - } - - if (stat.isDirectory()) { - const installables = await collectMatchingPaths(candidatePath, params.isInstallablePath); - const installable = installables[0]; - if (installable !== undefined && installables.length === 1) { - return { - archivePath: params.archivePath, - installablePath: installable, - }; - } - if (installables.length > 1) { - throw new AppError( - 'INVALID_ARGS', - `Found multiple ${params.installableLabel} candidates under ${candidatePath}`, - { matches: installables }, - ); - } - - const archives = await collectMatchingPaths( - candidatePath, - (entryPath, entryStat) => entryStat.isFile() && isArchivePath(entryPath), - ); - const archive = archives[0]; - if (archive !== undefined && archives.length === 1) { - if (!params.allowArchiveExtraction) { - throw new AppError( - 'INVALID_ARGS', - `URL sources must point directly to a ${params.installableLabel}; nested archives are not allowed`, - { path: archive }, - ); - } - const extracted = await extractArchive(archive); - params.registerCleanup(extracted.cleanup); - return await resolveInstallableCandidate(extracted.outputPath, { - ...params, - archivePath: params.archivePath ?? archive, - }); - } - if (archives.length > 1) { - throw new AppError( - 'INVALID_ARGS', - `Found multiple nested archives under ${candidatePath}; expected one ${params.installableLabel} source`, - { matches: archives }, - ); - } - } - - throw new AppError( - 'INVALID_ARGS', - `Expected ${params.installableLabel} source, but got ${candidatePath}`, - ); -} - -async function collectMatchingPaths( - rootPath: string, - matcher: (candidatePath: string, stat: { isFile(): boolean; isDirectory(): boolean }) => boolean, -): Promise { - const matches: string[] = []; - const queue: Array<{ path: string; depth: number }> = [{ path: rootPath, depth: 0 }]; - - while (queue.length > 0) { - const current = queue.shift(); - if (!current) continue; - const entries = await fs.readdir(current.path, { withFileTypes: true }); - entries.sort((left, right) => left.name.localeCompare(right.name)); - for (const entry of entries) { - const entryPath = path.join(current.path, entry.name); - if (matcher(entryPath, entry)) { - matches.push(entryPath); - continue; - } - if (entry.isDirectory() && current.depth < MAX_INSTALL_SOURCE_SEARCH_DEPTH) { - queue.push({ path: entryPath, depth: current.depth + 1 }); - } - } - } - - return matches; -} - -async function extractArchive( - archivePath: string, -): Promise<{ outputPath: string; cleanup: () => Promise }> { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-archive-')); - try { - if (archivePath.toLowerCase().endsWith('.zip')) { - await extractZipArchive(archivePath, tempDir); - } else if ( - archivePath.toLowerCase().endsWith('.tar.gz') || - archivePath.toLowerCase().endsWith('.tgz') - ) { - await runCmd('tar', ['-xzf', archivePath, '-C', tempDir]); - } else { - await runCmd('tar', ['-xf', archivePath, '-C', tempDir]); - } - return { - outputPath: tempDir, - cleanup: async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }, - }; - } catch (error) { - await fs.rm(tempDir, { recursive: true, force: true }); - throw error; - } -} - -async function extractZipArchive(archivePath: string, outputPath: string): Promise { - await runCmd('unzip', ['-q', archivePath, '-d', outputPath]); -} - -function isArchivePath(candidatePath: string): boolean { - const lower = candidatePath.toLowerCase(); - return INTERNAL_ARCHIVE_EXTENSIONS.some((extension) => lower.endsWith(extension)); -} - async function runCleanupTasks(tasks: Array<() => Promise>): Promise { for (const task of [...tasks].reverse()) { await task(); diff --git a/src/utils/__tests__/archive-safety.test.ts b/src/utils/__tests__/archive-safety.test.ts new file mode 100644 index 0000000000..701d6a3432 --- /dev/null +++ b/src/utils/__tests__/archive-safety.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { + ArchiveBudget, + normalizeArchiveEntryName, + resolveArchiveOutputPath, +} from '../archive-safety.ts'; + +function reason(error: unknown): string | undefined { + return error instanceof AppError ? (error.details?.reason as string | undefined) : undefined; +} + +test('archive paths normalize within the output root', () => { + assert.equal( + normalizeArchiveEntryName('./Payload/App.app/Info.plist'), + 'Payload/App.app/Info.plist', + ); + assert.equal( + resolveArchiveOutputPath('/tmp/archive-root', 'Payload/App.app/Info.plist'), + '/tmp/archive-root/Payload/App.app/Info.plist', + ); +}); + +test('archive paths reject absolute, escaping, drive, UNC, NUL, and backslash forms', () => { + for (const entry of [ + '/absolute', + '../escape', + 'safe/../../escape', + 'C:/drive', + '//server/share', + 'safe\\windows', + 'nul\0entry', + '.', + ]) { + assert.throws( + () => normalizeArchiveEntryName(entry), + (error) => reason(error) === 'ARCHIVE_UNSAFE_PATH', + entry, + ); + } +}); + +test('archive budget preflight is non-consuming and cumulative charges are bounded', () => { + const budget = new ArchiveBudget({ maxBytes: 5, maxEntries: 2, maxDepth: 2 }); + const reservation = budget.preflightArchive({ depth: 1, entryCount: 1, declaredBytes: 3 }); + assert.equal(budget.bytes, 0); + assert.equal(budget.entries, 0); + + reservation.chargeBytes(3); + reservation.commitEntry(); + reservation.finish(); + assert.equal(budget.bytes, 3); + assert.equal(budget.entries, 1); + + assert.throws( + () => budget.preflightArchive({ depth: 2, entryCount: 1, declaredBytes: 3 }), + (error) => reason(error) === 'ARCHIVE_EXPANDED_BYTES_LIMIT', + ); + assert.throws( + () => budget.preflightArchive({ depth: 3, entryCount: 0, declaredBytes: 0 }), + (error) => reason(error) === 'ARCHIVE_NESTING_LIMIT', + ); +}); + +test('archive reservation rejects bytes or entries beyond its inspected manifest', () => { + const bytes = new ArchiveBudget({ maxBytes: 10, maxEntries: 2 }); + const byteReservation = bytes.preflightArchive({ depth: 1, entryCount: 1, declaredBytes: 2 }); + assert.throws( + () => byteReservation.chargeBytes(3), + (error) => reason(error) === 'ARCHIVE_MANIFEST_MISMATCH', + ); + + const entries = new ArchiveBudget({ maxBytes: 10, maxEntries: 2 }); + const entryReservation = entries.preflightArchive({ depth: 1, entryCount: 1, declaredBytes: 0 }); + entryReservation.commitEntry(); + assert.throws( + () => entryReservation.commitEntry(), + (error) => reason(error) === 'ARCHIVE_MANIFEST_MISMATCH', + ); +}); diff --git a/src/utils/__tests__/byte-limit-stream.test.ts b/src/utils/__tests__/byte-limit-stream.test.ts new file mode 100644 index 0000000000..cf070a52dd --- /dev/null +++ b/src/utils/__tests__/byte-limit-stream.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { Readable, Writable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { test } from 'vitest'; +import { createByteLimitStream } from '../byte-limit-stream.ts'; + +test('byte limit rejects before forwarding overflow and reports accepted bytes only', async () => { + const limitError = new Error('limit exceeded'); + const limiter = createByteLimitStream({ + maxBytes: 5, + createLimitError: () => limitError, + }); + const forwarded: Buffer[] = []; + const sink = new Writable({ + write(chunk: Buffer, _encoding, callback) { + forwarded.push(Buffer.from(chunk)); + callback(); + }, + }); + + await assert.rejects( + pipeline(Readable.from([Buffer.from('abc'), Buffer.from('defg')]), limiter, sink), + (error) => error === limitError, + ); + assert.equal(Buffer.concat(forwarded).toString(), 'abc'); + assert.equal(limiter.bytesSeen, 3); +}); + +test('byte limit accepts an exact multi-chunk payload', async () => { + const limiter = createByteLimitStream({ + maxBytes: 5, + createLimitError: () => new Error('limit exceeded'), + }); + const forwarded: Buffer[] = []; + await pipeline( + Readable.from([Buffer.from('ab'), Buffer.from('cde')]), + limiter, + new Writable({ + write(chunk: Buffer, _encoding, callback) { + forwarded.push(Buffer.from(chunk)); + callback(); + }, + }), + ); + + assert.equal(Buffer.concat(forwarded).toString(), 'abcde'); + assert.equal(limiter.bytesSeen, 5); +}); + +test('byte limit rejects invalid limits before constructing a stream', () => { + for (const maxBytes of [-1, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1, 1.5]) { + assert.throws( + () => + createByteLimitStream({ maxBytes, createLimitError: () => new Error('limit exceeded') }), + /non-negative safe integer/, + ); + } +}); diff --git a/src/utils/archive-extraction-tar.ts b/src/utils/archive-extraction-tar.ts new file mode 100644 index 0000000000..2ca4667a4a --- /dev/null +++ b/src/utils/archive-extraction-tar.ts @@ -0,0 +1,151 @@ +import { createReadStream, createWriteStream, promises as fs } from 'node:fs'; +import path from 'node:path'; +import { createGunzip } from 'node:zlib'; +import { pipeline } from 'node:stream/promises'; +import * as tar from 'tar-stream'; +import { + ArchiveBudget, + archiveError, + normalizeArchiveEntryName, + reserveArchiveManifest, + resolveArchiveOutputPath, + sameArchiveManifestEntry, + type ArchiveManifestEntry, +} from './archive-safety.ts'; + +type TarOptions = { + archivePath: string; + outputRoot: string; + gzip: boolean; + budget?: ArchiveBudget; + depth?: number; + validateManifest?: (manifest: readonly ArchiveManifestEntry[]) => void | Promise; +}; + +export async function extractTarArchive(options: TarOptions): Promise { + const manifest = await inspectTar(options); + await options.validateManifest?.(manifest); + const budget = options.budget ?? new ArchiveBudget(); + const reservation = reserveArchiveManifest(budget, options.depth ?? 1, manifest); + const extractor = tar.extract(); + const extraction = streamTar(options.archivePath, options.gzip, extractor); + try { + for await (const entry of extractor) { + const actualEntry = manifestEntryFromTarHeader(entry.header); + if (!actualEntry) { + await drainTarEntry(entry); + continue; + } + const manifestEntry = manifest.shift(); + if (!manifestEntry || !sameArchiveManifestEntry(manifestEntry, actualEntry)) { + throw archiveError( + 'ARCHIVE_MANIFEST_MISMATCH', + 'Archive contents changed after inspection', + ); + } + await writeTarEntry(entry, manifestEntry, options.outputRoot, reservation); + reservation.commitEntry(); + } + await extraction; + } catch (error) { + extractor.destroy(); + await extraction.catch(() => {}); + throw error; + } + if (manifest.length !== 0) { + throw archiveError('ARCHIVE_MANIFEST_MISMATCH', 'Archive contents changed after inspection'); + } + reservation.finish(); +} + +async function inspectTar(options: TarOptions): Promise { + const manifest: ArchiveManifestEntry[] = []; + const extractor = tar.extract(); + const inspection = streamTar(options.archivePath, options.gzip, extractor); + try { + for await (const entry of extractor) { + const manifestEntry = manifestEntryFromTarHeader(entry.header); + if (!manifestEntry) { + await drainTarEntry(entry); + continue; + } + manifest.push(manifestEntry); + await drainTarEntry(entry); + } + await inspection; + } catch (error) { + extractor.destroy(); + await inspection.catch(() => {}); + throw error; + } + return manifest; +} + +function streamTar(archivePath: string, gzip: boolean, extractor: tar.Extract): Promise { + return gzip + ? pipeline(createReadStream(archivePath), createGunzip(), extractor) + : pipeline(createReadStream(archivePath), extractor); +} + +function readTarKind(type: tar.Headers['type']): 'directory' | 'file' { + if (type === 'directory') return 'directory'; + if (type === 'file' || type === 'contiguous-file' || type == null) return 'file'; + if (type === 'link' || type === 'symlink') { + throw archiveError( + 'ARCHIVE_UNSAFE_ENTRY', + 'Uploaded app bundle archive cannot contain symlinks or hard links', + ); + } + throw archiveError('ARCHIVE_UNSAFE_ENTRY', 'Archive contains a link or special entry'); +} + +function safeMode(mode: number | undefined, kind: 'directory' | 'file'): number { + const fallback = kind === 'directory' ? 0o755 : 0o644; + return (mode ?? fallback) & (kind === 'directory' ? 0o777 : 0o777); +} + +function manifestEntryFromTarHeader(header: tar.Headers): ArchiveManifestEntry | undefined { + if (isRootDirectoryMarker(header.name, header.type)) return undefined; + const name = normalizeArchiveEntryName(header.name); + const kind = readTarKind(header.type); + const size = kind === 'directory' ? 0 : (header.size ?? 0); + if (!Number.isSafeInteger(size) || size < 0) { + throw archiveError('ARCHIVE_INVALID_ENTRY', 'Archive entry has an invalid size'); + } + return { name, kind, size, mode: safeMode(header.mode, kind) }; +} + +async function writeTarEntry( + entry: tar.Entry, + manifestEntry: ArchiveManifestEntry, + outputRoot: string, + reservation: { chargeBytes(bytes: number): void }, +): Promise { + const outputPath = resolveArchiveOutputPath(outputRoot, manifestEntry.name); + if (manifestEntry.kind === 'directory') { + await fs.mkdir(outputPath, { recursive: true, mode: manifestEntry.mode }); + await drainTarEntry(entry); + return; + } + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await pipeline( + entry, + async function* (source) { + for await (const chunk of source) { + reservation.chargeBytes(Buffer.byteLength(chunk)); + yield chunk; + } + }, + createWriteStream(outputPath, { flags: 'wx', mode: manifestEntry.mode }), + ); +} + +async function drainTarEntry(entry: tar.Entry): Promise { + for await (const _chunk of entry) { + // Drain inspection metadata and directory markers without charging decoded bytes. + } +} + +function isRootDirectoryMarker(name: string, type: tar.Headers['type']): boolean { + return type === 'directory' && (name === '.' || name === './' || name === ''); +} diff --git a/src/utils/archive-extraction-zip.ts b/src/utils/archive-extraction-zip.ts new file mode 100644 index 0000000000..d17429b9b2 --- /dev/null +++ b/src/utils/archive-extraction-zip.ts @@ -0,0 +1,127 @@ +import { createWriteStream, promises as fs } from 'node:fs'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import * as yauzl from 'yauzl'; +import { + ArchiveBudget, + archiveError, + normalizeArchiveEntryName, + reserveArchiveManifest, + resolveArchiveOutputPath, + sameArchiveManifestEntry, + type ArchiveManifestEntry, +} from './archive-safety.ts'; + +type ZipOptions = { + archivePath: string; + outputRoot: string; + budget?: ArchiveBudget; + depth?: number; + validateManifest?: (manifest: readonly ArchiveManifestEntry[]) => void | Promise; +}; + +export async function extractZipArchive(options: ZipOptions): Promise { + const inspected = await readZipEntries(options.archivePath); + const manifest = inspected.map(toManifestEntry); + await options.validateManifest?.(manifest); + const budget = options.budget ?? new ArchiveBudget(); + const reservation = reserveArchiveManifest(budget, options.depth ?? 1, manifest); + const entries = await readZipEntries(options.archivePath); + if (entries.length !== manifest.length) mismatch(); + const zipFile = await openZip(options.archivePath); + try { + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + const expected = manifest[index]; + if (!entry || !expected || !sameArchiveManifestEntry(toManifestEntry(entry), expected)) { + mismatch(); + } + const outputPath = resolveArchiveOutputPath(options.outputRoot, expected.name); + if (expected.kind === 'directory') { + await fs.mkdir(outputPath, { recursive: true, mode: expected.mode }); + } else { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + const source = await openEntryStream(zipFile, entry); + await pipeline( + source, + async function* (chunks) { + for await (const chunk of chunks) { + reservation.chargeBytes(Buffer.byteLength(chunk)); + yield chunk; + } + }, + createWriteStream(outputPath, { flags: 'wx', mode: expected.mode }), + ); + } + reservation.commitEntry(); + } + reservation.finish(); + } finally { + zipFile.close(); + } +} + +async function readZipEntries(archivePath: string): Promise { + const zipFile = await openZip(archivePath); + return await new Promise((resolve, reject) => { + const entries: yauzl.Entry[] = []; + zipFile.on('entry', (entry: yauzl.Entry) => { + entries.push(entry); + zipFile.readEntry(); + }); + zipFile.once('end', () => resolve(entries)); + zipFile.once('error', reject); + zipFile.readEntry(); + }).finally(() => zipFile.close()); +} + +function openZip(archivePath: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open( + archivePath, + { lazyEntries: true, autoClose: false, validateEntrySizes: true, strictFileNames: true }, + (error, zipFile) => (error ? reject(error) : resolve(zipFile)), + ); + }); +} + +function openEntryStream( + zipFile: yauzl.ZipFile, + entry: yauzl.Entry, +): Promise { + return new Promise((resolve, reject) => { + zipFile.openReadStream(entry, (error, stream) => (error ? reject(error) : resolve(stream))); + }); +} + +function toManifestEntry(entry: yauzl.Entry): ArchiveManifestEntry { + assertZipEntryIsReadable(entry); + const name = normalizeArchiveEntryName(entry.fileName); + const unixMode = (entry.externalFileAttributes >>> 16) & 0xffff; + const directory = isZipDirectory(entry.fileName, unixMode); + const fallback = directory ? 0o755 : 0o644; + return { + name, + kind: directory ? 'directory' : 'file', + size: directory ? 0 : entry.uncompressedSize, + mode: unixMode & 0o777 || fallback, + }; +} + +function assertZipEntryIsReadable(entry: yauzl.Entry): void { + if (entry.isEncrypted()) { + throw archiveError('ARCHIVE_UNSAFE_ENTRY', 'Encrypted archive entries are not supported'); + } + const type = (entry.externalFileAttributes >>> 16) & 0o170000; + if (type !== 0 && type !== 0o040000 && type !== 0o100000) { + throw archiveError('ARCHIVE_UNSAFE_ENTRY', 'Archive contains a link or special entry'); + } +} + +function isZipDirectory(fileName: string, unixMode: number): boolean { + return fileName.endsWith('/') || (unixMode & 0o170000) === 0o040000; +} + +function mismatch(): never { + throw archiveError('ARCHIVE_MANIFEST_MISMATCH', 'Archive contents changed after inspection'); +} diff --git a/src/utils/archive-extraction.ts b/src/utils/archive-extraction.ts new file mode 100644 index 0000000000..d5199db47e --- /dev/null +++ b/src/utils/archive-extraction.ts @@ -0,0 +1,48 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { ArchiveBudget, type ArchiveManifestEntry } from './archive-safety.ts'; +import { extractTarArchive } from './archive-extraction-tar.ts'; +import { extractZipArchive } from './archive-extraction-zip.ts'; + +export type SupportedArchiveType = 'tar' | 'tgz' | 'zip'; + +export type ExtractArchiveOptions = { + archivePath: string; + outputRoot: string; + type: SupportedArchiveType; + budget?: ArchiveBudget; + depth?: number; + validateManifest?: (manifest: readonly ArchiveManifestEntry[]) => void | Promise; +}; + +export async function extractArchiveSafely(options: ExtractArchiveOptions): Promise { + const archivePath = path.resolve(options.archivePath); + const outputRoot = path.resolve(options.outputRoot); + if (archivePath === outputRoot || archivePath.startsWith(`${outputRoot}${path.sep}`)) { + throw new RangeError('archivePath must be outside outputRoot'); + } + await fs.mkdir(outputRoot, { recursive: false }); + try { + if (options.type === 'zip') { + await extractZipArchive({ ...options, archivePath, outputRoot }); + } else { + await extractTarArchive({ + ...options, + archivePath, + outputRoot, + gzip: options.type === 'tgz', + }); + } + } catch (error) { + await fs.rm(outputRoot, { recursive: true, force: true }); + throw error; + } +} + +export function archiveTypeFromPath(archivePath: string): SupportedArchiveType | undefined { + const lower = archivePath.toLowerCase(); + if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) return 'tgz'; + if (lower.endsWith('.tar')) return 'tar'; + if (lower.endsWith('.zip') || lower.endsWith('.ipa')) return 'zip'; + return undefined; +} diff --git a/src/utils/archive-safety.ts b/src/utils/archive-safety.ts new file mode 100644 index 0000000000..519e337028 --- /dev/null +++ b/src/utils/archive-safety.ts @@ -0,0 +1,201 @@ +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { + MAX_ARCHIVE_ENTRIES, + MAX_ARCHIVE_EXPANDED_BYTES, + MAX_ARCHIVE_NESTING_DEPTH, +} from './artifact-limits.ts'; + +export type ArchiveEntryKind = 'directory' | 'file'; + +export type ArchiveManifestEntry = { + name: string; + kind: ArchiveEntryKind; + size: number; + mode: number; +}; + +type ArchiveLimits = { + maxBytes?: number; + maxEntries?: number; + maxDepth?: number; +}; + +export class ArchiveBudget { + readonly maxBytes: number; + readonly maxEntries: number; + readonly maxDepth: number; + #bytes = 0; + #entries = 0; + + constructor(limits: ArchiveLimits = {}) { + this.maxBytes = validateLimit(limits.maxBytes ?? MAX_ARCHIVE_EXPANDED_BYTES, 'maxBytes'); + this.maxEntries = validateLimit(limits.maxEntries ?? MAX_ARCHIVE_ENTRIES, 'maxEntries'); + this.maxDepth = validateLimit(limits.maxDepth ?? MAX_ARCHIVE_NESTING_DEPTH, 'maxDepth'); + } + + get bytes(): number { + return this.#bytes; + } + + get entries(): number { + return this.#entries; + } + + get remainingBytes(): number { + return this.maxBytes - this.#bytes; + } + + get remainingEntries(): number { + return this.maxEntries - this.#entries; + } + + preflightArchive(input: { + depth: number; + entryCount: number; + declaredBytes: number; + }): ArchiveReservation { + const depth = validateLimit(input.depth, 'depth'); + const entryCount = validateLimit(input.entryCount, 'entryCount'); + const declaredBytes = validateLimit(input.declaredBytes, 'declaredBytes'); + if (depth < 1 || depth > this.maxDepth) { + throw archiveError('ARCHIVE_NESTING_LIMIT', 'Archive nesting depth exceeds the limit'); + } + if (entryCount > this.remainingEntries) { + throw archiveError('ARCHIVE_ENTRY_LIMIT', 'Archive contains too many entries'); + } + if (declaredBytes > this.remainingBytes) { + throw archiveError('ARCHIVE_EXPANDED_BYTES_LIMIT', 'Archive expands beyond the byte limit'); + } + return new ArchiveReservation( + (bytes) => this.#chargeBytes(bytes), + () => this.#commitEntry(), + input.entryCount, + input.declaredBytes, + ); + } + + #chargeBytes(bytes: number): void { + const acceptedBytes = validateLimit(bytes, 'bytes'); + if (acceptedBytes > this.remainingBytes) { + throw archiveError('ARCHIVE_EXPANDED_BYTES_LIMIT', 'Archive expands beyond the byte limit'); + } + this.#bytes += acceptedBytes; + } + + #commitEntry(): void { + if (this.remainingEntries < 1) { + throw archiveError('ARCHIVE_ENTRY_LIMIT', 'Archive contains too many entries'); + } + this.#entries += 1; + } +} + +class ArchiveReservation { + readonly #chargeBudgetBytes: (bytes: number) => void; + readonly #commitBudgetEntry: () => void; + readonly #declaredEntries: number; + readonly #declaredBytes: number; + #actualEntries = 0; + #actualBytes = 0; + + constructor( + chargeBudgetBytes: (bytes: number) => void, + commitBudgetEntry: () => void, + declaredEntries: number, + declaredBytes: number, + ) { + this.#chargeBudgetBytes = chargeBudgetBytes; + this.#commitBudgetEntry = commitBudgetEntry; + this.#declaredEntries = declaredEntries; + this.#declaredBytes = declaredBytes; + } + + chargeBytes(bytes: number): void { + const acceptedBytes = validateLimit(bytes, 'bytes'); + if (acceptedBytes > this.#declaredBytes - this.#actualBytes) { + throw archiveError('ARCHIVE_MANIFEST_MISMATCH', 'Archive contents changed after inspection'); + } + this.#chargeBudgetBytes(acceptedBytes); + this.#actualBytes += acceptedBytes; + } + + commitEntry(): void { + if (this.#actualEntries >= this.#declaredEntries) { + throw archiveError('ARCHIVE_MANIFEST_MISMATCH', 'Archive contents changed after inspection'); + } + this.#commitBudgetEntry(); + this.#actualEntries += 1; + } + + finish(): void { + if ( + this.#actualEntries !== this.#declaredEntries || + this.#actualBytes !== this.#declaredBytes + ) { + throw archiveError('ARCHIVE_MANIFEST_MISMATCH', 'Archive contents changed after inspection'); + } + } +} + +export function reserveArchiveManifest( + budget: ArchiveBudget, + depth: number, + manifest: readonly ArchiveManifestEntry[], +): ArchiveReservation { + return budget.preflightArchive({ + depth, + entryCount: manifest.length, + declaredBytes: manifest.reduce((total, entry) => total + entry.size, 0), + }); +} + +export function sameArchiveManifestEntry( + left: ArchiveManifestEntry, + right: ArchiveManifestEntry, +): boolean { + return ( + left.name === right.name && + left.kind === right.kind && + left.size === right.size && + left.mode === right.mode + ); +} + +export function normalizeArchiveEntryName(rawName: string): string { + if (!rawName || rawName.includes('\0') || rawName.includes('\\')) { + throw archiveError('ARCHIVE_UNSAFE_PATH', 'Archive contains an unsafe entry path'); + } + if (path.posix.isAbsolute(rawName) || /^[a-zA-Z]:/.test(rawName) || rawName.startsWith('//')) { + throw archiveError('ARCHIVE_UNSAFE_PATH', 'Archive contains an unsafe entry path'); + } + const normalized = path.posix + .normalize(rawName) + .replace(/^(\.\/)+/, '') + .replace(/\/$/, ''); + if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) { + throw archiveError('ARCHIVE_UNSAFE_PATH', 'Archive contains an unsafe entry path'); + } + return normalized; +} + +export function resolveArchiveOutputPath(outputRoot: string, entryName: string): string { + const normalized = normalizeArchiveEntryName(entryName); + const resolvedRoot = path.resolve(outputRoot); + const resolvedEntry = path.resolve(resolvedRoot, ...normalized.split('/')); + if (!resolvedEntry.startsWith(`${resolvedRoot}${path.sep}`)) { + throw archiveError('ARCHIVE_UNSAFE_PATH', 'Archive entry escapes the extraction root'); + } + return resolvedEntry; +} + +export function archiveError(reason: string, message: string, cause?: unknown): AppError { + return new AppError('INVALID_ARGS', message, { reason }, cause); +} + +function validateLimit(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${label} must be a non-negative safe integer`); + } + return value; +} diff --git a/src/utils/artifact-limits.ts b/src/utils/artifact-limits.ts new file mode 100644 index 0000000000..bd2141db1b --- /dev/null +++ b/src/utils/artifact-limits.ts @@ -0,0 +1,4 @@ +export const MAX_ARTIFACT_COMPRESSED_BYTES = 2 * 1024 * 1024 * 1024; +export const MAX_ARCHIVE_EXPANDED_BYTES = 4 * 1024 * 1024 * 1024; +export const MAX_ARCHIVE_ENTRIES = 100_000; +export const MAX_ARCHIVE_NESTING_DEPTH = 3; diff --git a/src/utils/byte-limit-stream.ts b/src/utils/byte-limit-stream.ts new file mode 100644 index 0000000000..21cbfd75d4 --- /dev/null +++ b/src/utils/byte-limit-stream.ts @@ -0,0 +1,32 @@ +import { Transform } from 'node:stream'; + +export type ByteLimitStream = Transform & { + readonly bytesSeen: number; +}; + +export function createByteLimitStream(options: { + maxBytes: number; + createLimitError: () => Error; +}): ByteLimitStream { + if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0) { + throw new RangeError('maxBytes must be a non-negative safe integer'); + } + + let bytesSeen = 0; + const stream = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + const nextBytesSeen = bytesSeen + chunk.byteLength; + if (!Number.isSafeInteger(nextBytesSeen) || nextBytesSeen > options.maxBytes) { + callback(options.createLimitError()); + return; + } + bytesSeen = nextBytesSeen; + callback(null, chunk); + }, + }) as ByteLimitStream; + Object.defineProperty(stream, 'bytesSeen', { + enumerable: true, + get: () => bytesSeen, + }); + return stream; +} diff --git a/test/integration/provider-scenarios/daemon-http-resumable-upload.test.ts b/test/integration/provider-scenarios/daemon-http-resumable-upload.test.ts new file mode 100644 index 0000000000..dc89f47e14 --- /dev/null +++ b/test/integration/provider-scenarios/daemon-http-resumable-upload.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import { test } from 'vitest'; +import { + cleanupUploadedArtifact, + prepareUploadedArtifact, +} from '../../../src/daemon/artifact-tracking.ts'; +import { createDaemonHttpServer } from '../../../src/daemon/server/http-server.ts'; +import type { DaemonResponse } from '../../../src/daemon/types.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../../src/__tests__/test-utils/loopback.ts'; + +test('chunked ranged overflow rolls back before an exact retry and finalize', async (t) => { + if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP resumable upload coverage')) return; + const content = Buffer.from('ABCDE'); + const server = await createDaemonHttpServer({ + token: 'resumable-token', + handleRequest: async (): Promise => ({ ok: true, data: {} }), + }); + let trackedUploadId = ''; + try { + const port = await listenOnLoopback(server); + const auth = { authorization: 'Bearer resumable-token' }; + const preflight = await fetch(`http://127.0.0.1:${port}/upload/preflight`, { + method: 'POST', + headers: { ...auth, 'content-type': 'application/json' }, + body: JSON.stringify({ + uploadAttemptId: crypto.randomUUID(), + sha256: crypto.createHash('sha256').update(content).digest('hex'), + fileName: 'demo.apk', + sizeBytes: content.length, + artifactType: 'file', + platform: 'android', + }), + }); + const ticket = (await preflight.json()) as { + uploadId?: string; + upload?: { url?: string; headers?: Record }; + }; + assert.ok(ticket.uploadId && ticket.upload?.url); + const uploadHeaders = ticket.upload.headers ?? {}; + + const rejected = await chunkedPut( + ticket.upload.url, + { + ...uploadHeaders, + 'content-range': 'bytes 0-1/5', + }, + Buffer.from('ABC'), + ); + assert.ok(rejected >= 400); + const retried = await fetch(ticket.upload.url, { + method: 'PUT', + headers: { ...uploadHeaders, 'content-range': 'bytes 0-1/5' }, + body: Buffer.from('AB'), + }); + assert.equal(retried.status, 308); + assert.equal(retried.headers.get('x-upload-offset'), '2'); + const completed = await fetch(ticket.upload.url, { + method: 'PUT', + headers: { ...uploadHeaders, 'content-range': 'bytes 2-4/5' }, + body: Buffer.from('CDE'), + }); + assert.equal(completed.status, 200); + + const finalized = await fetch(`http://127.0.0.1:${port}/upload/finalize`, { + method: 'POST', + headers: { ...auth, 'content-type': 'application/json' }, + body: JSON.stringify({ uploadId: ticket.uploadId }), + }); + assert.equal(finalized.status, 200); + const body = (await finalized.json()) as { uploadId?: string }; + trackedUploadId = body.uploadId ?? ''; + assert.deepEqual(fs.readFileSync(prepareUploadedArtifact(trackedUploadId)), content); + } finally { + if (trackedUploadId) cleanupUploadedArtifact(trackedUploadId); + await closeLoopbackServer(server); + } +}); + +function chunkedPut(url: string, headers: Record, body: Buffer): Promise { + return new Promise((resolve, reject) => { + const req = http.request(url, { method: 'PUT', headers }, (response) => { + response.resume(); + response.once('end', () => resolve(response.statusCode ?? 0)); + }); + req.once('error', reject); + req.write(body); + req.end(); + }); +} diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index b54917f705..4dfd5df8da 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -547,6 +547,9 @@ agent-device install-from-source --github-actions-artifact thymikee/RNCLI83:6635 - Use `install` or `reinstall` for local `.apk`, `.aab`, `.app`, and `.ipa` paths; use `install-from-source` when the artifact already exists at a URL reachable by the daemon. - Direct Android URL sources may be `.apk` or `.aab`. - Trusted artifact service URLs may resolve to archives containing one installable `.apk`, `.aab`, `.ipa`, or iOS `.app` tar archive. Prefer `--github-actions-artifact` for GitHub Actions artifacts that a compatible remote daemon can resolve with its own credentials. +- Downloads resolve and approve every redirect destination, pin each connection to the approved address, reject HTTPS downgrades, and follow at most five redirects. Sensitive caller headers are not forwarded across origins. +- Downloaded artifacts are limited to 2 GiB compressed. Archive materialization is limited to 4 GiB expanded data, 100,000 entries, and three nested archive layers; links and special archive entries are rejected. +- Standard `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` configuration is honored without delegating destination DNS resolution to the proxy. - `--retain-paths` keeps retained materialized artifact paths after install, and `--retention-ms ` sets their TTL. - URL downloads follow the same `installFromSource()` safety checks and host restrictions as the JS client API. From a1b352027e738b0d07fa5580df303abdf1ccfc62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 8 Aug 2026 19:57:20 +0200 Subject: [PATCH 2/3] fix: bound archive inspection and upload expiry --- src/daemon/__tests__/resumable-upload.test.ts | 47 ++++++++++++++ src/daemon/resumable-upload-transfer.ts | 10 +-- src/daemon/resumable-upload.ts | 4 +- .../__tests__/archive-extraction-tar.test.ts | 32 +++++++++ .../__tests__/archive-extraction-zip.test.ts | 34 ++++++++++ .../__tests__/archive-extraction.fixtures.ts | 36 ++++++++++ src/utils/archive-extraction-tar.ts | 20 ++++-- src/utils/archive-extraction-zip.ts | 65 +++++++++++++++---- 8 files changed, 226 insertions(+), 22 deletions(-) create mode 100644 src/utils/__tests__/archive-extraction-tar.test.ts create mode 100644 src/utils/__tests__/archive-extraction-zip.test.ts create mode 100644 src/utils/__tests__/archive-extraction.fixtures.ts diff --git a/src/daemon/__tests__/resumable-upload.test.ts b/src/daemon/__tests__/resumable-upload.test.ts index d126feec81..b7f6e6a274 100644 --- a/src/daemon/__tests__/resumable-upload.test.ts +++ b/src/daemon/__tests__/resumable-upload.test.ts @@ -99,6 +99,53 @@ test('expiry aborts an active receive and invalidates the ticket after rollback' } }); +test('an ignored out-of-order chunk does not extend upload expiry', async () => { + vi.useFakeTimers(); + try { + const bytes = Buffer.from('AB'); + const uploadId = beginUpload(bytes).uploadId; + await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + + assert.deepEqual( + await receiveResumableUploadChunk({ + uploadId, + req: request(Buffer.from('B'), { 'content-range': 'bytes 1-1/2' }), + }), + { complete: false, offset: 0 }, + ); + + await vi.advanceTimersByTimeAsync(60 * 1000); + await assert.rejects(finalizeResumableUpload(uploadId), /not found or expired/i); + } finally { + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + vi.useRealTimers(); + } +}); + +test('an ignored un-ranged retry does not extend upload expiry', async () => { + vi.useFakeTimers(); + try { + const bytes = Buffer.from('ABC'); + const uploadId = beginUpload(bytes).uploadId; + await receiveResumableUploadChunk({ + uploadId, + req: request(Buffer.from('A'), { 'content-range': 'bytes 0-0/3' }), + }); + await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + + assert.deepEqual( + await receiveResumableUploadChunk({ uploadId, req: request(Buffer.from('A')) }), + { complete: false, offset: 1 }, + ); + + await vi.advanceTimersByTimeAsync(60 * 1000); + await assert.rejects(finalizeResumableUpload(uploadId), /not found or expired/i); + } finally { + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + vi.useRealTimers(); + } +}); + function beginUpload(bytes: Buffer): ReturnType { return beginResumableUpload({ baseUrl: 'http://127.0.0.1:1234', diff --git a/src/daemon/resumable-upload-transfer.ts b/src/daemon/resumable-upload-transfer.ts index 1b7417c357..37cd30eaa6 100644 --- a/src/daemon/resumable-upload-transfer.ts +++ b/src/daemon/resumable-upload-transfer.ts @@ -15,14 +15,16 @@ export async function receiveResumableTransfer(params: { entry: ResumableTransferEntry; req: IncomingMessage; invalidate: (error: unknown) => Promise; -}): Promise<{ complete: boolean; offset: number }> { +}): Promise<{ complete: boolean; offset: number; appended: boolean }> { const oldSize = await currentOffset(params.entry); const range = parseUploadContentRange( params.req.headers['content-range'], params.entry.sizeBytes, ); - if (range && range.start !== oldSize) return { complete: false, offset: oldSize }; - if (!range && oldSize > 0) return { complete: false, offset: oldSize }; + if (range && range.start !== oldSize) { + return { complete: false, offset: oldSize, appended: false }; + } + if (!range && oldSize > 0) return { complete: false, offset: oldSize, appended: false }; const remaining = params.entry.sizeBytes - oldSize; const bodyLimit = range?.span ?? remaining; const contentLength = parseUploadContentLength(params.req.headers['content-length']); @@ -55,7 +57,7 @@ export async function receiveResumableTransfer(params: { throw error; } const offset = await currentOffset(params.entry); - return { complete: offset === params.entry.sizeBytes, offset }; + return { complete: offset === params.entry.sizeBytes, offset, appended: offset > oldSize }; } export async function computeUploadHash(filePath: string): Promise { diff --git a/src/daemon/resumable-upload.ts b/src/daemon/resumable-upload.ts index 229cd4b544..c0a530fccd 100644 --- a/src/daemon/resumable-upload.ts +++ b/src/daemon/resumable-upload.ts @@ -86,12 +86,12 @@ export async function receiveResumableUploadChunk(params: { }): Promise<{ complete: boolean; offset: number }> { const entry = requireResumableUpload(params.uploadId, params.tenantId); return await runExclusive(entry, 'receive', params.req, async () => { - const result = await receiveResumableTransfer({ + const { appended, ...result } = await receiveResumableTransfer({ entry, req: params.req, invalidate: async () => await terminateEntry(entry), }); - if (!entry.closed) refreshResumableUploadTimer(entry); + if (appended && !entry.closed) refreshResumableUploadTimer(entry); return result; }); } diff --git a/src/utils/__tests__/archive-extraction-tar.test.ts b/src/utils/__tests__/archive-extraction-tar.test.ts new file mode 100644 index 0000000000..05333db87e --- /dev/null +++ b/src/utils/__tests__/archive-extraction-tar.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { extractTarArchive } from '../archive-extraction-tar.ts'; +import { ArchiveBudget } from '../archive-safety.ts'; +import { createArchiveWorkspace, createTruncatedTgz } from './archive-extraction.fixtures.ts'; + +test('tar inspection rejects declared bytes before draining an over-budget entry', async () => { + const workspace = await createArchiveWorkspace(); + try { + await createTruncatedTgz(workspace.archivePath); + const budget = new ArchiveBudget({ maxBytes: 1 }); + const error = await extractTarArchive({ + archivePath: workspace.archivePath, + outputRoot: workspace.outputRoot, + gzip: true, + budget, + }).then( + () => null, + (cause: unknown) => cause, + ); + + assert.equal(error instanceof AppError, true); + assert.equal((error as AppError).details?.reason, 'ARCHIVE_EXPANDED_BYTES_LIMIT'); + assert.equal(budget.bytes, 0); + assert.equal(budget.entries, 0); + assert.deepEqual(await fs.readdir(workspace.outputRoot), []); + } finally { + await fs.rm(workspace.root, { recursive: true, force: true }); + } +}); diff --git a/src/utils/__tests__/archive-extraction-zip.test.ts b/src/utils/__tests__/archive-extraction-zip.test.ts new file mode 100644 index 0000000000..40a5e26699 --- /dev/null +++ b/src/utils/__tests__/archive-extraction-zip.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { extractZipArchive } from '../archive-extraction-zip.ts'; +import { ArchiveBudget } from '../archive-safety.ts'; +import { + createArchiveWorkspace, + createZipWithEncryptedSecondEntry, +} from './archive-extraction.fixtures.ts'; + +test('zip inspection stops at the entry budget before inspecting later entries', async () => { + const workspace = await createArchiveWorkspace(); + try { + await createZipWithEncryptedSecondEntry(workspace.archivePath); + const budget = new ArchiveBudget({ maxEntries: 1 }); + const error = await extractZipArchive({ + archivePath: workspace.archivePath, + outputRoot: workspace.outputRoot, + budget, + }).then( + () => null, + (cause: unknown) => cause, + ); + + assert.equal(error instanceof AppError, true); + assert.equal((error as AppError).details?.reason, 'ARCHIVE_ENTRY_LIMIT'); + assert.equal(budget.bytes, 0); + assert.equal(budget.entries, 0); + assert.deepEqual(await fs.readdir(workspace.outputRoot), []); + } finally { + await fs.rm(workspace.root, { recursive: true, force: true }); + } +}); diff --git a/src/utils/__tests__/archive-extraction.fixtures.ts b/src/utils/__tests__/archive-extraction.fixtures.ts new file mode 100644 index 0000000000..694ea53dfc --- /dev/null +++ b/src/utils/__tests__/archive-extraction.fixtures.ts @@ -0,0 +1,36 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { gzipSync } from 'node:zlib'; +import * as tar from 'tar-stream'; +import { runCmdSync } from '../exec.ts'; + +export async function createArchiveWorkspace(): Promise<{ + archivePath: string; + outputRoot: string; + root: string; +}> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-archive-')); + const outputRoot = path.join(root, 'output'); + await fs.mkdir(outputRoot); + return { archivePath: path.join(root, 'fixture.archive'), outputRoot, root }; +} + +export async function createTruncatedTgz(archivePath: string): Promise { + const pack = tar.pack(); + pack.entry({ name: 'payload.bin' }, Buffer.from('AB')); + pack.finalize(); + const chunks: Buffer[] = []; + for await (const chunk of pack) chunks.push(Buffer.from(chunk)); + const headerOnly = Buffer.concat(chunks).subarray(0, 512); + await fs.writeFile(archivePath, gzipSync(headerOnly)); +} + +export async function createZipWithEncryptedSecondEntry(archivePath: string): Promise { + const staging = path.join(path.dirname(archivePath), 'zip-input'); + await fs.mkdir(staging); + await fs.writeFile(path.join(staging, 'first.txt'), 'first'); + await fs.writeFile(path.join(staging, 'second.txt'), 'second'); + runCmdSync('zip', ['-q', archivePath, 'first.txt'], { cwd: staging }); + runCmdSync('zip', ['-q', '-P', 'secret', archivePath, 'second.txt'], { cwd: staging }); +} diff --git a/src/utils/archive-extraction-tar.ts b/src/utils/archive-extraction-tar.ts index 2ca4667a4a..d4fb52a2ce 100644 --- a/src/utils/archive-extraction-tar.ts +++ b/src/utils/archive-extraction-tar.ts @@ -23,10 +23,11 @@ type TarOptions = { }; export async function extractTarArchive(options: TarOptions): Promise { - const manifest = await inspectTar(options); - await options.validateManifest?.(manifest); const budget = options.budget ?? new ArchiveBudget(); - const reservation = reserveArchiveManifest(budget, options.depth ?? 1, manifest); + const depth = options.depth ?? 1; + const manifest = await inspectTar(options, budget, depth); + await options.validateManifest?.(manifest); + const reservation = reserveArchiveManifest(budget, depth, manifest); const extractor = tar.extract(); const extraction = streamTar(options.archivePath, options.gzip, extractor); try { @@ -58,8 +59,13 @@ export async function extractTarArchive(options: TarOptions): Promise { reservation.finish(); } -async function inspectTar(options: TarOptions): Promise { +async function inspectTar( + options: TarOptions, + budget: ArchiveBudget, + depth: number, +): Promise { const manifest: ArchiveManifestEntry[] = []; + let declaredBytes = 0; const extractor = tar.extract(); const inspection = streamTar(options.archivePath, options.gzip, extractor); try { @@ -69,6 +75,12 @@ async function inspectTar(options: TarOptions): Promise await drainTarEntry(entry); continue; } + declaredBytes += manifestEntry.size; + budget.preflightArchive({ + depth, + entryCount: manifest.length + 1, + declaredBytes, + }); manifest.push(manifestEntry); await drainTarEntry(entry); } diff --git a/src/utils/archive-extraction-zip.ts b/src/utils/archive-extraction-zip.ts index d17429b9b2..33fffbfde8 100644 --- a/src/utils/archive-extraction-zip.ts +++ b/src/utils/archive-extraction-zip.ts @@ -21,12 +21,30 @@ type ZipOptions = { }; export async function extractZipArchive(options: ZipOptions): Promise { - const inspected = await readZipEntries(options.archivePath); - const manifest = inspected.map(toManifestEntry); - await options.validateManifest?.(manifest); const budget = options.budget ?? new ArchiveBudget(); - const reservation = reserveArchiveManifest(budget, options.depth ?? 1, manifest); - const entries = await readZipEntries(options.archivePath); + const depth = options.depth ?? 1; + const manifest: ArchiveManifestEntry[] = []; + let declaredBytes = 0; + await readZipEntries(options.archivePath, (entry) => { + budget.preflightArchive({ + depth, + entryCount: manifest.length + 1, + declaredBytes, + }); + const manifestEntry = toManifestEntry(entry); + declaredBytes += manifestEntry.size; + budget.preflightArchive({ + depth, + entryCount: manifest.length + 1, + declaredBytes, + }); + manifest.push(manifestEntry); + }); + await options.validateManifest?.(manifest); + const reservation = reserveArchiveManifest(budget, depth, manifest); + const entries = await readZipEntries(options.archivePath, (_entry, index) => { + if (index >= manifest.length) mismatch(); + }); if (entries.length !== manifest.length) mismatch(); const zipFile = await openZip(options.archivePath); try { @@ -61,16 +79,39 @@ export async function extractZipArchive(options: ZipOptions): Promise { } } -async function readZipEntries(archivePath: string): Promise { +async function readZipEntries( + archivePath: string, + inspect?: (entry: yauzl.Entry, index: number) => void, +): Promise { const zipFile = await openZip(archivePath); return await new Promise((resolve, reject) => { const entries: yauzl.Entry[] = []; - zipFile.on('entry', (entry: yauzl.Entry) => { - entries.push(entry); - zipFile.readEntry(); - }); - zipFile.once('end', () => resolve(entries)); - zipFile.once('error', reject); + const cleanup = (): void => { + zipFile.off('entry', onEntry); + zipFile.off('end', onEnd); + zipFile.off('error', onError); + }; + const onEntry = (entry: yauzl.Entry): void => { + try { + inspect?.(entry, entries.length); + entries.push(entry); + zipFile.readEntry(); + } catch (error) { + cleanup(); + reject(error); + } + }; + const onEnd = (): void => { + cleanup(); + resolve(entries); + }; + const onError = (error: Error): void => { + cleanup(); + reject(error); + }; + zipFile.on('entry', onEntry); + zipFile.once('end', onEnd); + zipFile.once('error', onError); zipFile.readEntry(); }).finally(() => zipFile.close()); } From 565ea5452ae15700ba06041b9701a1ef2fc050dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 9 Aug 2026 08:07:47 +0200 Subject: [PATCH 3/3] fix: preserve upload preflight expiry --- src/daemon/__tests__/resumable-upload.test.ts | 30 +++++++++++++++++-- src/daemon/resumable-upload.ts | 2 +- .../__tests__/install-source-download.test.ts | 1 - .../__tests__/install-source.test.ts | 1 - src/platforms/install-source-download.ts | 1 - .../install-source-network-transport.ts | 11 ++----- src/platforms/install-source-network.ts | 5 ++-- src/utils/archive-extraction-tar.ts | 2 +- src/utils/archive-safety.ts | 20 ++++--------- 9 files changed, 39 insertions(+), 34 deletions(-) diff --git a/src/daemon/__tests__/resumable-upload.test.ts b/src/daemon/__tests__/resumable-upload.test.ts index b7f6e6a274..73a1defe14 100644 --- a/src/daemon/__tests__/resumable-upload.test.ts +++ b/src/daemon/__tests__/resumable-upload.test.ts @@ -146,8 +146,34 @@ test('an ignored un-ranged retry does not extend upload expiry', async () => { } }); +test('idempotent preflight does not extend upload expiry', async () => { + vi.useFakeTimers(); + try { + const bytes = Buffer.from('AB'); + const options = uploadOptions(bytes); + const uploadId = beginResumableUpload(options).uploadId; + await receiveResumableUploadChunk({ + uploadId, + req: request(Buffer.from('A'), { 'content-range': 'bytes 0-0/2' }), + }); + await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + + assert.equal(beginResumableUpload(options).uploadId, uploadId); + + await vi.advanceTimersByTimeAsync(60 * 1000); + await assert.rejects(finalizeResumableUpload(uploadId), /not found or expired/i); + } finally { + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + vi.useRealTimers(); + } +}); + function beginUpload(bytes: Buffer): ReturnType { - return beginResumableUpload({ + return beginResumableUpload(uploadOptions(bytes)); +} + +function uploadOptions(bytes: Buffer): Parameters[0] { + return { baseUrl: 'http://127.0.0.1:1234', tokenHeaders: {}, uploadAttemptId: crypto.randomUUID(), @@ -155,7 +181,7 @@ function beginUpload(bytes: Buffer): ReturnType { fileName: 'artifact.bin', sizeBytes: bytes.length, artifactType: 'file', - }); + }; } function request(body: Buffer, headers: Record = {}): IncomingMessage { diff --git a/src/daemon/resumable-upload.ts b/src/daemon/resumable-upload.ts index c0a530fccd..d398d1982d 100644 --- a/src/daemon/resumable-upload.ts +++ b/src/daemon/resumable-upload.ts @@ -62,7 +62,6 @@ export function beginResumableUpload(options: BeginResumableUploadOptions): { const existingId = RESUMABLE_UPLOADS_BY_KEY.get(key); const existing = existingId ? RESUMABLE_UPLOADS_BY_ID.get(existingId) : undefined; const entry = existing ?? createResumableUploadEntry(options, key); - refreshResumableUploadTimer(entry); return { uploadId: entry.id, cacheHit: false, @@ -154,6 +153,7 @@ function createResumableUploadEntry( }; RESUMABLE_UPLOADS_BY_ID.set(id, entry); RESUMABLE_UPLOADS_BY_KEY.set(key, id); + refreshResumableUploadTimer(entry); return entry; } diff --git a/src/platforms/__tests__/install-source-download.test.ts b/src/platforms/__tests__/install-source-download.test.ts index ef6b51ebf0..0ae8c35f90 100644 --- a/src/platforms/__tests__/install-source-download.test.ts +++ b/src/platforms/__tests__/install-source-download.test.ts @@ -130,7 +130,6 @@ function response( ): networkTransport.InstallSourceNetworkResponse { return { statusCode, - statusText: String(statusCode), headers, body: Readable.from(body), close: async () => {}, diff --git a/src/platforms/__tests__/install-source.test.ts b/src/platforms/__tests__/install-source.test.ts index a7d2d7c9b2..b439d9693c 100644 --- a/src/platforms/__tests__/install-source.test.ts +++ b/src/platforms/__tests__/install-source.test.ts @@ -662,7 +662,6 @@ async function withMockedInstallSourceFetch( ); const requestMock = vi.spyOn(networkTransport, 'requestApprovedUrl').mockResolvedValue({ statusCode: 200, - statusText: 'OK', headers: { 'content-disposition': `attachment; filename="${options?.filename ?? 'artifact.zip'}"`, 'content-type': options?.contentType ?? 'application/zip', diff --git a/src/platforms/install-source-download.ts b/src/platforms/install-source-download.ts index 82d49cb300..dd2f01dacb 100644 --- a/src/platforms/install-source-download.ts +++ b/src/platforms/install-source-download.ts @@ -105,7 +105,6 @@ async function writeResponse( throw new AppError('COMMAND_FAILED', 'App source response used unsupported content encoding'); } validateContentLength(readHeader(response.headers, 'content-length')); - if (!response.body) throw new AppError('COMMAND_FAILED', 'Download response body was empty'); const destinationPath = path.join(tempDir, resolveDownloadFileName(response, url)); const byteLimit = createByteLimitStream({ maxBytes: MAX_ARTIFACT_COMPRESSED_BYTES, diff --git a/src/platforms/install-source-network-transport.ts b/src/platforms/install-source-network-transport.ts index 852392bbd5..a32efb9f27 100644 --- a/src/platforms/install-source-network-transport.ts +++ b/src/platforms/install-source-network-transport.ts @@ -3,7 +3,6 @@ import { Agent, ProxyAgent, request, type Dispatcher } from 'undici'; export type InstallSourceNetworkResponse = { statusCode: number; - statusText: string; headers: Record; body: NodeJS.ReadableStream; close: () => Promise; @@ -18,7 +17,7 @@ export async function requestApprovedUrl(params: { }): Promise { const proxy = resolveProxyForUrl(params.url); const dispatcher = proxy - ? proxyDispatcher(proxy, params.url, params.approvedAddress, params.family) + ? proxyDispatcher(proxy, params.url) : directDispatcher(params.approvedAddress, params.family); const dispatchUrl = proxy ? approvedAddressUrl(params.url, params.approvedAddress, params.family) @@ -34,7 +33,6 @@ export async function requestApprovedUrl(params: { const response = await request(dispatchUrl, requestOptions); return { statusCode: response.statusCode, - statusText: String(response.statusCode), headers: response.headers, body: response.body, close: async () => await closeDispatcher(dispatcher), @@ -79,12 +77,7 @@ function directDispatcher(address: string, family: 4 | 6): Agent { }); } -function proxyDispatcher( - proxyUrl: string, - destination: URL, - _address: string, - _family: 4 | 6, -): ProxyAgent { +function proxyDispatcher(proxyUrl: string, destination: URL): ProxyAgent { const literalDestination = net.isIP(stripBrackets(destination.hostname)) !== 0; return new ProxyAgent({ uri: proxyUrl, diff --git a/src/platforms/install-source-network.ts b/src/platforms/install-source-network.ts index d1c250aa9f..e02be00d0a 100644 --- a/src/platforms/install-source-network.ts +++ b/src/platforms/install-source-network.ts @@ -7,7 +7,6 @@ export async function approveDownloadSourceUrl( parsedUrl: URL, signal?: AbortSignal, ): Promise<{ - hostname: string; address: string; family: 4 | 6; }> { @@ -21,7 +20,7 @@ export async function approveDownloadSourceUrl( const hostname = canonicalHostname(parsedUrl.hostname); if (isBlockedSourceHostname(hostname)) blockedHost(parsedUrl.hostname); const literalFamily = net.isIP(hostname); - if (literalFamily) return { hostname, address: hostname, family: literalFamily as 4 | 6 }; + if (literalFamily) return { address: hostname, family: literalFamily as 4 | 6 }; let resolved: Array<{ address: string; family: number }>; try { @@ -42,7 +41,7 @@ export async function approveDownloadSourceUrl( } if (resolved.some((entry) => isBlockedIpAddress(entry.address))) blockedHost(hostname); const selected = resolved[0]!; - return { hostname, address: selected.address, family: selected.family as 4 | 6 }; + return { address: selected.address, family: selected.family as 4 | 6 }; } async function lookupWithSignal( diff --git a/src/utils/archive-extraction-tar.ts b/src/utils/archive-extraction-tar.ts index d4fb52a2ce..54eb5cd60c 100644 --- a/src/utils/archive-extraction-tar.ts +++ b/src/utils/archive-extraction-tar.ts @@ -113,7 +113,7 @@ function readTarKind(type: tar.Headers['type']): 'directory' | 'file' { function safeMode(mode: number | undefined, kind: 'directory' | 'file'): number { const fallback = kind === 'directory' ? 0o755 : 0o644; - return (mode ?? fallback) & (kind === 'directory' ? 0o777 : 0o777); + return (mode ?? fallback) & 0o777; } function manifestEntryFromTarHeader(header: tar.Headers): ArchiveManifestEntry | undefined { diff --git a/src/utils/archive-safety.ts b/src/utils/archive-safety.ts index 519e337028..892fc51961 100644 --- a/src/utils/archive-safety.ts +++ b/src/utils/archive-safety.ts @@ -6,11 +6,9 @@ import { MAX_ARCHIVE_NESTING_DEPTH, } from './artifact-limits.ts'; -export type ArchiveEntryKind = 'directory' | 'file'; - export type ArchiveManifestEntry = { name: string; - kind: ArchiveEntryKind; + kind: 'directory' | 'file'; size: number; mode: number; }; @@ -42,14 +40,6 @@ export class ArchiveBudget { return this.#entries; } - get remainingBytes(): number { - return this.maxBytes - this.#bytes; - } - - get remainingEntries(): number { - return this.maxEntries - this.#entries; - } - preflightArchive(input: { depth: number; entryCount: number; @@ -61,10 +51,10 @@ export class ArchiveBudget { if (depth < 1 || depth > this.maxDepth) { throw archiveError('ARCHIVE_NESTING_LIMIT', 'Archive nesting depth exceeds the limit'); } - if (entryCount > this.remainingEntries) { + if (entryCount > this.maxEntries - this.#entries) { throw archiveError('ARCHIVE_ENTRY_LIMIT', 'Archive contains too many entries'); } - if (declaredBytes > this.remainingBytes) { + if (declaredBytes > this.maxBytes - this.#bytes) { throw archiveError('ARCHIVE_EXPANDED_BYTES_LIMIT', 'Archive expands beyond the byte limit'); } return new ArchiveReservation( @@ -77,14 +67,14 @@ export class ArchiveBudget { #chargeBytes(bytes: number): void { const acceptedBytes = validateLimit(bytes, 'bytes'); - if (acceptedBytes > this.remainingBytes) { + if (acceptedBytes > this.maxBytes - this.#bytes) { throw archiveError('ARCHIVE_EXPANDED_BYTES_LIMIT', 'Archive expands beyond the byte limit'); } this.#bytes += acceptedBytes; } #commitEntry(): void { - if (this.remainingEntries < 1) { + if (this.#entries >= this.maxEntries) { throw archiveError('ARCHIVE_ENTRY_LIMIT', 'Archive contains too many entries'); } this.#entries += 1;