diff --git a/Cargo.lock b/Cargo.lock index 7039d19c..c0f31cd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2268,6 +2268,7 @@ dependencies = [ name = "locality-notion" version = "0.3.6" dependencies = [ + "base64 0.22.1", "locality-connector", "locality-core", "mime_guess", diff --git a/apps/oauth-service/package-lock.json b/apps/oauth-service/package-lock.json index c736826c..24bbf16c 100644 --- a/apps/oauth-service/package-lock.json +++ b/apps/oauth-service/package-lock.json @@ -8,13 +8,13 @@ "name": "@codeflash-ai/locality-auth-broker", "version": "0.1.0", "dependencies": { - "hono": "^4.6.0" + "hono": "^4.12.31" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260611.0", + "@cloudflare/workers-types": "^5.20260721.1", "typescript": "^5.9.0", "vitest": "^4.1.9", - "wrangler": "^4.0.0" + "wrangler": "^4.113.0" }, "engines": { "node": ">=22" @@ -47,9 +47,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260617.1.tgz", - "integrity": "sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260721.1.tgz", + "integrity": "sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==", "cpu": [ "x64" ], @@ -64,9 +64,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260617.1.tgz", - "integrity": "sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260721.1.tgz", + "integrity": "sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==", "cpu": [ "arm64" ], @@ -81,9 +81,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260617.1.tgz", - "integrity": "sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260721.1.tgz", + "integrity": "sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==", "cpu": [ "x64" ], @@ -98,9 +98,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260617.1.tgz", - "integrity": "sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260721.1.tgz", + "integrity": "sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==", "cpu": [ "arm64" ], @@ -115,9 +115,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260617.1.tgz", - "integrity": "sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260721.1.tgz", + "integrity": "sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==", "cpu": [ "x64" ], @@ -132,9 +132,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260623.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260623.1.tgz", - "integrity": "sha512-J/0POl0HeLepbwDE5Yx5c7jQrHFkvCEFu3TS+TQsDDlg/vTs5og7wdGP6eNGXOAntgWUrjcvvKTmVLTP7OrnAg==", + "version": "5.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260721.1.tgz", + "integrity": "sha512-J6HZRuQOP3gVe9G5rxHQVS8kQRjo7NIaF+Lz4kO2lVmaCkQ1E78APOOthaI7KyCQzp+A2NbXBQI6QLRIswdWbA==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -185,2604 +185,2604 @@ "tslib": "^2.4.0" } }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { "node": ">=18" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ - "arm64" + "arm" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "node": ">=18" } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "node": ">=18" } }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "freebsd" + "android" ], - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "darwin" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "darwin" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ - "arm" + "arm64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ - "ppc64" + "arm" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ - "s390x" + "ia32" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ - "x64" + "loong64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ - "arm64" + "mips64el" ], "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ - "x64" + "ppc64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ - "arm" + "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ - "arm64" + "s390x" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ - "ppc64" + "x64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "netbsd" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ - "s390x" + "x64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "netbsd" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "node": ">=18" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "node": ">=18" } }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.1" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ - "wasm32" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, + "os": [ + "sunos" + ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "node": ">=18" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "url": "https://opencollective.com/libvips" }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@poppinss/colors": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", - "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^4.1.5" - } - }, - "node_modules/@poppinss/dumper": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", - "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@sindresorhus/is": "^7.0.2", - "supports-color": "^10.0.0" + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, - "node_modules/@poppinss/exception": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", - "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "android" + "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", - "cpu": [ - "arm64" - ], + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "darwin" + "freebsd" ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "freebsd" + "darwin" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ - "ppc64" + "riscv64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", - "cpu": [ + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ "s390x" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "openharmony" + "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ - "wasm32" + "arm" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ - "x64" + "riscv64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, - "node_modules/@speed-highlight/core": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", - "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "CC0-1.0" + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "Apache-2.0", "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "url": "https://opencollective.com/libvips" }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, "dependencies": { - "tinyrainbow": "^3.1.0" + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "@vitest/utils": "4.1.9", - "pathe": "^2.0.3" + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/libvips" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=6.0.0" } }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/error-stack-parser-es": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", - "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/antfu" + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" + "kleur": "^4.1.5" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } + "license": "MIT" }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ - "x64" + "ppc64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ - "x64" + "s390x" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/miniflare": { - "version": "4.20260617.1", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260617.1.tgz", - "integrity": "sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.34.5", - "undici": "7.28.0", - "workerd": "1.20260617.1", - "ws": "8.21.0", - "youch": "4.1.0-beta.10" - }, - "bin": { - "miniflare": "bootstrap.js" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=22.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12.20.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", + "optional": true, "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" + "tslib": "^2.4.0" } }, - "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" - }, - "engines": { - "node": ">=20.9.0" + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://opencollective.com/vitest" }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "@types/node": { + "msw": { + "optional": true + }, + "vite": { "optional": true } } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://opencollective.com/vitest" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "url": "https://opencollective.com/vitest" } }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=12" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", "dev": true, - "license": "0BSD", - "optional": true + "license": "MIT" }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "MIT", "engines": { - "node": ">=14.17" + "node": ">=18" } }, - "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/unenv": { - "version": "2.0.0-rc.24", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", - "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", "dev": true, "license": "MIT", - "dependencies": { - "pathe": "^2.0.3" + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", - "tinyglobby": "^0.2.17" - }, "bin": { - "vite": "bin/vite.js" + "esbuild": "bin/esbuild" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" + "node": ">=18" }, "optionalDependencies": { - "fsevents": "~2.3.3" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "picomatch": "^3 || ^4" }, "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { + "picomatch": { "optional": true } } }, - "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "dev": true, "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" + "detect-libc": "^2.0.3" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 12.0.0" }, "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/workerd": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260617.1.tgz", - "integrity": "sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260617.1", - "@cloudflare/workerd-darwin-arm64": "1.20260617.1", - "@cloudflare/workerd-linux-64": "1.20260617.1", - "@cloudflare/workerd-linux-arm64": "1.20260617.1", - "@cloudflare/workerd-windows-64": "1.20260617.1" - } - }, - "node_modules/wrangler": { - "version": "4.103.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.103.0.tgz", - "integrity": "sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", - "blake3-wasm": "2.1.5", - "esbuild": "0.28.1", - "miniflare": "4.20260617.1", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260617.1" - }, - "bin": { - "cf-wrangler": "bin/cf-wrangler.js", - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, - "engines": { - "node": ">=22.0.0" + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "fsevents": "2.3.3" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^4.20260617.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "aix" + "android" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ - "arm" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "android" + "darwin" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "android" + "darwin" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "android" + "freebsd" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ - "arm64" + "arm" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "freebsd" + "linux" ], "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ - "arm" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ - "ia32" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ - "loong64" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], + "node_modules/miniflare": { + "version": "4.20260721.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260721.0.tgz", + "integrity": "sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260721.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, "engines": { - "node": ">=18" + "node": ">=22.0.0" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, "engines": { - "node": ">=18" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" ], + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12.20.0" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } + "license": "ISC" }, - "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], "engines": { - "node": ">=18" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, "engines": { - "node": ">=18" + "node": "^10 || ^12 || >=14" } }, - "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, - "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=18" + "node": ">=10" } }, - "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/wrangler/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { "node": ">=18" } }, - "node_modules/wrangler/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=18" + "node": ">=12.0.0" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260721.1.tgz", + "integrity": "sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260721.1", + "@cloudflare/workerd-darwin-arm64": "1.20260721.1", + "@cloudflare/workerd-linux-64": "1.20260721.1", + "@cloudflare/workerd-linux-arm64": "1.20260721.1", + "@cloudflare/workerd-windows-64": "1.20260721.1" + } + }, + "node_modules/wrangler": { + "version": "4.113.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.113.0.tgz", + "integrity": "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260721.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260721.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260721.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } } }, "node_modules/ws": { diff --git a/apps/oauth-service/package.json b/apps/oauth-service/package.json index 8ee5bf2a..23b303e4 100644 --- a/apps/oauth-service/package.json +++ b/apps/oauth-service/package.json @@ -13,13 +13,16 @@ "check": "npm run typecheck && npm test" }, "dependencies": { - "hono": "^4.6.0" + "hono": "^4.12.31" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260611.0", + "@cloudflare/workers-types": "^5.20260721.1", "typescript": "^5.9.0", "vitest": "^4.1.9", - "wrangler": "^4.0.0" + "wrangler": "^4.113.0" + }, + "overrides": { + "sharp": "^0.35.3" }, "overrides": { "sharp": "0.35.3" diff --git a/crates/loc-cli/Cargo.toml b/crates/loc-cli/Cargo.toml index 1806276b..fc700594 100644 --- a/crates/loc-cli/Cargo.toml +++ b/crates/loc-cli/Cargo.toml @@ -33,9 +33,9 @@ reqwest = { version = "0.13", default-features = false, features = ["blocking", rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.10" yaml_serde = "0.10" [dev-dependencies] -sha2 = "0.10" tar = { version = "0.4", default-features = false } zstd = { version = "0.13", default-features = false } diff --git a/crates/loc-cli/src/commands.rs b/crates/loc-cli/src/commands.rs index ce6fa1bf..1a78b23e 100644 --- a/crates/loc-cli/src/commands.rs +++ b/crates/loc-cli/src/commands.rs @@ -7,7 +7,7 @@ use std::sync::mpsc::{self, Sender}; use std::thread::{self, JoinHandle}; use std::time::Duration; -use clap::{Args, CommandFactory, Parser, Subcommand}; +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use locality_connector::ConnectorUndoApplier; use locality_connector::oauth_broker::OAuthBrokerStart; use locality_core::LocalityError; @@ -110,7 +110,8 @@ use crate::push::{ }; use crate::restore::{RestoreError, RestoreOptions, RestoreReport, run_restore}; use crate::sandbox::{ - SandboxInitOptions, SandboxInitReport, resolve_bootstrap_token, run_sandbox_init, + SandboxContentEncodingPreference, SandboxInitOptions, SandboxInitReport, + resolve_bootstrap_token, run_sandbox_init_with_encoding, }; use crate::search::{ SearchError, SearchOptions, SearchReport, SearchResult, is_notion_url_host, notion_id_from_url, @@ -266,10 +267,41 @@ struct SandboxInitArgs { help = "Absent destination path for the read-only replica" )] root: String, + #[arg( + long, + value_enum, + value_name = "ENCODING", + help = "Require zstd or identity export encoding" + )] + encoding: Option, #[arg(long, help = "Read the one-time bootstrap token from standard input")] bootstrap_token_stdin: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +enum SandboxEncodingArg { + Zstd, + Identity, +} + +impl SandboxEncodingArg { + const fn as_str(self) -> &'static str { + match self { + Self::Zstd => "zstd", + Self::Identity => "identity", + } + } +} + +impl From for SandboxContentEncodingPreference { + fn from(value: SandboxEncodingArg) -> Self { + match value { + SandboxEncodingArg::Zstd => Self::Zstd, + SandboxEncodingArg::Identity => Self::Identity, + } + } +} + #[derive(Debug, Subcommand)] enum ConnectCommand { #[command(about = "Connect a Notion workspace")] @@ -1473,6 +1505,9 @@ fn legacy_args_for_command(command: &LocalityCommand) -> Vec { args.push("init".to_string()); push_flag_value(&mut args, "--api-url", &options.api_url); push_flag_value(&mut args, "--root", &options.root); + if let Some(encoding) = options.encoding { + push_flag_value(&mut args, "--encoding", encoding.as_str()); + } push_flag( &mut args, "--bootstrap-token-stdin", @@ -1634,6 +1669,9 @@ fn legacy_args_for_command(command: &LocalityCommand) -> Vec { } fn sandbox_init(options: SandboxInitArgs, json: bool) -> i32 { + let content_encoding = options + .encoding + .map_or(SandboxContentEncodingPreference::Automatic, Into::into); let token = { let mut stdin = io::stdin().lock(); match resolve_bootstrap_token( @@ -1645,12 +1683,13 @@ fn sandbox_init(options: SandboxInitArgs, json: bool) -> i32 { Err(error) => return sandbox_init_command_error(json, error), } }; - match run_sandbox_init( + match run_sandbox_init_with_encoding( SandboxInitOptions { api_url: options.api_url, root: PathBuf::from(options.root), }, token, + content_encoding, ) { Ok(report) => { if json { @@ -9576,13 +9615,13 @@ mod tests { use super::resolve_mount_target; use super::{ Cli, ConnectReport, DaemonUnavailableReason, EXIT_SUCCESS, EXIT_USAGE, EXIT_VALIDATION, - FileProviderCommandReport, PushConfirmationPromptError, SLACK_CONNECTOR_ID, - VirtualProjectionRegistration, absolute_command_path, - auto_registration_for_mounted_projection, default_mount_id_for_source, - diff_report_exit_code, exact_located_entity_record, file_provider_list_lines, - google_calendar_oauth_broker_config, google_docs_oauth_broker_config, - guard_linux_fuse_shared_root_unregister, guard_unresolved_linux_fuse_unregister, - guard_unresolved_windows_cloud_files_unregister, + FileProviderCommandReport, LocalityCommand, PushConfirmationPromptError, + SLACK_CONNECTOR_ID, SandboxCommand, SandboxEncodingArg, VirtualProjectionRegistration, + absolute_command_path, auto_registration_for_mounted_projection, + default_mount_id_for_source, diff_report_exit_code, exact_located_entity_record, + file_provider_list_lines, google_calendar_oauth_broker_config, + google_docs_oauth_broker_config, guard_linux_fuse_shared_root_unregister, + guard_unresolved_linux_fuse_unregister, guard_unresolved_windows_cloud_files_unregister, guard_windows_cloud_files_shared_root_unregister, legacy_args_for_command, locality_error_code, locate_result_from_report, mount_slack, mount_usage, mounted_projection_preflight_error, notion_authorize_url, notion_oauth_broker_config, @@ -9862,6 +9901,7 @@ mod tests { "Usage: loc sandbox init", "--api-url ", "--root ", + "--encoding ", "--bootstrap-token-stdin", "--json", ], @@ -10167,6 +10207,8 @@ mod tests { "https://api.locality.test", "--root", "/mnt/locality", + "--encoding", + "zstd", "--bootstrap-token-stdin", ]); assert_eq!( @@ -10178,6 +10220,8 @@ mod tests { "https://api.locality.test", "--root", "/mnt/locality", + "--encoding", + "zstd", "--bootstrap-token-stdin" ] ); @@ -10519,6 +10563,64 @@ mod tests { } } + #[test] + fn sandbox_encoding_parser_is_typed_optional_and_exact() { + for (value, expected) in [ + ("zstd", SandboxEncodingArg::Zstd), + ("identity", SandboxEncodingArg::Identity), + ] { + let cli = parse_cli([ + "sandbox", + "init", + "--api-url", + "https://api.locality.test", + "--root", + "/mnt/locality", + "--encoding", + value, + ]); + let Some(LocalityCommand::Sandbox { + command: SandboxCommand::Init(options), + }) = cli.command + else { + panic!("sandbox init command expected"); + }; + assert_eq!(options.encoding, Some(expected)); + } + + let automatic = parse_cli([ + "sandbox", + "init", + "--api-url", + "https://api.locality.test", + "--root", + "/mnt/locality", + ]); + let Some(LocalityCommand::Sandbox { + command: SandboxCommand::Init(options), + }) = automatic.command + else { + panic!("sandbox init command expected"); + }; + assert_eq!(options.encoding, None); + + let error = Cli::try_parse_from(argv([ + "sandbox", + "init", + "--api-url", + "https://api.locality.test", + "--root", + "/mnt/locality", + "--encoding", + "gzip", + ])) + .expect_err("unsupported encoding rejected by parser"); + assert_eq!(error.kind(), ErrorKind::InvalidValue); + let message = error.to_string(); + assert!(message.contains("invalid value 'gzip'")); + assert!(message.contains("[possible values: zstd, identity]")); + } + #[test] fn log_report_writer_prints_metadata_and_readable_diff() { let report = LogReport { diff --git a/crates/loc-cli/src/sandbox.rs b/crates/loc-cli/src/sandbox.rs index be41d195..ab290bca 100644 --- a/crates/loc-cli/src/sandbox.rs +++ b/crates/loc-cli/src/sandbox.rs @@ -27,12 +27,16 @@ use reqwest::blocking::{Client, Response}; use reqwest::header::{ACCEPT, ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_TYPE, HeaderMap}; use serde::Serialize; use serde::de::DeserializeOwned; +use sha2::{Digest, Sha256}; const JSON_MEDIA_TYPE: &str = "application/json"; const TAR_MEDIA_TYPE: &str = "application/x-tar"; const MAX_JSON_RESPONSE_BYTES: u64 = 1024 * 1024; const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30 * 60); +const BOOTSTRAP_EXCHANGE_ATTEMPTS: usize = 2; +const BOOTSTRAP_IDEMPOTENCY_DOMAIN: &[u8] = b"locality.session-exchange-idempotency.v1\0"; +const IDEMPOTENCY_KEY_HEADER: &str = "Idempotency-Key"; static REQWEST_CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); #[derive(Clone)] @@ -64,6 +68,38 @@ pub struct SandboxInitOptions { pub root: PathBuf, } +/// Controls HTTP content negotiation for a sandbox export. +/// +/// [`Self::Automatic`] preserves the original preference for Zstd with an +/// identity fallback. The forced variants are intended for acceptance and +/// interoperability testing and fail closed if the server selects a different +/// encoding. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SandboxContentEncodingPreference { + #[default] + Automatic, + Identity, + Zstd, +} + +impl SandboxContentEncodingPreference { + fn accept_encoding(self) -> &'static str { + match self { + Self::Automatic => "zstd, identity", + Self::Identity => "identity", + Self::Zstd => "zstd", + } + } + + fn required_encoding(self) -> Option { + match self { + Self::Automatic => None, + Self::Identity => Some(ReplicaArchiveEncoding::Identity), + Self::Zstd => Some(ReplicaArchiveEncoding::Zstd), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct SandboxInitReport { pub ok: bool, @@ -298,6 +334,29 @@ pub fn resolve_bootstrap_token( pub fn run_sandbox_init( options: SandboxInitOptions, bootstrap_token: SandboxBootstrapToken, +) -> Result { + run_sandbox_init_with_encoding( + options, + bootstrap_token, + SandboxContentEncodingPreference::Automatic, + ) +} + +/// Initializes a sandbox with an explicit export content-negotiation policy. +/// +/// The existing [`run_sandbox_init`] entry point remains automatic for source +/// compatibility. A forced preference is checked against the sealed offer +/// before the export request and against the response before any body bytes are +/// materialized. +/// +/// # Errors +/// +/// Returns [`SandboxInitError`] when validation, protocol exchange, content +/// negotiation, or atomic materialization fails. +pub fn run_sandbox_init_with_encoding( + options: SandboxInitOptions, + bootstrap_token: SandboxBootstrapToken, + content_encoding: SandboxContentEncodingPreference, ) -> Result { let root = absolute_destination(&options.root)?; validate_destination(&root)?; @@ -307,8 +366,9 @@ pub fn run_sandbox_init( validate_capability(&capability)?; let status = client.session_status(&capability)?; let (offer, expected_receipt) = validate_status(&capability, &status)?; + validate_encoding_preference(offer, content_encoding)?; let limits = limits_for_offer(offer)?; - let (encoding, response) = client.open_export(&capability, offer)?; + let (encoding, response) = client.open_export(&capability, offer, content_encoding)?; let archive = ReplicaArchive::new(encoding, response); let summary = materialize_replica_archive_with_expected_receipt(archive, &root, limits, expected_receipt) @@ -491,6 +551,25 @@ fn limits_for_offer( }) } +fn validate_encoding_preference( + offer: &TarExportOffer, + preference: SandboxContentEncodingPreference, +) -> Result<(), SandboxInitError> { + let Some(required) = preference.required_encoding() else { + return Ok(()); + }; + if offer + .supported_content_encodings + .contains(&protocol_encoding(required)) + { + Ok(()) + } else { + Err(SandboxInitError::UnsupportedExportEncoding( + encoding_name(required).to_string(), + )) + } +} + fn report( root: &Path, capability: &SessionCapability, @@ -566,17 +645,45 @@ impl SandboxHttpClient { let request = OpaqueBootstrapExchangeRequest { bootstrap_token: token.expose().to_string(), }; - let response = self - .client - .post(self.sessions_url()) - .header(ACCEPT, JSON_MEDIA_TYPE) - .json(&request) - .send() - .map_err(|error| SandboxInitError::Http { - operation: "bootstrap exchange", - detail: error.without_url().to_string(), - })?; - read_json_response(response, "bootstrap exchange") + let idempotency_key = derive_idempotency_key(token); + + for attempt in 0..BOOTSTRAP_EXCHANGE_ATTEMPTS { + let response = match self + .client + .post(self.sessions_url()) + .header(ACCEPT, JSON_MEDIA_TYPE) + .header(IDEMPOTENCY_KEY_HEADER, &idempotency_key) + .json(&request) + .send() + { + Ok(response) => response, + Err(error) => { + let error = SandboxInitError::Http { + operation: "bootstrap exchange", + detail: error.without_url().to_string(), + }; + if has_retry_remaining(attempt) { + continue; + } + return Err(error); + } + }; + + if is_retriable_bootstrap_status(response.status()) && has_retry_remaining(attempt) { + continue; + } + match read_json_response(response, "bootstrap exchange") { + Ok(capability) => return Ok(capability), + Err(error) + if has_retry_remaining(attempt) && is_ambiguous_bootstrap_error(&error) => + { + continue; + } + Err(error) => return Err(error), + } + } + + unreachable!("bootstrap exchange attempt loop always returns") } fn session_status( @@ -600,12 +707,13 @@ impl SandboxHttpClient { &self, capability: &SessionCapability, offer: &TarExportOffer, + preference: SandboxContentEncodingPreference, ) -> Result<(ReplicaArchiveEncoding, Response), SandboxInitError> { let response = self .client .get(self.export_url(capability.session_id.as_str())) .header(ACCEPT, TAR_MEDIA_TYPE) - .header(ACCEPT_ENCODING, "zstd, identity") + .header(ACCEPT_ENCODING, preference.accept_encoding()) .bearer_auth(&capability.opaque_capability) .send() .map_err(|error| SandboxInitError::Http { @@ -615,15 +723,21 @@ impl SandboxHttpClient { ensure_success(&response, "session export")?; require_media_type(response.headers(), "session export", TAR_MEDIA_TYPE)?; let encoding = response_encoding(response.headers())?; - let offered = match encoding { - ReplicaArchiveEncoding::Identity => TarContentEncoding::Identity, - ReplicaArchiveEncoding::Zstd => TarContentEncoding::Zstd, - }; + let offered = protocol_encoding(encoding); if !offer.supported_content_encodings.contains(&offered) { return Err(SandboxInitError::UnsupportedExportEncoding( encoding_name(encoding).to_string(), )); } + if let Some(required) = preference.required_encoding() + && encoding != required + { + return Err(SandboxInitError::UnsupportedExportEncoding(format!( + "{} (requested {})", + encoding_name(encoding), + encoding_name(required) + ))); + } Ok((encoding, response)) } @@ -640,6 +754,41 @@ impl SandboxHttpClient { } } +fn derive_idempotency_key(token: &SandboxBootstrapToken) -> String { + let mut hasher = Sha256::new(); + hasher.update(BOOTSTRAP_IDEMPOTENCY_DOMAIN); + hasher.update(token.expose().as_bytes()); + let digest = hasher.finalize(); + let mut encoded = [0_u8; 64]; + const LOWER_HEX: &[u8; 16] = b"0123456789abcdef"; + for (index, byte) in digest.into_iter().enumerate() { + encoded[index * 2] = LOWER_HEX[usize::from(byte >> 4)]; + encoded[index * 2 + 1] = LOWER_HEX[usize::from(byte & 0x0f)]; + } + String::from_utf8(encoded.to_vec()).expect("lowercase hexadecimal is valid UTF-8") +} + +fn has_retry_remaining(attempt: usize) -> bool { + attempt + 1 < BOOTSTRAP_EXCHANGE_ATTEMPTS +} + +fn is_retriable_bootstrap_status(status: StatusCode) -> bool { + matches!( + status, + StatusCode::BAD_GATEWAY | StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT + ) +} + +fn is_ambiguous_bootstrap_error(error: &SandboxInitError) -> bool { + matches!( + error, + SandboxInitError::Http { + operation: "bootstrap exchange", + .. + } + ) +} + fn endpoint_url(base: &reqwest::Url, segments: &[&str]) -> reqwest::Url { let mut url = base.clone(); url.set_path(""); @@ -729,3 +878,441 @@ fn encoding_name(encoding: ReplicaArchiveEncoding) -> &'static str { ReplicaArchiveEncoding::Zstd => "zstd", } } + +fn protocol_encoding(encoding: ReplicaArchiveEncoding) -> TarContentEncoding { + match encoding { + ReplicaArchiveEncoding::Identity => TarContentEncoding::Identity, + ReplicaArchiveEncoding::Zstd => TarContentEncoding::Zstd, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::io::{self, Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc::{self, Receiver}; + use std::thread::{self, JoinHandle}; + use std::time::{Duration, Instant}; + + use locality_core::portable::SessionId; + + use super::*; + + #[derive(Debug)] + struct CapturedRequest { + method: String, + path: String, + headers: BTreeMap, + body: Vec, + } + + enum TestResponse { + DropConnection, + Json { status: &'static str, body: Vec }, + } + + struct TestServer { + api_url: String, + requests: Receiver, + handle: JoinHandle<()>, + } + + impl TestServer { + fn start(responses: Vec, reject_extra_request: bool) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + listener + .set_nonblocking(true) + .expect("set test listener nonblocking"); + let address = listener.local_addr().expect("test server address"); + let (sender, requests) = mpsc::channel(); + let handle = thread::spawn(move || { + for response in responses { + let mut stream = + accept_before(&listener, Instant::now() + Duration::from_secs(5)); + let request = read_request(&mut stream); + sender.send(request).expect("capture request"); + match response { + TestResponse::DropConnection => {} + TestResponse::Json { status, body } => { + write_json_response(&mut stream, status, &body); + } + } + } + if reject_extra_request { + let deadline = Instant::now() + Duration::from_millis(250); + loop { + match listener.accept() { + Ok((mut stream, _)) => { + let request = read_request(&mut stream); + panic!("unexpected retry: {request:?}"); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + break; + } + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept unexpected request: {error}"), + } + } + } + }); + Self { + api_url: format!("http://{address}"), + requests, + handle, + } + } + + fn finish(self) -> Vec { + self.handle.join().expect("test server completed"); + self.requests.try_iter().collect() + } + } + + #[test] + fn dropped_bootstrap_response_retries_with_identical_key_and_body() { + let response = serde_json::to_vec(&capability()).expect("serialize capability"); + let server = TestServer::start( + vec![ + TestResponse::DropConnection, + TestResponse::Json { + status: "200 OK", + body: response, + }, + ], + true, + ); + let client = SandboxHttpClient::new(&server.api_url).expect("HTTP client"); + let token = SandboxBootstrapToken::new("bootstrap-secret").expect("bootstrap token"); + + let actual = client + .exchange_bootstrap(&token) + .expect("retry bootstrap exchange"); + + assert_eq!(actual, capability()); + let requests = server.finish(); + assert_eq!(requests.len(), 2); + assert_bootstrap_request(&requests[0], br#"{"bootstrap_token":"bootstrap-secret"}"#); + assert_bootstrap_request(&requests[1], br#"{"bootstrap_token":"bootstrap-secret"}"#); + assert_eq!(requests[0].body, requests[1].body); + assert_eq!(idempotency_key(&requests[0]), idempotency_key(&requests[1])); + assert_eq!( + idempotency_key(&requests[0]), + "fe1fd6a544a78d3a3087bf1517b0ca83b6d122bf1d88d1eddc264e883500bded" + ); + } + + #[test] + fn retriable_gateway_responses_retry_with_identical_key_and_body() { + let cases = [ + "502 Bad Gateway", + "503 Service Unavailable", + "504 Gateway Timeout", + ]; + + for status in cases { + let response = serde_json::to_vec(&capability()).expect("serialize capability"); + let server = TestServer::start( + vec![ + TestResponse::Json { + status, + body: Vec::new(), + }, + TestResponse::Json { + status: "200 OK", + body: response, + }, + ], + true, + ); + let client = SandboxHttpClient::new(&server.api_url).expect("HTTP client"); + let token = SandboxBootstrapToken::new("bootstrap-secret").expect("bootstrap token"); + + assert_eq!( + client + .exchange_bootstrap(&token) + .expect("retry gateway response"), + capability() + ); + + let requests = server.finish(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].body, requests[1].body); + assert_eq!(idempotency_key(&requests[0]), idempotency_key(&requests[1])); + } + } + + #[test] + fn bootstrap_gateway_retry_is_bounded_to_two_attempts() { + let server = TestServer::start( + vec![ + TestResponse::Json { + status: "503 Service Unavailable", + body: Vec::new(), + }, + TestResponse::Json { + status: "503 Service Unavailable", + body: Vec::new(), + }, + ], + true, + ); + let client = SandboxHttpClient::new(&server.api_url).expect("HTTP client"); + let token = SandboxBootstrapToken::new("bootstrap-secret").expect("bootstrap token"); + + let error = client + .exchange_bootstrap(&token) + .expect_err("repeated service failure must stop"); + + assert!(matches!( + &error, + SandboxInitError::HttpStatus { + operation: "bootstrap exchange", + status: StatusCode::SERVICE_UNAVAILABLE + } + )); + assert_eq!( + error.to_string(), + "bootstrap exchange returned HTTP 503 Service Unavailable" + ); + let requests = server.finish(); + assert_eq!(requests.len(), BOOTSTRAP_EXCHANGE_ATTEMPTS); + assert_eq!(requests[0].body, requests[1].body); + assert_eq!(idempotency_key(&requests[0]), idempotency_key(&requests[1])); + } + + #[test] + fn bootstrap_idempotency_keys_are_stable_per_token_and_separate_between_tokens() { + let response = serde_json::to_vec(&capability()).expect("serialize capability"); + let server = TestServer::start( + vec![ + TestResponse::Json { + status: "200 OK", + body: response.clone(), + }, + TestResponse::Json { + status: "200 OK", + body: response.clone(), + }, + TestResponse::Json { + status: "200 OK", + body: response, + }, + ], + true, + ); + let token = SandboxBootstrapToken::new("bootstrap-secret").expect("bootstrap token"); + let different_token = SandboxBootstrapToken::new("different-bootstrap-secret") + .expect("different bootstrap token"); + + assert_eq!( + SandboxHttpClient::new(&server.api_url) + .expect("first HTTP client") + .exchange_bootstrap(&token) + .expect("first exchange"), + capability() + ); + assert_eq!( + SandboxHttpClient::new(&server.api_url) + .expect("second HTTP client") + .exchange_bootstrap(&token) + .expect("second exchange"), + capability() + ); + assert_eq!( + SandboxHttpClient::new(&server.api_url) + .expect("third HTTP client") + .exchange_bootstrap(&different_token) + .expect("third exchange"), + capability() + ); + + let requests = server.finish(); + assert_eq!(requests.len(), 3); + let first = idempotency_key(&requests[0]); + let second = idempotency_key(&requests[1]); + let third = idempotency_key(&requests[2]); + assert_valid_idempotency_key(first); + assert_valid_idempotency_key(second); + assert_valid_idempotency_key(third); + assert_eq!(first, second); + assert_ne!(first, third); + assert_eq!( + first, + "fe1fd6a544a78d3a3087bf1517b0ca83b6d122bf1d88d1eddc264e883500bded" + ); + assert_eq!( + third, + "c5ffe95233e3b77d1a6170c672587fa9fdf3f7feccbf2a6e30ba3eb3bcf81b9b" + ); + assert_bootstrap_request(&requests[0], br#"{"bootstrap_token":"bootstrap-secret"}"#); + assert_bootstrap_request(&requests[1], br#"{"bootstrap_token":"bootstrap-secret"}"#); + assert_bootstrap_request( + &requests[2], + br#"{"bootstrap_token":"different-bootstrap-secret"}"#, + ); + } + + #[test] + fn deterministic_bootstrap_errors_do_not_retry_or_leak_the_token() { + let cases = [ + ("400 Bad Request", StatusCode::BAD_REQUEST), + ("401 Unauthorized", StatusCode::UNAUTHORIZED), + ("409 Conflict", StatusCode::CONFLICT), + ("422 Unprocessable Entity", StatusCode::UNPROCESSABLE_ENTITY), + ]; + + for (status_line, expected_status) in cases { + let server = TestServer::start( + vec![TestResponse::Json { + status: status_line, + body: Vec::new(), + }], + true, + ); + let client = SandboxHttpClient::new(&server.api_url).expect("HTTP client"); + let token = SandboxBootstrapToken::new("bootstrap-secret").expect("bootstrap token"); + + let error = client + .exchange_bootstrap(&token) + .expect_err("deterministic response must fail"); + + assert!(matches!( + &error, + SandboxInitError::HttpStatus { + operation: "bootstrap exchange", + status + } if *status == expected_status + )); + assert_eq!( + error.to_string(), + format!("bootstrap exchange returned HTTP {expected_status}") + ); + assert!(!format!("{error:?}").contains("bootstrap-secret")); + assert!(!error.to_string().contains("bootstrap-secret")); + assert_eq!(format!("{token:?}"), "SandboxBootstrapToken()"); + let requests = server.finish(); + assert_eq!(requests.len(), 1); + assert_bootstrap_request(&requests[0], br#"{"bootstrap_token":"bootstrap-secret"}"#); + } + } + + fn capability() -> SessionCapability { + SessionCapability { + session_id: SessionId::new("session-idempotent"), + opaque_capability: "capability-secret".to_string(), + expires_at: "2026-07-20T12:00:00Z".to_string(), + } + } + + fn assert_bootstrap_request(request: &CapturedRequest, expected_body: &[u8]) { + assert_eq!(request.method, "POST"); + assert_eq!(request.path, "/v1/sessions"); + assert_eq!( + request.headers.get("accept").map(String::as_str), + Some(JSON_MEDIA_TYPE) + ); + assert_eq!( + request.headers.get("content-type").map(String::as_str), + Some(JSON_MEDIA_TYPE) + ); + assert_eq!(request.body, expected_body); + assert_valid_idempotency_key(idempotency_key(request)); + } + + fn idempotency_key(request: &CapturedRequest) -> &str { + request + .headers + .get("idempotency-key") + .map(String::as_str) + .expect("idempotency key header") + } + + fn assert_valid_idempotency_key(key: &str) { + assert_eq!(key.len(), 64); + assert!( + key.as_bytes() + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + ); + } + + fn accept_before(listener: &TcpListener, deadline: Instant) -> TcpStream { + loop { + match listener.accept() { + Ok((stream, _)) => return stream, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "request timed out"); + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept request: {error}"), + } + } + } + + fn read_request(stream: &mut TcpStream) -> CapturedRequest { + // Accepted sockets may inherit the listener's nonblocking mode on some + // platforms. Normalize both test-server accept paths before reading. + stream + .set_nonblocking(false) + .expect("set request stream blocking"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set request timeout"); + let mut bytes = Vec::new(); + let header_end = loop { + let mut chunk = [0_u8; 4096]; + let read = stream.read(&mut chunk).expect("read request"); + assert!(read > 0, "request ended before headers"); + bytes.extend_from_slice(&chunk[..read]); + if let Some(position) = find_bytes(&bytes, b"\r\n\r\n") { + break position + 4; + } + }; + let headers_text = std::str::from_utf8(&bytes[..header_end]).expect("headers UTF-8"); + let mut lines = headers_text.split("\r\n"); + let mut request_line = lines.next().expect("request line").split_whitespace(); + let method = request_line.next().expect("method").to_string(); + let path = request_line.next().expect("path").to_string(); + let mut headers = BTreeMap::new(); + for line in lines.filter(|line| !line.is_empty()) { + let (name, value) = line.split_once(':').expect("header delimiter"); + headers.insert(name.to_ascii_lowercase(), value.trim().to_string()); + } + let content_length = headers + .get("content-length") + .map(|value| value.parse::().expect("content length")) + .unwrap_or(0); + while bytes.len() < header_end + content_length { + let mut chunk = [0_u8; 4096]; + let read = stream.read(&mut chunk).expect("read request body"); + assert!(read > 0, "request body ended early"); + bytes.extend_from_slice(&chunk[..read]); + } + CapturedRequest { + method, + path, + headers, + body: bytes[header_end..header_end + content_length].to_vec(), + } + } + + fn write_json_response(stream: &mut TcpStream, status: &str, body: &[u8]) { + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nContent-Type: {JSON_MEDIA_TYPE}\r\nConnection: close\r\n\r\n", + body.len() + ) + .expect("write response head"); + stream.write_all(body).expect("write response body"); + stream.flush().expect("flush response"); + } + + fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) + } +} diff --git a/crates/loc-cli/tests/sandbox.rs b/crates/loc-cli/tests/sandbox.rs index ba2d9ae7..0ee39eef 100644 --- a/crates/loc-cli/tests/sandbox.rs +++ b/crates/loc-cli/tests/sandbox.rs @@ -10,8 +10,8 @@ use std::thread::{self, JoinHandle}; use std::time::Duration; use loc_cli::sandbox::{ - SandboxBootstrapToken, SandboxInitError, SandboxInitOptions, resolve_bootstrap_token, - run_sandbox_init, + SandboxBootstrapToken, SandboxContentEncodingPreference, SandboxInitError, SandboxInitOptions, + resolve_bootstrap_token, run_sandbox_init, run_sandbox_init_with_encoding, }; use locality_core::portable::SessionId; use locality_protocol::{ @@ -281,6 +281,161 @@ fn zstd_bootstrap_streams_into_the_shared_materializer() { ); } +#[test] +fn forced_content_encodings_send_exact_headers_and_match_reports() { + let tar = tar_file(b"forced.txt", b"forced\n"); + let zstd = zstd::stream::encode_all(tar.as_slice(), 1).expect("compress tar"); + let cases = [ + ( + "forced-identity", + SandboxContentEncodingPreference::Identity, + "identity", + tar.clone(), + ), + ( + "forced-zstd", + SandboxContentEncodingPreference::Zstd, + "zstd", + zstd, + ), + ]; + + for (label, preference, expected_encoding, body) in cases { + let directory = TestDirectory::new(label); + let capability = capability(); + let status = ready_status( + capability.session_id.clone(), + COMPONENT_VERSIONS, + &tar, + BTreeSet::from([TarContentEncoding::Identity, TarContentEncoding::Zstd]), + ); + let server = MockServer::start(vec![ + ResponseFixture::json(&capability), + ResponseFixture::json(&status), + ResponseFixture::export(expected_encoding, body), + ]); + + let report = run_sandbox_init_with_encoding( + SandboxInitOptions { + api_url: server.api_url.clone(), + root: directory.root(), + }, + SandboxBootstrapToken::new("bootstrap-secret").expect("token"), + preference, + ) + .expect("bootstrap forced export encoding"); + + assert_eq!(report.content_encoding, expected_encoding, "case {label}"); + assert_eq!( + fs::read(directory.root().join("forced.txt")).expect("read replica"), + b"forced\n", + "case {label}" + ); + let _ = server.request(); + let _ = server.request(); + let export = server.request(); + assert_eq!( + export.headers.get("accept-encoding").unwrap(), + expected_encoding, + "case {label}" + ); + } +} + +#[test] +fn forced_content_encoding_fails_closed_on_offer_or_response_mismatch() { + let tar = tar_file(b"never-published.txt", b"body"); + let offer_capability = capability(); + let identity_only = ready_status( + offer_capability.session_id.clone(), + COMPONENT_VERSIONS, + &tar, + BTreeSet::from([TarContentEncoding::Identity]), + ); + let server = MockServer::start(vec![ + ResponseFixture::json(&offer_capability), + ResponseFixture::json(&identity_only), + ]); + let directory = TestDirectory::new("forced-zstd-not-offered"); + + let error = run_sandbox_init_with_encoding( + SandboxInitOptions { + api_url: server.api_url.clone(), + root: directory.root(), + }, + SandboxBootstrapToken::new("bootstrap-secret").expect("token"), + SandboxContentEncodingPreference::Zstd, + ) + .expect_err("unoffered forced encoding must fail before export"); + + assert_eq!(error.code(), "backend_protocol_invalid"); + assert_eq!( + error.to_string(), + "unsupported sandbox export encoding `zstd`" + ); + assert!(!directory.root().exists()); + let _ = server.request(); + let _ = server.request(); + server.assert_no_request(); + + for (label, preference, response_encoding, requested) in [ + ( + "forced-identity-got-zstd", + SandboxContentEncodingPreference::Identity, + "zstd", + "identity", + ), + ( + "forced-zstd-got-identity", + SandboxContentEncodingPreference::Zstd, + "identity", + "zstd", + ), + ] { + let directory = TestDirectory::new(label); + let capability = capability(); + let status = ready_status( + capability.session_id.clone(), + COMPONENT_VERSIONS, + &tar, + BTreeSet::from([TarContentEncoding::Identity, TarContentEncoding::Zstd]), + ); + let server = MockServer::start(vec![ + ResponseFixture::json(&capability), + ResponseFixture::json(&status), + ResponseFixture::export(response_encoding, tar.clone()), + ]); + + let error = run_sandbox_init_with_encoding( + SandboxInitOptions { + api_url: server.api_url.clone(), + root: directory.root(), + }, + SandboxBootstrapToken::new("bootstrap-secret").expect("token"), + preference, + ) + .expect_err("forced response mismatch must fail before materialization"); + + assert_eq!(error.code(), "backend_protocol_invalid", "case {label}"); + assert_eq!( + error.to_string(), + format!( + "unsupported sandbox export encoding `{response_encoding} (requested {requested})`" + ), + "case {label}" + ); + assert!(!directory.root().exists(), "case {label}"); + let _ = server.request(); + let _ = server.request(); + let export = server.request(); + assert_eq!( + export.headers.get("accept-encoding").unwrap(), + requested, + "case {label}" + ); + } +} + #[test] fn export_receipt_mismatches_roll_back_without_leaking_tokens() { let tar = tar_file(b"never-published.txt", b"body"); @@ -694,7 +849,7 @@ fn token_sources_are_exclusive_trim_only_line_endings_and_redact_debug() { } #[test] -fn cli_environment_token_never_appears_in_output() { +fn cli_forced_identity_reports_encoding_without_leaking_environment_token() { let directory = TestDirectory::new("cli-redaction"); let tar = tar_file(b"visible.txt", b"visible\n"); let capability = capability(); @@ -719,6 +874,8 @@ fn cli_environment_token_never_appears_in_output() { &server.api_url, "--root", &root, + "--encoding", + "identity", "--json", ]) .env("LOCALITY_BOOTSTRAP_TOKEN", "cli-bootstrap-secret") @@ -750,7 +907,8 @@ fn cli_environment_token_never_appears_in_output() { r#"{"bootstrap_token":"cli-bootstrap-secret"}"# ); let _ = server.request(); - let _ = server.request(); + let export = server.request(); + assert_eq!(export.headers.get("accept-encoding").unwrap(), "identity"); } trait StatusFixtureExt { diff --git a/crates/locality-connector/src/lib.rs b/crates/locality-connector/src/lib.rs index 5c5cbc4e..7ae935a0 100644 --- a/crates/locality-connector/src/lib.rs +++ b/crates/locality-connector/src/lib.rs @@ -208,6 +208,29 @@ impl PortableCompleteness { } } +/// Reserved [`SourceObject::edges`] relationship for the canonical explicit +/// scope root that owns a portable source object. +pub const PORTABLE_SCOPE_ROOT_RELATIONSHIP: &str = "locality_scope_root"; + +/// Decode the optional owning-root edge, rejecting ambiguous source objects. +pub fn portable_scope_root_remote_id( + source_object: &SourceObject, +) -> LocalityResult> { + let mut roots = source_object + .edges + .iter() + .filter(|edge| edge.relationship == PORTABLE_SCOPE_ROOT_RELATIONSHIP) + .map(|edge| &edge.target_remote_id); + let root = roots.next(); + if roots.next().is_some() { + return Err(locality_core::LocalityError::InvalidState(format!( + "portable source `{}` returned multiple owning-root edges", + source_object.remote_id.as_str() + ))); + } + Ok(root) +} + /// One provider object discovered by bootstrap or synchronization. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PortableSourceChange { diff --git a/crates/locality-engine/src/synchronize_project.rs b/crates/locality-engine/src/synchronize_project.rs index 4a2f2eeb..e7f7cd69 100644 --- a/crates/locality-engine/src/synchronize_project.rs +++ b/crates/locality-engine/src/synchronize_project.rs @@ -11,6 +11,7 @@ use locality_connector::{ PortableCompleteness, PortableContentArtifact, PortableEnumerateRequest, PortableEnumerateResult, PortableFetchReason, PortableFetchRequest, PortableProjectionArtifact, PortableRenderRequest, PortableSourceChange, PortableSyncRequest, + portable_scope_root_remote_id, }; use locality_core::model::RemoteId; use locality_core::portable::{ @@ -265,6 +266,7 @@ fn validate_changes( changes: &[PortableSourceChange], ) -> LocalityResult<()> { let mut remote_ids = BTreeSet::new(); + let mut provenance_mode = None; for change in changes { if &change.source_object.source_connection_id != source_connection_id { return Err(LocalityError::InvalidState( @@ -277,6 +279,14 @@ fn validate_changes( change.source_object.remote_id.as_str() ))); } + let has_root_provenance = portable_scope_root_remote_id(&change.source_object)?.is_some(); + if provenance_mode.is_some_and(|expected| expected != has_root_provenance) { + return Err(LocalityError::InvalidState( + "portable connector returned a batch with ambiguous owning-root provenance" + .to_string(), + )); + } + provenance_mode.get_or_insert(has_root_provenance); } Ok(()) } diff --git a/crates/locality-engine/tests/synchronize_project.rs b/crates/locality-engine/tests/synchronize_project.rs index d636caeb..3e907453 100644 --- a/crates/locality-engine/tests/synchronize_project.rs +++ b/crates/locality-engine/tests/synchronize_project.rs @@ -3,15 +3,16 @@ use std::collections::{BTreeMap, BTreeSet}; use locality_connector::{ ApplyPlanRequest, ApplyPlanResult, ApplyUndoRequest, ApplyUndoResult, Connector, ConnectorCapabilities, ConnectorKind, EnumerateRequest, FetchRequest, NativeEntity, - ParsedEntity, PortableArtifactKey, PortableBootstrapRequest, PortableChangeBatch, - PortableCheckpoint, PortableCompleteness, PortableContentArtifact, PortableFetchRequest, - PortableFetchResult, PortableIncompleteReason, PortableProjectionArtifact, - PortableRenderRequest, PortableRenderResult, PortableSourceChange, PortableSyncRequest, + PORTABLE_SCOPE_ROOT_RELATIONSHIP, ParsedEntity, PortableArtifactKey, PortableBootstrapRequest, + PortableChangeBatch, PortableCheckpoint, PortableCompleteness, PortableContentArtifact, + PortableFetchRequest, PortableFetchResult, PortableIncompleteReason, + PortableProjectionArtifact, PortableRenderRequest, PortableRenderResult, PortableSourceChange, + PortableSyncRequest, }; use locality_core::LocalityResult; use locality_core::model::{CanonicalDocument, EntityKind, RemoteId, TreeEntry}; use locality_core::portable::{ - LogicalPath, ProjectionFileKind, SourceAction, SourceConnectionId, SourceObject, + LogicalPath, ProjectionFileKind, SourceAction, SourceConnectionId, SourceEdge, SourceObject, }; use locality_engine::synchronize_project::{ bootstrap_and_project, synchronize_and_project_portable, @@ -21,6 +22,8 @@ use locality_engine::synchronize_project::{ struct FixtureConnector { incomplete: bool, duplicate_projection_key: bool, + duplicate_owning_root_edge: bool, + omit_owning_root_edge: bool, } impl FixtureConnector { @@ -28,6 +31,8 @@ impl FixtureConnector { Self { incomplete: false, duplicate_projection_key: false, + duplicate_owning_root_edge: false, + omit_owning_root_edge: false, } } } @@ -53,11 +58,21 @@ impl Connector for FixtureConnector { &self, request: PortableBootstrapRequest, ) -> LocalityResult { + let mut changes = vec![ + change(&request.source_connection_id, "page-b", "B/page.md"), + change(&request.source_connection_id, "page-a", "A/page.md"), + ]; + if self.duplicate_owning_root_edge { + changes[0].source_object.edges.push(SourceEdge { + relationship: PORTABLE_SCOPE_ROOT_RELATIONSHIP.to_string(), + target_remote_id: RemoteId::new("other-root"), + }); + } + if self.omit_owning_root_edge { + changes[1].source_object.edges.clear(); + } Ok(PortableChangeBatch { - changes: vec![ - change(&request.source_connection_id, "page-b", "B/page.md"), - change(&request.source_connection_id, "page-a", "A/page.md"), - ], + changes, next_checkpoint: PortableCheckpoint { format_version: 1, opaque: "ready".to_string(), @@ -176,6 +191,13 @@ fn deterministic_retry_returns_identical_unpersisted_candidates_and_hashes() { first.source_versions[0].canonical_sha256, "sha256:5ebbb50be5c7e33f3fa9ea6c8ae904fa32f8827011e15bbf4947897bdc53e333" ); + assert!(first.observed_changes.iter().all(|change| { + change.source_object.edges + == vec![SourceEdge { + relationship: PORTABLE_SCOPE_ROOT_RELATIONSHIP.to_string(), + target_remote_id: RemoteId::new("root"), + }] + })); } #[test] @@ -183,6 +205,8 @@ fn incomplete_connector_batch_cannot_be_published() { let connector = FixtureConnector { incomplete: true, duplicate_projection_key: false, + duplicate_owning_root_edge: false, + omit_owning_root_edge: false, }; let batch = bootstrap_and_project(&connector, request(), 1).expect("incomplete batch"); @@ -220,6 +244,8 @@ fn conflicting_artifact_keys_fail_the_whole_batch() { let connector = FixtureConnector { incomplete: false, duplicate_projection_key: true, + duplicate_owning_root_edge: false, + omit_owning_root_edge: false, }; let error = bootstrap_and_project(&connector, request(), 1) .expect_err("duplicate artifact identity must fail closed"); @@ -231,6 +257,32 @@ fn conflicting_artifact_keys_fail_the_whole_batch() { ); } +#[test] +fn multiple_owning_root_edges_fail_closed() { + let connector = FixtureConnector { + incomplete: false, + duplicate_projection_key: false, + duplicate_owning_root_edge: true, + omit_owning_root_edge: false, + }; + let error = bootstrap_and_project(&connector, request(), 1) + .expect_err("multiple owning roots must fail closed"); + assert!(error.to_string().contains("multiple owning-root edges")); +} + +#[test] +fn mixed_owning_root_provenance_fails_closed() { + let connector = FixtureConnector { + incomplete: false, + duplicate_projection_key: false, + duplicate_owning_root_edge: false, + omit_owning_root_edge: true, + }; + let error = bootstrap_and_project(&connector, request(), 1) + .expect_err("mixed owning-root provenance must fail closed"); + assert!(error.to_string().contains("ambiguous owning-root")); +} + fn request() -> PortableBootstrapRequest { PortableBootstrapRequest { source_connection_id: SourceConnectionId::new("source-fixture"), @@ -250,7 +302,10 @@ fn change( source_connection_id: source_connection_id.clone(), remote_id: RemoteId::new(remote_id), kind: EntityKind::Page, - edges: Vec::new(), + edges: vec![SourceEdge { + relationship: PORTABLE_SCOPE_ROOT_RELATIONSHIP.to_string(), + target_remote_id: RemoteId::new("root"), + }], opaque_version: Some("v1".to_string()), deleted: false, connector_metadata: BTreeMap::new(), diff --git a/crates/locality-notion/Cargo.toml b/crates/locality-notion/Cargo.toml index 2e918664..e5e63965 100644 --- a/crates/locality-notion/Cargo.toml +++ b/crates/locality-notion/Cargo.toml @@ -11,6 +11,7 @@ name = "locality_notion" path = "src/lib.rs" [dependencies] +base64 = "0.22" locality-core.workspace = true locality-connector.workspace = true mime_guess = "2.0" diff --git a/crates/locality-notion/src/database.rs b/crates/locality-notion/src/database.rs index 976a0e50..60ea0f75 100644 --- a/crates/locality-notion/src/database.rs +++ b/crates/locality-notion/src/database.rs @@ -4,10 +4,16 @@ //! sources provide the row pages and the property schema that `_schema.yaml` //! mirrors for future frontmatter validation and row creation. +use std::collections::BTreeMap; + +use locality_core::LocalityError; use locality_core::LocalityResult; +use serde::Serialize; use crate::client::NotionApi; -use crate::dto::{DataSourceDto, DataSourcePropertyDto, DatabaseDto, SelectOptionDto}; +use crate::dto::{ + DataSourceDto, DataSourcePropertyDto, DatabaseDto, NotionDatabaseBundle, SelectOptionDto, +}; use crate::render::rich_text_plain_text; pub fn database_schema_yaml(api: &dyn NotionApi, database_id: &str) -> LocalityResult { @@ -21,6 +27,201 @@ pub fn database_schema_yaml(api: &dyn NotionApi, database_id: &str) -> LocalityR Ok(render_database_schema(&database, &data_sources)) } +/// Retrieves a database and every data-source schema it declares without search. +/// +/// Notion can spell the same UUID with or without hyphens. Equivalent duplicate +/// summaries are retrieved once and retain their first declared position; +/// conflicting duplicates and responses that do not belong to the requested +/// database fail closed. +pub fn fetch_database_bundle( + api: &dyn NotionApi, + database_id: &str, +) -> LocalityResult { + let requested_database_id = canonical_notion_uuid(database_id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database bundle requires a canonical UUID, got `{database_id}`" + )) + })?; + + let database = api.retrieve_database(database_id)?; + let returned_database_id = canonical_notion_uuid(&database.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database bundle returned a non-canonical database ID `{}`", + database.id + )) + })?; + if returned_database_id != requested_database_id { + return Err(LocalityError::InvalidState(format!( + "Notion database bundle returned database `{}` for requested database `{database_id}`", + database.id + ))); + } + + let mut summaries = Vec::new(); + let mut summary_positions = BTreeMap::::new(); + for summary in &database.data_sources { + if summary.id.trim().is_empty() { + return Err(LocalityError::InvalidState(format!( + "Notion database `{}` contains a data source without an ID", + database.id + ))); + } + let canonical_id = canonical_notion_uuid(&summary.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database `{}` contains non-canonical data source ID `{}`", + database.id, summary.id + )) + })?; + if let Some(position) = summary_positions.get(&canonical_id).copied() { + let existing: &crate::dto::DataSourceSummaryDto = &summaries[position]; + if existing.name != summary.name { + return Err(LocalityError::InvalidState(format!( + "Notion database `{}` contains conflicting summaries for data source `{}`", + database.id, summary.id + ))); + } + continue; + } + summary_positions.insert(canonical_id, summaries.len()); + summaries.push(summary.clone()); + } + + let mut data_sources = Vec::with_capacity(summaries.len()); + for summary in summaries { + let data_source = api.retrieve_data_source(&summary.id)?; + let returned_data_source_id = canonical_notion_uuid(&data_source.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database `{}` returned non-canonical data source ID `{}`", + database.id, data_source.id + )) + })?; + let declared_data_source_id = + canonical_notion_uuid(&summary.id).expect("validated database data-source summary ID"); + if returned_data_source_id != declared_data_source_id { + return Err(LocalityError::InvalidState(format!( + "Notion database `{}` returned data source `{}` for declared data source `{}`", + database.id, data_source.id, summary.id + ))); + } + let parent_database_id = data_source + .parent + .as_ref() + .and_then(|parent| parent.database_id.as_deref()) + .ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion data source `{}` did not expose its parent database", + data_source.id + )) + })?; + let parent_database_id_canonical = canonical_notion_uuid(parent_database_id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion data source `{}` exposed non-canonical parent database ID `{parent_database_id}`", + data_source.id + )) + })?; + if parent_database_id_canonical != returned_database_id { + return Err(LocalityError::InvalidState(format!( + "Notion data source `{}` belongs to database `{parent_database_id}`, not `{}`", + data_source.id, database.id + ))); + } + data_sources.push(data_source); + } + + Ok(NotionDatabaseBundle { + database, + data_sources, + }) +} + +/// Renders the same exact `_schema.yaml` bytes as the direct database path. +pub fn render_database_bundle_schema(bundle: &NotionDatabaseBundle) -> String { + render_database_schema(&bundle.database, &bundle.data_sources) +} + +/// Returns the opaque, deterministic provider-version material for a bundle. +/// +/// A database container's edit time alone does not cover changes to its data +/// source schemas, so the material includes the exact IDs and edit times for +/// both layers in bundle order. +pub fn database_bundle_provider_version(bundle: &NotionDatabaseBundle) -> LocalityResult { + #[derive(Serialize)] + struct ObjectVersion<'a> { + id: &'a str, + last_edited_time: Option<&'a str>, + } + + #[derive(Serialize)] + struct DatabaseVersion<'a> { + format_version: u16, + database: ObjectVersion<'a>, + data_sources: Vec>, + } + + let database_id = canonical_notion_uuid(&bundle.database.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database bundle contains non-canonical database ID `{}`", + bundle.database.id + )) + })?; + let data_source_ids = bundle + .data_sources + .iter() + .map(|data_source| { + canonical_notion_uuid(&data_source.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database bundle contains non-canonical data source ID `{}`", + data_source.id + )) + }) + }) + .collect::>>()?; + let material = DatabaseVersion { + format_version: 1, + database: ObjectVersion { + id: &database_id, + last_edited_time: bundle.database.last_edited_time.as_deref(), + }, + data_sources: bundle + .data_sources + .iter() + .zip(&data_source_ids) + .map(|(data_source, canonical_id)| ObjectVersion { + id: canonical_id, + last_edited_time: data_source.last_edited_time.as_deref(), + }) + .collect(), + }; + + serde_json::to_string(&material).map_err(|error| { + LocalityError::Io(format!( + "Notion database provider version encode failed: {error}" + )) + }) +} + +fn canonical_notion_uuid(value: &str) -> Option { + let bytes = value.as_bytes(); + let valid = match bytes.len() { + 32 => bytes.iter().all(u8::is_ascii_hexdigit), + 36 => bytes.iter().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + *byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }), + _ => false, + }; + valid.then(|| { + value + .bytes() + .filter(|byte| *byte != b'-') + .map(|byte| char::from(byte.to_ascii_lowercase())) + .collect() + }) +} + fn render_database_schema(database: &DatabaseDto, data_sources: &[DataSourceDto]) -> String { let mut out = String::new(); out.push_str("loc:\n"); @@ -117,12 +318,21 @@ fn yaml_string(value: &str) -> String { #[cfg(test)] mod tests { use std::collections::BTreeMap; + use std::sync::Mutex; + + use locality_core::{LocalityError, LocalityResult}; + use crate::client::NotionApi; use crate::dto::{ - DataSourceDto, DataSourcePropertyDto, DatabaseDto, SelectOptionDto, SelectPropertySchemaDto, + BlockDto, BlockListDto, DataSourceDto, DataSourcePropertyDto, DataSourceSummaryDto, + DatabaseDto, PageDto, PageListDto, ParentDto, RichTextDto, SelectOptionDto, + SelectPropertySchemaDto, }; - use super::render_database_schema; + use super::{ + database_bundle_provider_version, fetch_database_bundle, render_database_bundle_schema, + render_database_schema, + }; #[test] fn renders_database_schema_with_select_options() { @@ -153,10 +363,345 @@ mod tests { let schema = render_database_schema(&database, &[data_source]); - assert!(schema.contains("database_id: \"database-1\"")); - assert!(schema.contains("name: \"Tasks\"")); - assert!(schema.contains("\"Status\":")); - assert!(schema.contains("type: \"select\"")); - assert!(schema.contains("name: \"Todo\"")); + assert_eq!( + schema, + concat!( + "loc:\n", + " type: notion_database_schema\n", + " database_id: \"database-1\"\n", + "title: \"\"\n", + "data_sources:\n", + " - id: \"source-1\"\n", + " name: \"Tasks\"\n", + " properties:\n", + " \"Status\":\n", + " id: \"status-id\"\n", + " type: \"select\"\n", + " options:\n", + " - name: \"Todo\"\n", + " id: \"todo-id\"\n", + " color: \"red\"\n", + ) + ); + } + + #[test] + fn database_bundle_is_ordered_deduplicated_and_exactly_versioned() { + let database_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + let database = DatabaseDto { + id: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA".to_string(), + last_edited_time: Some("2026-07-21T01:02:03.000Z".to_string()), + title: vec![plain_text("Roadmap")], + data_sources: vec![ + DataSourceSummaryDto { + id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".to_string(), + name: Some("Tasks".to_string()), + }, + DataSourceSummaryDto { + id: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(), + name: Some("Tasks".to_string()), + }, + DataSourceSummaryDto { + id: "cccccccc-cccc-cccc-cccc-cccccccccccc".to_string(), + name: Some("Archive".to_string()), + }, + ], + ..Default::default() + }; + let api = FixtureApi::new( + database, + [ + ( + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + data_source( + "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "Tasks", + Some("2026-07-21T01:03:00.000Z"), + BTreeMap::from([( + "Status".to_string(), + DataSourcePropertyDto { + id: "status-id".to_string(), + kind: "select".to_string(), + select: Some(SelectPropertySchemaDto { + options: vec![SelectOptionDto { + id: "todo-id".to_string(), + name: "Todo".to_string(), + color: Some("red".to_string()), + }], + }), + ..Default::default() + }, + )]), + ), + ), + ( + "cccccccc-cccc-cccc-cccc-cccccccccccc", + data_source( + "cccccccccccccccccccccccccccccccc", + database_id, + "Archive", + None, + BTreeMap::new(), + ), + ), + ], + ); + + let bundle = fetch_database_bundle(&api, database_id).expect("database bundle"); + + assert_eq!( + api.retrieved_data_sources.into_inner().expect("calls"), + vec![ + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".to_string(), + "cccccccc-cccc-cccc-cccc-cccccccccccc".to_string(), + ] + ); + assert_eq!( + bundle + .data_sources + .iter() + .map(|data_source| data_source.id.as_str()) + .collect::>(), + vec![ + "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + "cccccccccccccccccccccccccccccccc", + ] + ); + assert_eq!( + render_database_bundle_schema(&bundle), + concat!( + "loc:\n", + " type: notion_database_schema\n", + " database_id: \"AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA\"\n", + "title: \"Roadmap\"\n", + "data_sources:\n", + " - id: \"BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB\"\n", + " name: \"Tasks\"\n", + " properties:\n", + " \"Status\":\n", + " id: \"status-id\"\n", + " type: \"select\"\n", + " options:\n", + " - name: \"Todo\"\n", + " id: \"todo-id\"\n", + " color: \"red\"\n", + " - id: \"cccccccccccccccccccccccccccccccc\"\n", + " name: \"Archive\"\n", + " properties: {}\n", + ) + ); + let provider_version = database_bundle_provider_version(&bundle).expect("provider version"); + assert_eq!( + provider_version, + concat!( + "{\"format_version\":1,", + "\"database\":{\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",", + "\"last_edited_time\":\"2026-07-21T01:02:03.000Z\"},", + "\"data_sources\":[", + "{\"id\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",", + "\"last_edited_time\":\"2026-07-21T01:03:00.000Z\"},", + "{\"id\":\"cccccccccccccccccccccccccccccccc\",", + "\"last_edited_time\":null}]}", + ) + ); + + let mut alternate_spelling = bundle.clone(); + alternate_spelling.database.id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(); + alternate_spelling.data_sources[0].id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(); + alternate_spelling.data_sources[1].id = "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC".to_string(); + assert_eq!( + database_bundle_provider_version(&alternate_spelling).expect("alternate version"), + provider_version + ); + } + + #[test] + fn database_bundle_rejects_conflicting_duplicate_summaries() { + let database = DatabaseDto { + id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + data_sources: vec![ + DataSourceSummaryDto { + id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".to_string(), + name: Some("Tasks".to_string()), + }, + DataSourceSummaryDto { + id: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(), + name: Some("Other".to_string()), + }, + ], + ..Default::default() + }; + let api = FixtureApi::new(database, []); + + let error = fetch_database_bundle(&api, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .expect_err("conflicting summary"); + + assert_eq!( + error, + LocalityError::InvalidState( + "Notion database `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa` contains conflicting summaries for data source `BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB`".to_string() + ) + ); + assert!( + api.retrieved_data_sources + .into_inner() + .expect("calls") + .is_empty() + ); + } + + #[test] + fn database_bundle_rejects_data_source_from_another_database() { + let database = DatabaseDto { + id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + data_sources: vec![DataSourceSummaryDto { + id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + name: Some("Tasks".to_string()), + }], + ..Default::default() + }; + let api = FixtureApi::new( + database, + [( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + data_source( + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "cccccccc-cccc-cccc-cccc-cccccccccccc", + "Tasks", + Some("version-1"), + BTreeMap::new(), + ), + )], + ); + + let error = fetch_database_bundle(&api, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .expect_err("foreign data source"); + + assert_eq!( + error, + LocalityError::InvalidState( + "Notion data source `bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb` belongs to database `cccccccc-cccc-cccc-cccc-cccccccccccc`, not `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`".to_string() + ) + ); + } + + #[test] + fn database_bundle_rejects_non_uuid_identity_spelling() { + let api = FixtureApi::new(DatabaseDto::default(), []); + + let error = fetch_database_bundle(&api, "database-1").expect_err("non-UUID database ID"); + + assert_eq!( + error, + LocalityError::InvalidState( + "Notion database bundle requires a canonical UUID, got `database-1`".to_string() + ) + ); + } + + fn plain_text(value: &str) -> RichTextDto { + RichTextDto { + kind: "text".to_string(), + plain_text: value.to_string(), + ..Default::default() + } + } + + fn data_source( + id: &str, + database_id: &str, + name: &str, + last_edited_time: Option<&str>, + properties: BTreeMap, + ) -> DataSourceDto { + DataSourceDto { + id: id.to_string(), + parent: Some(ParentDto { + kind: "database_id".to_string(), + database_id: Some(database_id.to_string()), + ..Default::default() + }), + name: Some(name.to_string()), + last_edited_time: last_edited_time.map(str::to_string), + properties, + ..Default::default() + } + } + + #[derive(Debug)] + struct FixtureApi { + database: DatabaseDto, + data_sources: BTreeMap, + retrieved_data_sources: Mutex>, + } + + impl FixtureApi { + fn new<'a>( + database: DatabaseDto, + data_sources: impl IntoIterator, + ) -> Self { + Self { + database, + data_sources: data_sources + .into_iter() + .map(|(id, data_source)| (id.to_string(), data_source)) + .collect(), + retrieved_data_sources: Mutex::new(Vec::new()), + } + } + } + + impl NotionApi for FixtureApi { + fn retrieve_page(&self, page_id: &str) -> LocalityResult { + Err(LocalityError::RemoteNotFound(page_id.to_string())) + } + + fn retrieve_database(&self, _database_id: &str) -> LocalityResult { + Ok(self.database.clone()) + } + + fn retrieve_data_source(&self, data_source_id: &str) -> LocalityResult { + self.retrieved_data_sources + .lock() + .expect("record data-source call") + .push(data_source_id.to_string()); + self.data_sources + .get(data_source_id) + .cloned() + .ok_or_else(|| LocalityError::RemoteNotFound(data_source_id.to_string())) + } + + fn retrieve_block_children( + &self, + _block_id: &str, + _start_cursor: Option<&str>, + ) -> LocalityResult { + Err(LocalityError::NotImplemented("fixture block children")) + } + + fn search_pages(&self, _start_cursor: Option<&str>) -> LocalityResult { + Err(LocalityError::NotImplemented("fixture page search")) + } + + fn update_block( + &self, + _block_id: &str, + _body: serde_json::Value, + ) -> LocalityResult { + Err(LocalityError::NotImplemented("fixture block update")) + } + + fn append_block_children( + &self, + _block_id: &str, + _body: serde_json::Value, + ) -> LocalityResult { + Err(LocalityError::NotImplemented("fixture block append")) + } + + fn delete_block(&self, _block_id: &str) -> LocalityResult { + Err(LocalityError::NotImplemented("fixture block delete")) + } } } diff --git a/crates/locality-notion/src/dto.rs b/crates/locality-notion/src/dto.rs index 4f5d5f04..1f9b11f3 100644 --- a/crates/locality-notion/src/dto.rs +++ b/crates/locality-notion/src/dto.rs @@ -13,6 +13,176 @@ pub struct NotionPageBundle { pub blocks: Vec, } +/// Versioned native page payload used only by opt-in portable hosted-media capture. +/// +/// `page` contains a sanitized Notion bundle: hosted URLs have no query or +/// fragment, expiries are removed, and uncaptured media URLs are empty. Captured +/// bytes are keyed by stable block identity rather than by signed provider URL. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotionPortablePageBundleV1 { + pub format_version: u16, + pub page: NotionPageBundle, + pub captured_media: Vec, + pub incomplete_media: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotionPortableCapturedMediaV1 { + pub block_id: String, + pub kind: String, + pub media_type: String, + #[serde(with = "portable_media_base64")] + pub bytes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotionPortableIncompleteMediaV1 { + pub block_id: String, + pub kind: String, + pub code: String, +} + +mod portable_media_base64 { + use base64::Engine; + use base64::engine::general_purpose::STANDARD; + use serde::{Deserialize, Deserializer, Serializer}; + + use crate::media::PORTABLE_MEDIA_MAX_ASSET_BYTES; + + pub fn serialize(bytes: &[u8], serializer: S) -> Result + where + S: Serializer, + { + if bytes.len() > PORTABLE_MEDIA_MAX_ASSET_BYTES { + return Err(serde::ser::Error::custom( + "portable media bytes exceed the decoded asset limit", + )); + } + serializer.serialize_str(&STANDARD.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let encoded = String::deserialize(deserializer)?; + let maximum_encoded_len = PORTABLE_MEDIA_MAX_ASSET_BYTES.div_ceil(3) * 4; + if encoded.len() > maximum_encoded_len { + return Err(serde::de::Error::custom( + "portable media base64 exceeds the encoded asset limit", + )); + } + let bytes = STANDARD.decode(&encoded).map_err(|_| { + serde::de::Error::custom("portable media base64 is invalid or non-canonical") + })?; + if bytes.len() > PORTABLE_MEDIA_MAX_ASSET_BYTES || STANDARD.encode(&bytes) != encoded { + return Err(serde::de::Error::custom( + "portable media base64 exceeds the decoded limit or is non-canonical", + )); + } + Ok(bytes) + } +} + +#[cfg(test)] +mod portable_media_tests { + use super::{ + NotionPageBundle, NotionPortableCapturedMediaV1, NotionPortableIncompleteMediaV1, + NotionPortablePageBundleV1, PageDto, + }; + use crate::media::PORTABLE_MEDIA_MAX_ASSET_BYTES; + + #[test] + fn portable_media_base64_roundtrips_near_cap_with_bounded_overhead() { + let decoded_len = PORTABLE_MEDIA_MAX_ASSET_BYTES - 17; + let bytes = vec![0xa5; decoded_len]; + let asset = NotionPortableCapturedMediaV1 { + block_id: "block-1".to_string(), + kind: "file".to_string(), + media_type: "application/octet-stream".to_string(), + bytes: bytes.clone(), + }; + + let encoded = serde_json::to_vec(&asset).expect("near-cap media encode"); + let base64_len = decoded_len.div_ceil(3) * 4; + assert!(encoded.len() <= base64_len + 160); + assert!(encoded.len() < decoded_len * 2); + + let decoded: NotionPortableCapturedMediaV1 = + serde_json::from_slice(&encoded).expect("near-cap media decode"); + assert_eq!(decoded.bytes.len(), decoded_len); + assert!(decoded.bytes == bytes); + } + + #[test] + fn portable_media_base64_rejects_noncanonical_input() { + let error = serde_json::from_str::( + r#"{"block_id":"block-1","kind":"file","media_type":"application/octet-stream","bytes":"Zh=="}"#, + ) + .expect_err("non-canonical trailing bits"); + assert!(error.to_string().contains("non-canonical")); + } + + #[test] + fn portable_media_base64_refuses_to_serialize_oversized_bytes() { + let asset = NotionPortableCapturedMediaV1 { + block_id: "block-1".to_string(), + kind: "file".to_string(), + media_type: "application/octet-stream".to_string(), + bytes: vec![0; PORTABLE_MEDIA_MAX_ASSET_BYTES + 1], + }; + + let error = serde_json::to_vec(&asset).expect_err("oversized media encode"); + assert!(error.to_string().contains("decoded asset limit")); + } + + #[test] + fn portable_media_native_json_is_byte_exact() { + let bundle = NotionPortablePageBundleV1 { + format_version: 1, + page: NotionPageBundle { + page: PageDto { + id: "page-1".to_string(), + parent: None, + created_time: None, + last_edited_time: Some("2026-07-22T01:00:00.000Z".to_string()), + archived: false, + in_trash: false, + properties: Default::default(), + }, + blocks: Vec::new(), + }, + captured_media: vec![NotionPortableCapturedMediaV1 { + block_id: "block-1".to_string(), + kind: "image".to_string(), + media_type: "image/png".to_string(), + bytes: vec![1, 2, 3], + }], + incomplete_media: vec![NotionPortableIncompleteMediaV1 { + block_id: "page-property-file-1".to_string(), + kind: "file_property".to_string(), + code: "unsupported_page_property_media".to_string(), + }], + }; + + assert_eq!( + serde_json::to_vec(&bundle).expect("portable media native JSON"), + br#"{"format_version":1,"page":{"page":{"id":"page-1","parent":null,"created_time":null,"last_edited_time":"2026-07-22T01:00:00.000Z","archived":false,"in_trash":false,"properties":{}},"blocks":[]},"captured_media":[{"block_id":"block-1","kind":"image","media_type":"image/png","bytes":"AQID"}],"incomplete_media":[{"block_id":"page-property-file-1","kind":"file_property","code":"unsupported_page_property_media"}]}"# + ); + } +} + +/// Native database container plus the authoritative schemas for its data sources. +/// +/// The data sources retain the order declared by the database response after +/// equivalent duplicate references have been removed. This makes the bundle a +/// stable input for both `_schema.yaml` rendering and portable synchronization. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotionDatabaseBundle { + pub database: DatabaseDto, + pub data_sources: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BlockTreeDto { pub block: BlockDto, diff --git a/crates/locality-notion/src/fetch.rs b/crates/locality-notion/src/fetch.rs index 71d599b2..f35d125d 100644 --- a/crates/locality-notion/src/fetch.rs +++ b/crates/locality-notion/src/fetch.rs @@ -3,12 +3,20 @@ use locality_core::LocalityResult; use crate::client::NotionApi; -use crate::dto::{BlockTreeDto, NotionPageBundle}; +use crate::dto::{BlockTreeDto, NotionPageBundle, PageDto}; pub fn fetch_page_bundle(api: &dyn NotionApi, page_id: &str) -> LocalityResult { let page = api.retrieve_page(page_id)?; - let blocks = fetch_block_trees(api, page_id)?; + fetch_known_page_bundle(api, page_id, page) +} +/// Fetches block content after the caller has already classified an object as a page. +pub(crate) fn fetch_known_page_bundle( + api: &dyn NotionApi, + page_id: &str, + page: PageDto, +) -> LocalityResult { + let blocks = fetch_block_trees(api, page_id)?; Ok(NotionPageBundle { page, blocks }) } diff --git a/crates/locality-notion/src/lib.rs b/crates/locality-notion/src/lib.rs index bf453945..98e6c1a2 100644 --- a/crates/locality-notion/src/lib.rs +++ b/crates/locality-notion/src/lib.rs @@ -37,9 +37,12 @@ use locality_core::{LocalityError, LocalityResult}; use crate::apply::{apply_plan, apply_undo, check_concurrency}; use crate::client::{DEFAULT_NOTION_TOKEN_ENV, HttpNotionApi, NotionApi}; use crate::fetch::fetch_page_bundle; -use crate::media::{MediaDownloadReport, download_media_assets}; +use crate::media::{ + MediaDownloadReport, PortableMediaCaptureFetcher, PortableMediaCapturePolicy, + download_media_assets, +}; use crate::projection::{ - enumerate_root_page_tree, enumerate_shared_pages, list_container_children, observe_entity, + enumerate_explicit_root_trees, enumerate_shared_pages, list_container_children, observe_entity, resolve_notion_object_path_entries, resolve_page_path_entries, }; use crate::render::{ @@ -102,12 +105,20 @@ impl NotionConfig { pub struct NotionConnector { config: NotionConfig, api: Arc, + explicit_root_page_ids: Vec, + explicit_root_set: bool, + portable_media_capture_policy: PortableMediaCapturePolicy, + portable_media_fetcher: Option>, } impl std::fmt::Debug for NotionConnector { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("NotionConnector") .field("config", &self.config) + .field( + "portable_media_capture_policy", + &self.portable_media_capture_policy, + ) .finish_non_exhaustive() } } @@ -118,7 +129,15 @@ impl NotionConnector { } pub fn with_api(config: NotionConfig, api: Arc) -> Self { - Self { config, api } + let explicit_root_page_ids = config.root_page_id.iter().cloned().collect(); + Self { + config, + api, + explicit_root_page_ids, + explicit_root_set: false, + portable_media_capture_policy: PortableMediaCapturePolicy::Disabled, + portable_media_fetcher: None, + } } pub fn config(&self) -> &NotionConfig { @@ -127,13 +146,69 @@ impl NotionConnector { pub fn with_root_page_id(&self, root_page_id: locality_core::model::RemoteId) -> Self { let mut config = self.config.clone(); - config.root_page_id = Some(root_page_id); + config.root_page_id = Some(root_page_id.clone()); + Self { + config, + api: Arc::clone(&self.api), + explicit_root_page_ids: vec![root_page_id], + explicit_root_set: false, + portable_media_capture_policy: self.portable_media_capture_policy, + portable_media_fetcher: self.portable_media_fetcher.clone(), + } + } + + /// Select up to 16 explicit page or full-page database roots without using + /// provider search. Validation is deferred to enumeration/bootstrap so + /// malformed scopes fail through the normal connector result channel. + pub fn with_root_ids(&self, root_ids: impl IntoIterator) -> Self { + let explicit_root_page_ids = root_ids.into_iter().collect::>(); + let mut config = self.config.clone(); + config.root_page_id = + (explicit_root_page_ids.len() == 1).then(|| explicit_root_page_ids[0].clone()); Self { config, api: Arc::clone(&self.api), + explicit_root_page_ids, + explicit_root_set: true, + portable_media_capture_policy: self.portable_media_capture_policy, + portable_media_fetcher: self.portable_media_fetcher.clone(), } } + /// Compatibility alias for [`Self::with_root_ids`]. + pub fn with_root_page_ids(&self, root_page_ids: impl IntoIterator) -> Self { + self.with_root_ids(root_page_ids) + } + + pub fn explicit_root_page_ids(&self) -> &[RemoteId] { + &self.explicit_root_page_ids + } + + /// Enable or disable the portable hosted-media capture policy. + /// + /// This does not change direct fetch/render or desktop media materialization. + pub fn with_portable_media_capture(&self, policy: PortableMediaCapturePolicy) -> Self { + let mut connector = self.clone(); + connector.portable_media_capture_policy = policy; + connector + } + + /// Inject a deterministic portable media fetcher while retaining the same + /// connector-side URL validation and pilot byte limits. + pub fn with_portable_media_capture_fetcher( + &self, + policy: PortableMediaCapturePolicy, + fetcher: Arc, + ) -> Self { + let mut connector = self.with_portable_media_capture(policy); + connector.portable_media_fetcher = Some(fetcher); + connector + } + + pub fn portable_media_capture_policy(&self) -> PortableMediaCapturePolicy { + self.portable_media_capture_policy + } + pub fn render_native_entity( &self, entity: &NativeEntity, @@ -206,7 +281,15 @@ impl NotionConnector { impl Connector for NotionConnector { fn with_execution_policy(&self, policy: ConnectorExecutionPolicy) -> Self { - Self::new(self.config.clone().with_execution_policy(policy)) + let connector = Self::new(self.config.clone().with_execution_policy(policy)); + let mut connector = if self.explicit_root_set { + connector.with_root_ids(self.explicit_root_page_ids.clone()) + } else { + connector + }; + connector.portable_media_capture_policy = self.portable_media_capture_policy; + connector.portable_media_fetcher = self.portable_media_fetcher.clone(); + connector } fn kind(&self) -> ConnectorKind { @@ -246,8 +329,16 @@ impl Connector for NotionConnector { } fn enumerate(&self, request: EnumerateRequest) -> LocalityResult> { - if let Some(root_page_id) = &self.config.root_page_id { - enumerate_root_page_tree(self.api.as_ref(), request.mount_id, root_page_id) + if self.explicit_root_set || !self.explicit_root_page_ids.is_empty() { + portable::validate_configured_roots(&self.explicit_root_page_ids)?; + Ok(enumerate_explicit_root_trees( + self.api.as_ref(), + request.mount_id, + &self.explicit_root_page_ids, + )? + .into_iter() + .map(|projected| projected.entry) + .collect()) } else { enumerate_shared_pages(self.api.as_ref(), request.mount_id) } @@ -259,7 +350,8 @@ impl Connector for NotionConnector { ) -> LocalityResult { portable::bootstrap( self.api.as_ref(), - self.config.root_page_id.as_ref(), + &self.explicit_root_page_ids, + self.explicit_root_set, request, ) } @@ -267,13 +359,19 @@ impl Connector for NotionConnector { fn sync_portable(&self, request: PortableSyncRequest) -> LocalityResult { portable::synchronize( self.api.as_ref(), - self.config.root_page_id.as_ref(), + &self.explicit_root_page_ids, + self.explicit_root_set, request, ) } fn fetch_portable(&self, request: PortableFetchRequest) -> LocalityResult { - portable::fetch(self.api.as_ref(), request) + portable::fetch( + self.api.as_ref(), + self.portable_media_capture_policy, + self.portable_media_fetcher.as_deref(), + request, + ) } fn render_portable( diff --git a/crates/locality-notion/src/media.rs b/crates/locality-notion/src/media.rs index 45c2310a..59916bc7 100644 --- a/crates/locality-notion/src/media.rs +++ b/crates/locality-notion/src/media.rs @@ -7,13 +7,17 @@ //! Notion page/database names. use std::collections::BTreeMap; +use std::io::Read; use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Mutex}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use locality_core::path_projection::page_container_path; use locality_core::{LocalityError, LocalityResult}; +use reqwest::StatusCode; use reqwest::blocking::Client; +use reqwest::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, LOCATION}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -25,6 +29,382 @@ const MEDIA_MANIFEST: &str = "manifest.json"; const MEDIA_FETCH_ATTEMPTS: usize = 3; const MEDIA_FETCH_RETRY_DELAY: Duration = Duration::from_millis(50); +pub const PORTABLE_MEDIA_MAX_ASSETS: usize = 128; +pub const PORTABLE_MEDIA_MAX_ASSET_BYTES: usize = 20 * 1024 * 1024; +pub const PORTABLE_MEDIA_MAX_AGGREGATE_BYTES: usize = 100 * 1024 * 1024; +const PORTABLE_MEDIA_READ_BUFFER_BYTES: usize = 64 * 1024; +const PORTABLE_MEDIA_MAX_REDIRECTS: usize = 3; +const PORTABLE_MEDIA_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const PORTABLE_MEDIA_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Opt-in hosted-media behavior for portable Notion fetches. +/// +/// The default preserves the existing direct connector behavior. `HostedPilot` +/// captures only Notion-hosted file payloads under the fixed pilot limits above. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PortableMediaCapturePolicy { + #[default] + Disabled, + HostedPilot, +} + +impl PortableMediaCapturePolicy { + pub(crate) fn captures_hosted_media(self) -> bool { + self == Self::HostedPilot + } +} + +/// A bounded hosted-media response returned to portable capture. +/// +/// Custom implementations are intended for deterministic tests. The connector +/// validates the initial provider URL before invoking the fetcher and enforces +/// the same byte limits on every returned body. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PortableMediaCapture { + pub bytes: Vec, + pub media_type: String, +} + +pub trait PortableMediaCaptureFetcher: Send + Sync { + fn fetch(&self, hosted_url: &str, max_bytes: usize) -> LocalityResult; +} + +#[derive(Default)] +struct SecurePortableMediaCaptureFetcher { + transport: Mutex>, +} + +impl PortableMediaCaptureFetcher for SecurePortableMediaCaptureFetcher { + fn fetch(&self, hosted_url: &str, max_bytes: usize) -> LocalityResult { + let mut transport = self.transport.lock().map_err(|_| { + LocalityError::InvalidState("portable media HTTP client lock is poisoned".to_string()) + })?; + if transport.is_none() { + *transport = Some(ReqwestPortableMediaTransport::new()?); + } + fetch_portable_media_with_transport( + transport + .as_ref() + .expect("portable media HTTP client was initialized above"), + hosted_url, + max_bytes, + ) + } +} + +pub(crate) fn default_portable_media_fetcher() -> Arc { + Arc::new(SecurePortableMediaCaptureFetcher::default()) +} + +struct PortableMediaHttpResponse { + status: StatusCode, + location: Option, + content_encoding: Option, + content_length: Option, + content_type: Option, + body: Box, +} + +trait PortableMediaHttpTransport { + fn get(&self, url: &str, timeout: Duration) -> LocalityResult; +} + +struct ReqwestPortableMediaTransport { + client: Client, +} + +impl ReqwestPortableMediaTransport { + fn new() -> LocalityResult { + // Install the crate's selected rustls provider before building a + // separately hardened client. + drop(notion_http_client()); + let client = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(PORTABLE_MEDIA_CONNECT_TIMEOUT) + .timeout(PORTABLE_MEDIA_REQUEST_TIMEOUT) + .build() + .map_err(|_| { + LocalityError::Io("portable media HTTP client initialization failed".to_string()) + })?; + Ok(Self { client }) + } +} + +impl PortableMediaHttpTransport for ReqwestPortableMediaTransport { + fn get(&self, url: &str, timeout: Duration) -> LocalityResult { + let response = self + .client + .get(url) + .header(ACCEPT_ENCODING, "identity") + .timeout(timeout) + .send() + .map_err(|_| LocalityError::Io("portable media request failed".to_string()))?; + let headers = response.headers(); + let location = headers + .get(LOCATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let content_encoding = headers + .get(CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let content_length = headers + .get(CONTENT_LENGTH) + .map(|value| { + value + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| { + LocalityError::InvalidState( + "portable media content length is invalid".to_string(), + ) + }) + }) + .transpose()?; + let content_type = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let status = response.status(); + Ok(PortableMediaHttpResponse { + status, + location, + content_encoding, + content_length, + content_type, + body: Box::new(response), + }) + } +} + +fn fetch_portable_media_with_transport( + transport: &dyn PortableMediaHttpTransport, + hosted_url: &str, + max_bytes: usize, +) -> LocalityResult { + let mut current = validate_portable_hosted_media_url(hosted_url)?; + let mut visited = std::collections::BTreeSet::new(); + let started = Instant::now(); + + for redirects in 0..=PORTABLE_MEDIA_MAX_REDIRECTS { + if !visited.insert(current.as_str().to_string()) { + return Err(LocalityError::InvalidState( + "portable media redirect loop rejected".to_string(), + )); + } + let timeout = PORTABLE_MEDIA_REQUEST_TIMEOUT + .checked_sub(started.elapsed()) + .ok_or_else(|| LocalityError::Io("portable media request timed out".to_string()))?; + let mut response = transport.get(current.as_str(), timeout)?; + + if response.status.is_redirection() { + if redirects == PORTABLE_MEDIA_MAX_REDIRECTS { + return Err(LocalityError::InvalidState( + "portable media redirect limit exceeded".to_string(), + )); + } + let location = response.location.as_deref().ok_or_else(|| { + LocalityError::InvalidState( + "portable media redirect omitted its destination".to_string(), + ) + })?; + let destination = current.join(location).map_err(|_| { + LocalityError::InvalidState( + "portable media redirect destination is invalid".to_string(), + ) + })?; + current = validate_portable_hosted_media_url(destination.as_str())?; + continue; + } + if !response.status.is_success() { + return Err(LocalityError::Io(format!( + "portable media request returned HTTP {}", + response.status.as_u16() + ))); + } + if let Some(encoding) = response.content_encoding.as_deref() + && !encoding.eq_ignore_ascii_case("identity") + { + return Err(LocalityError::InvalidState( + "portable media content encoding is unsupported".to_string(), + )); + } + if response + .content_length + .is_some_and(|length| length > max_bytes as u64) + { + return Err(LocalityError::InvalidState( + "portable media content length exceeds the asset limit".to_string(), + )); + } + + let mut bytes = Vec::with_capacity( + response + .content_length + .and_then(|length| usize::try_from(length).ok()) + .unwrap_or(0) + .min(max_bytes), + ); + let mut buffer = [0_u8; PORTABLE_MEDIA_READ_BUFFER_BYTES]; + loop { + let read = response.body.read(&mut buffer).map_err(|_| { + LocalityError::Io("portable media response body failed".to_string()) + })?; + if read == 0 { + break; + } + if bytes.len().saturating_add(read) > max_bytes { + return Err(LocalityError::InvalidState( + "portable media response exceeded the asset limit".to_string(), + )); + } + bytes.extend_from_slice(&buffer[..read]); + } + if response + .content_length + .is_some_and(|length| length != bytes.len() as u64) + { + return Err(LocalityError::InvalidState( + "portable media content length did not match the response body".to_string(), + )); + } + return Ok(PortableMediaCapture { + bytes, + media_type: sanitize_portable_media_type(response.content_type.as_deref()), + }); + } + + unreachable!("redirect loop always returns or continues within its fixed bound") +} + +pub(crate) fn validate_portable_hosted_media_url(url: &str) -> LocalityResult { + let parsed = reqwest::Url::parse(url) + .map_err(|_| LocalityError::InvalidState("portable media URL is invalid".to_string()))?; + if parsed.scheme() != "https" + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some_and(|port| port != 443) + { + return Err(LocalityError::InvalidState( + "portable media URL violates the HTTPS origin policy".to_string(), + )); + } + let host = parsed.host_str().ok_or_else(|| { + LocalityError::InvalidState("portable media URL has no allowed host".to_string()) + })?; + let allowed = matches!( + host, + "secure.notion-static.com" | "prod-files-secure.s3.us-west-2.amazonaws.com" + ) || (host == "s3.us-west-2.amazonaws.com" + && parsed.path().starts_with("/secure.notion-static.com/")); + if !allowed { + return Err(LocalityError::InvalidState( + "portable media URL host is not allowed".to_string(), + )); + } + Ok(parsed) +} + +pub(crate) fn sanitize_portable_hosted_media_url(url: &str) -> LocalityResult { + let mut parsed = validate_portable_hosted_media_url(url)?; + parsed.set_query(None); + parsed.set_fragment(None); + Ok(parsed.to_string()) +} + +pub(crate) fn portable_media_expired(expiry_time: &str) -> LocalityResult { + let expiry = parse_rfc3339_utc_seconds(expiry_time).ok_or_else(|| { + LocalityError::InvalidState("portable media expiry is invalid".to_string()) + })?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| LocalityError::InvalidState("system clock is before Unix time".to_string()))? + .as_secs(); + Ok(expiry <= now) +} + +fn parse_rfc3339_utc_seconds(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() < 20 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes[10] != b'T' + || bytes[13] != b':' + || bytes[16] != b':' + || *bytes.last()? != b'Z' + { + return None; + } + if bytes.len() > 20 { + let fraction = &bytes[20..bytes.len() - 1]; + if bytes[19] != b'.' || fraction.is_empty() || !fraction.iter().all(u8::is_ascii_digit) { + return None; + } + } + let number = |start: usize, end: usize| -> Option { + std::str::from_utf8(&bytes[start..end]).ok()?.parse().ok() + }; + let year = number(0, 4)?; + let month = number(5, 7)?; + let day = number(8, 10)?; + let hour = number(11, 13)?; + let minute = number(14, 16)?; + let second = number(17, 19)?; + if !(1..=12).contains(&month) + || day < 1 + || day > days_in_month(year, month) + || !(0..=23).contains(&hour) + || !(0..=59).contains(&minute) + || !(0..=59).contains(&second) + { + return None; + } + let days = days_from_civil(year, month, day); + if days < 0 { + return None; + } + u64::try_from(days * 86_400 + hour * 3_600 + minute * 60 + second).ok() +} + +fn days_in_month(year: i64, month: i64) -> i64 { + match month { + 2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let year = year - i64::from(month <= 2); + let era = year.div_euclid(400); + let year_of_era = year - era * 400; + let adjusted_month = month + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * adjusted_month + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + +pub(crate) fn sanitize_portable_media_type(value: Option<&str>) -> String { + let essence = value + .and_then(|value| value.split(';').next()) + .map(str::trim) + .filter(|value| { + let mut parts = value.split('/'); + let valid_part = |part: &str| { + !part.is_empty() + && part.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-') + }) + }; + matches!((parts.next(), parts.next(), parts.next()), (Some(kind), Some(subtype), None) if valid_part(kind) && valid_part(subtype)) + }); + essence + .unwrap_or("application/octet-stream") + .to_ascii_lowercase() +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct MediaAsset { pub block_id: String, @@ -631,11 +1011,18 @@ mod tests { #[cfg(windows)] use super::resolve_media_href_with_content_root; use super::{ - MediaAsset, local_media_href, media_local_path, replace_media_manifest, resolve_media_href, + MediaAsset, PORTABLE_MEDIA_READ_BUFFER_BYTES, PortableMediaHttpResponse, + PortableMediaHttpTransport, fetch_portable_media_with_transport, local_media_href, + media_local_path, portable_media_expired, replace_media_manifest, resolve_media_href, + sanitize_portable_hosted_media_url, validate_portable_hosted_media_url, }; + use reqwest::StatusCode; + use std::collections::VecDeque; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::Path; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; use std::thread; #[test] @@ -785,6 +1172,337 @@ mod tests { assert!(!super::should_download(&relative)); } + #[test] + fn portable_hosted_media_url_policy_is_exact_and_sanitizes_signatures() { + for url in [ + "https://secure.notion-static.com/image.png", + "https://prod-files-secure.s3.us-west-2.amazonaws.com/image.png", + "https://s3.us-west-2.amazonaws.com/secure.notion-static.com/image.png", + "https://secure.notion-static.com:443/image.png", + ] { + validate_portable_hosted_media_url(url) + .unwrap_or_else(|error| panic!("allowed URL {url}: {error}")); + } + for url in [ + "http://secure.notion-static.com/image.png", + "https://127.0.0.1/image.png", + "https://[::1]/image.png", + "https://user@secure.notion-static.com/image.png", + "https://user:pass@secure.notion-static.com/image.png", + "https://secure.notion-static.com:444/image.png", + "https://notion-static.com/image.png", + "https://sub.secure.notion-static.com/image.png", + "https://s3.amazonaws.com/secure.notion-static.com/image.png", + "https://s3.us-west-2.amazonaws.com/other/image.png", + ] { + assert!( + validate_portable_hosted_media_url(url).is_err(), + "denied URL {url}" + ); + } + assert_eq!( + sanitize_portable_hosted_media_url(concat!( + "https://secure.notion-static.com/image.png?", + "X-Amz-Signature=secret&token=secret#fragment" + )) + .expect("sanitize"), + "https://secure.notion-static.com/image.png" + ); + } + + #[test] + fn portable_media_follows_only_three_revalidated_redirects() { + let transport = ScriptedPortableMediaTransport::new([ + scripted_response( + StatusCode::FOUND, + Some("https://prod-files-secure.s3.us-west-2.amazonaws.com/second.png"), + None, + None, + None, + Vec::new(), + ), + scripted_response( + StatusCode::OK, + None, + None, + Some(4), + Some("image/png"), + b"data".to_vec(), + ), + ]); + let captured = fetch_portable_media_with_transport( + &transport, + "https://secure.notion-static.com/first.png?X-Amz-Signature=secret", + 1024, + ) + .expect("allowed redirect"); + assert_eq!(captured.bytes, b"data"); + assert_eq!(captured.media_type, "image/png"); + assert_eq!(transport.requests().len(), 2); + + let disallowed = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::FOUND, + Some("https://example.com/stolen.png?token=secret"), + None, + None, + None, + Vec::new(), + )]); + let error = fetch_portable_media_with_transport( + &disallowed, + "https://secure.notion-static.com/first.png", + 1024, + ) + .expect_err("disallowed redirect"); + assert_eq!( + error.to_string(), + "invalid state: portable media URL host is not allowed" + ); + assert!(!error.to_string().contains("token=secret")); + + let redirect_loop = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::FOUND, + Some("/first.png"), + None, + None, + None, + Vec::new(), + )]); + assert_eq!( + fetch_portable_media_with_transport( + &redirect_loop, + "https://secure.notion-static.com/first.png", + 1024, + ) + .expect_err("redirect loop") + .to_string(), + "invalid state: portable media redirect loop rejected" + ); + + let too_many = ScriptedPortableMediaTransport::new((0..=3).map(|index| { + scripted_response( + StatusCode::FOUND, + Some(&format!( + "https://secure.notion-static.com/redirect-{}.png", + index + 1 + )), + None, + None, + None, + Vec::new(), + ) + })); + assert_eq!( + fetch_portable_media_with_transport( + &too_many, + "https://secure.notion-static.com/start.png", + 1024, + ) + .expect_err("redirect bound") + .to_string(), + "invalid state: portable media redirect limit exceeded" + ); + } + + #[test] + fn portable_media_rejects_encoding_length_and_stream_overflow() { + let encoded = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::OK, + None, + Some("gzip"), + Some(4), + Some("image/png"), + b"data".to_vec(), + )]); + assert_eq!( + portable_transport_error(&encoded, 4), + "invalid state: portable media content encoding is unsupported" + ); + + let declared_oversize = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::OK, + None, + None, + Some(5), + Some("image/png"), + b"data".to_vec(), + )]); + assert_eq!( + portable_transport_error(&declared_oversize, 4), + "invalid state: portable media content length exceeds the asset limit" + ); + + let mismatched = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::OK, + None, + Some("identity"), + Some(3), + Some("image/png"), + b"data".to_vec(), + )]); + assert_eq!( + portable_transport_error(&mismatched, 4), + "invalid state: portable media content length did not match the response body" + ); + + let streamed_oversize = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::OK, + None, + None, + None, + Some("image/png"), + b"12345".to_vec(), + )]); + assert_eq!( + portable_transport_error(&streamed_oversize, 4), + "invalid state: portable media response exceeded the asset limit" + ); + } + + #[test] + fn portable_media_stream_reads_are_bounded_to_64_kib() { + let body = vec![7_u8; PORTABLE_MEDIA_READ_BUFFER_BYTES * 2 + 1]; + let transport = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::OK, + None, + None, + Some(body.len() as u64), + Some("APPLICATION/OCTET-STREAM; charset=binary"), + body.clone(), + )]); + let capture = fetch_portable_media_with_transport( + &transport, + "https://secure.notion-static.com/data.bin", + body.len(), + ) + .expect("bounded stream"); + assert_eq!(capture.bytes, body); + assert_eq!(capture.media_type, "application/octet-stream"); + assert!(transport.max_read_size() <= 64 * 1024); + } + + #[test] + fn portable_media_expiry_is_strict_and_utc() { + assert!(portable_media_expired("2000-01-01T00:00:00.000Z").expect("past")); + assert!(!portable_media_expired("2099-01-01T00:00:00Z").expect("future")); + assert!(portable_media_expired("2099-13-01T00:00:00Z").is_err()); + assert!(portable_media_expired("2099-01-01T00:00:00.Z").is_err()); + assert!(portable_media_expired("2099-01-01T00:00:00+01:00").is_err()); + } + + struct ScriptedPortableMediaTransport { + responses: Mutex>, + requests: Mutex>, + max_read_size: Arc, + } + + impl ScriptedPortableMediaTransport { + fn new(responses: impl IntoIterator) -> Self { + let max_read_size = Arc::new(AtomicUsize::new(0)); + let responses = responses + .into_iter() + .map(|response| PortableMediaHttpResponse { + status: response.status, + location: response.location, + content_encoding: response.content_encoding, + content_length: response.content_length, + content_type: response.content_type, + body: Box::new(TrackingReader { + bytes: std::io::Cursor::new(response.body), + max_read_size: Arc::clone(&max_read_size), + }), + }) + .collect(); + Self { + responses: Mutex::new(responses), + requests: Mutex::new(Vec::new()), + max_read_size, + } + } + + fn requests(&self) -> Vec { + self.requests.lock().expect("requests").clone() + } + + fn max_read_size(&self) -> usize { + self.max_read_size.load(Ordering::SeqCst) + } + } + + impl PortableMediaHttpTransport for ScriptedPortableMediaTransport { + fn get( + &self, + url: &str, + _timeout: std::time::Duration, + ) -> locality_core::LocalityResult { + self.requests + .lock() + .expect("requests") + .push(url.to_string()); + self.responses + .lock() + .expect("responses") + .pop_front() + .ok_or_else(|| { + locality_core::LocalityError::InvalidState( + "scripted transport exhausted".to_string(), + ) + }) + } + } + + struct ScriptedResponse { + status: StatusCode, + location: Option, + content_encoding: Option, + content_length: Option, + content_type: Option, + body: Vec, + } + + fn scripted_response( + status: StatusCode, + location: Option<&str>, + content_encoding: Option<&str>, + content_length: Option, + content_type: Option<&str>, + body: Vec, + ) -> ScriptedResponse { + ScriptedResponse { + status, + location: location.map(str::to_string), + content_encoding: content_encoding.map(str::to_string), + content_length, + content_type: content_type.map(str::to_string), + body, + } + } + + struct TrackingReader { + bytes: std::io::Cursor>, + max_read_size: Arc, + } + + impl Read for TrackingReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + self.max_read_size.fetch_max(buffer.len(), Ordering::SeqCst); + self.bytes.read(buffer) + } + } + + fn portable_transport_error( + transport: &ScriptedPortableMediaTransport, + max_bytes: usize, + ) -> String { + fetch_portable_media_with_transport( + transport, + "https://secure.notion-static.com/image.png", + max_bytes, + ) + .expect_err("transport must reject") + .to_string() + } + #[test] fn media_fetch_retries_transient_connection_failures() { let bytes = b"retry-media-bytes".to_vec(); diff --git a/crates/locality-notion/src/portable.rs b/crates/locality-notion/src/portable.rs index 0b7c3b1d..703916dd 100644 --- a/crates/locality-notion/src/portable.rs +++ b/crates/locality-notion/src/portable.rs @@ -2,31 +2,53 @@ //! //! The provider search endpoint is intentionally absent from this module. A //! search result is not an exhaustive Notion inventory, so portable coverage is -//! available only for a configured root page. +//! available only for configured page or full-page database roots. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; use locality_connector::{ - NativeEntity, PortableArtifactKey, PortableBootstrapRequest, PortableChangeBatch, - PortableCheckpoint, PortableCompleteness, PortableContentArtifact, PortableFetchRequest, - PortableFetchResult, PortableIncompleteReason, PortableProjectionArtifact, - PortableRenderRequest, PortableRenderResult, PortableSourceChange, PortableSyncRequest, + NativeEntity, PORTABLE_SCOPE_ROOT_RELATIONSHIP, PortableArtifactKey, PortableBootstrapRequest, + PortableChangeBatch, PortableCheckpoint, PortableCompleteness, PortableContentArtifact, + PortableFetchRequest, PortableFetchResult, PortableIncompleteReason, + PortableProjectionArtifact, PortableRenderRequest, PortableRenderResult, PortableSourceChange, + PortableSyncRequest, }; use locality_core::canonical::render_canonical_markdown; use locality_core::model::{EntityKind, MountId, RemoteId}; -use locality_core::portable::{LogicalPath, ProjectionFileKind, SourceAction, SourceObject}; +use locality_core::portable::{ + LogicalPath, ProjectionFileKind, SourceAction, SourceEdge, SourceObject, +}; use locality_core::{LocalityError, LocalityResult}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::client::NotionApi; -use crate::dto::{BlockTreeDto, NotionPageBundle}; -use crate::fetch::fetch_page_bundle; -use crate::projection::enumerate_root_page_tree; -use crate::render::render_native_entity; +use crate::database::{ + database_bundle_provider_version, fetch_database_bundle, render_database_bundle_schema, +}; +use crate::dto::{ + BlockDto, BlockTreeDto, FileBlockDto, NotionDatabaseBundle, NotionPageBundle, + NotionPortableCapturedMediaV1, NotionPortableIncompleteMediaV1, NotionPortablePageBundleV1, +}; +use crate::fetch::fetch_known_page_bundle; +use crate::media::{ + PORTABLE_MEDIA_MAX_AGGREGATE_BYTES, PORTABLE_MEDIA_MAX_ASSET_BYTES, PORTABLE_MEDIA_MAX_ASSETS, + PortableMediaCaptureFetcher, PortableMediaCapturePolicy, default_portable_media_fetcher, + portable_media_expired, sanitize_portable_hosted_media_url, sanitize_portable_media_type, +}; +use crate::projection::enumerate_explicit_root_trees; +use crate::render::{RenderOptions, render_native_entity, render_native_entity_with_options}; -const CHECKPOINT_FORMAT_VERSION: u16 = 1; +const LEGACY_CHECKPOINT_FORMAT_VERSION: u16 = 1; +const CHECKPOINT_FORMAT_VERSION: u16 = 2; +const CHECKPOINT_COMPONENT_VERSION: u16 = 2; +const MAX_EXPLICIT_ROOTS: usize = 16; const PORTABLE_FORMAT_VERSION: u32 = 1; +const PORTABLE_MEDIA_NATIVE_FORMAT_VERSION: u16 = 1; +const PORTABLE_MEDIA_NATIVE_KIND: &str = "notion_page_portable_media_v1"; +const PORTABLE_MEDIA_LIMIT_OUTCOME_ID: &str = "__locality_portable_media_limit_v1"; +const PORTABLE_MEDIA_SANITIZED_MARKER: &str = "_locality_portable_media_sanitized_v1"; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -36,7 +58,7 @@ enum CheckpointOperation { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct NotionCheckpoint { +struct LegacyNotionCheckpoint { operation: CheckpointOperation, root_remote_id: String, inventory_sha256: String, @@ -44,19 +66,54 @@ struct NotionCheckpoint { complete: bool, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct NotionCheckpoint { + component_version: u16, + operation: CheckpointOperation, + root_set_sha256: String, + root_remote_ids: Vec, + inventory_sha256: String, + offset: u64, + complete: bool, +} + +enum DecodedCheckpoint { + Legacy(LegacyNotionCheckpoint), + Current(NotionCheckpoint), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CanonicalRootSet { + roots: Vec, + normalized_ids: Vec, + identity: String, +} + pub(crate) fn bootstrap( api: &dyn NotionApi, - configured_root: Option<&RemoteId>, + configured_roots: &[RemoteId], + explicit_root_set: bool, request: PortableBootstrapRequest, ) -> LocalityResult { - let root = validate_explicit_root(configured_root, &request.scope.root_remote_ids)?; - let inventory = inventory(api, &request.source_connection_id, root)?; - let digest = inventory_sha256(&inventory); + let roots = validate_explicit_roots(configured_roots, &request.scope.root_remote_ids)?; + let inventory = inventory( + api, + &request.source_connection_id, + &roots.roots, + explicit_root_set, + )?; + let digest = inventory_sha256(&inventory, explicit_root_set); let offset = match request.checkpoint.as_ref() { Some(checkpoint) => { let checkpoint = decode_checkpoint(checkpoint)?; - validate_checkpoint(&checkpoint, CheckpointOperation::Bootstrap, root, &digest)?; - usize::try_from(checkpoint.offset).map_err(|_| { + validate_checkpoint( + &checkpoint, + CheckpointOperation::Bootstrap, + &roots, + Some(&digest), + explicit_root_set, + )?; + usize::try_from(checkpoint_offset(&checkpoint)).map_err(|_| { LocalityError::InvalidState( "Notion portable checkpoint offset is too large for this host".to_string(), ) @@ -67,37 +124,48 @@ pub(crate) fn bootstrap( page_batch( inventory, - root, + &roots, digest, CheckpointOperation::Bootstrap, offset, request.max_changes, + explicit_root_set, ) } pub(crate) fn synchronize( api: &dyn NotionApi, - configured_root: Option<&RemoteId>, + configured_roots: &[RemoteId], + explicit_root_set: bool, request: PortableSyncRequest, ) -> LocalityResult { - let root = validate_explicit_root(configured_root, &request.scope.root_remote_ids)?; - let inventory = inventory(api, &request.source_connection_id, root)?; - let digest = inventory_sha256(&inventory); + let roots = validate_explicit_roots(configured_roots, &request.scope.root_remote_ids)?; + let inventory = inventory( + api, + &request.source_connection_id, + &roots.roots, + explicit_root_set, + )?; + let digest = inventory_sha256(&inventory, explicit_root_set); let prior = decode_checkpoint(&request.checkpoint)?; - if !notion_ids_equal(&prior.root_remote_id, root.as_str()) { - return Err(LocalityError::InvalidState( - "Notion portable checkpoint belongs to a different explicit root".to_string(), - )); - } + validate_checkpoint( + &prior, + checkpoint_operation(&prior), + &roots, + None, + explicit_root_set, + )?; - let offset = if prior.operation == CheckpointOperation::Synchronize && !prior.complete { - if prior.inventory_sha256 != digest { + let offset = if checkpoint_operation(&prior) == CheckpointOperation::Synchronize + && !checkpoint_complete(&prior) + { + if checkpoint_inventory_sha256(&prior) != digest { return Err(LocalityError::InvalidState( "Notion portable inventory changed while synchronization was being resumed" .to_string(), )); } - usize::try_from(prior.offset).map_err(|_| { + usize::try_from(checkpoint_offset(&prior)).map_err(|_| { LocalityError::InvalidState( "Notion portable checkpoint offset is too large for this host".to_string(), ) @@ -111,22 +179,643 @@ pub(crate) fn synchronize( page_batch( inventory, - root, + &roots, digest, CheckpointOperation::Synchronize, offset, request.max_changes, + explicit_root_set, ) } pub(crate) fn fetch( api: &dyn NotionApi, + media_policy: PortableMediaCapturePolicy, + media_fetcher: Option<&dyn PortableMediaCaptureFetcher>, request: PortableFetchRequest, ) -> LocalityResult { - let bundle = fetch_page_bundle(api, request.remote_id.as_str())?; + match api.retrieve_page(request.remote_id.as_str()) { + Ok(page) => { + let bundle = fetch_known_page_bundle(api, request.remote_id.as_str(), page)?; + if media_policy.captures_hosted_media() { + fetch_portable_media_page_result(bundle, &request.remote_id, media_fetcher) + } else { + fetch_page_result(bundle, &request.remote_id) + } + } + Err(LocalityError::RemoteNotFound(_)) => { + let bundle = fetch_database_bundle(api, request.remote_id.as_str())?; + let provider_version = Some(database_bundle_provider_version(&bundle)?); + let remote_id = RemoteId::new(bundle.database.id.clone()); + let raw = serde_json::to_vec(&bundle).map_err(|error| { + LocalityError::Io(format!("notion database native encode failed: {error}")) + })?; + + Ok(PortableFetchResult { + native: NativeEntity { + remote_id, + kind: "notion_database".to_string(), + raw, + }, + provider_version, + completeness: PortableCompleteness::complete(), + }) + } + Err(error) => Err(error), + } +} + +fn fetch_portable_media_page_result( + mut bundle: NotionPageBundle, + requested_remote_id: &RemoteId, + media_fetcher: Option<&dyn PortableMediaCaptureFetcher>, +) -> LocalityResult { + let provider_version = bundle.page.last_edited_time.clone(); + let remote_id = RemoteId::new(bundle.page.id.clone()); + if !notion_ids_equal(remote_id.as_str(), requested_remote_id.as_str()) { + return Err(LocalityError::InvalidState( + "Notion portable fetch returned a different remote object".to_string(), + )); + } + + let asset_count = portable_media_asset_count(&bundle)?; + let limit_exceeded = asset_count > PORTABLE_MEDIA_MAX_ASSETS; + let default_fetcher = default_portable_media_fetcher(); + let fetcher = media_fetcher.unwrap_or(default_fetcher.as_ref()); + let mut capture = PortableMediaCaptureState::new(fetcher, limit_exceeded); + for tree in &mut bundle.blocks { + capture.capture_tree(tree)?; + } + capture.sanitize_page_file_properties(&mut bundle)?; + if limit_exceeded { + capture.record_incomplete( + PORTABLE_MEDIA_LIMIT_OUTCOME_ID, + "page", + "asset_limit_exceeded", + ); + } + + let completeness = portable_media_completeness(&capture.incomplete); + let portable_bundle = NotionPortablePageBundleV1 { + format_version: PORTABLE_MEDIA_NATIVE_FORMAT_VERSION, + page: bundle, + captured_media: capture.captured, + incomplete_media: capture.incomplete, + }; + let raw = serde_json::to_vec(&portable_bundle) + .map_err(|_| LocalityError::Io("Notion portable media native encode failed".to_string()))?; + + Ok(PortableFetchResult { + native: NativeEntity { + remote_id, + kind: PORTABLE_MEDIA_NATIVE_KIND.to_string(), + raw, + }, + provider_version, + completeness, + }) +} + +struct PortableMediaCaptureState<'a> { + fetcher: &'a dyn PortableMediaCaptureFetcher, + captured: Vec, + incomplete: Vec, + seen_block_ids: BTreeSet, + aggregate_bytes: usize, + limit_exceeded: bool, +} + +impl<'a> PortableMediaCaptureState<'a> { + fn new(fetcher: &'a dyn PortableMediaCaptureFetcher, limit_exceeded: bool) -> Self { + Self { + fetcher, + captured: Vec::new(), + incomplete: Vec::new(), + seen_block_ids: BTreeSet::new(), + aggregate_bytes: 0, + limit_exceeded, + } + } + + fn capture_tree(&mut self, tree: &mut BlockTreeDto) -> LocalityResult<()> { + let block_id = tree.block.id.clone(); + let kind = tree.block.kind.clone(); + validate_exclusive_typed_media_payload(&tree.block)?; + self.sanitize_block_arbitrary_json(&mut tree.block); + if is_media_kind(&kind) { + if !self.seen_block_ids.insert(block_id.clone()) { + return Err(LocalityError::InvalidState( + "Notion portable media contains a duplicate block identity".to_string(), + )); + } + let payload = selected_media_payload_mut(&mut tree.block).ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media block is missing its selected payload".to_string(), + ) + })?; + self.capture_payload(&block_id, &kind, payload)?; + } + for child in &mut tree.children { + self.capture_tree(child)?; + } + Ok(()) + } + + fn sanitize_block_arbitrary_json(&mut self, block: &mut BlockDto) { + for (field, value) in [ + ("tab", block.tab.as_mut()), + ("ai_block", block.ai_block.as_mut()), + ("custom_block", block.custom_block.as_mut()), + ("button", block.button.as_mut()), + ] { + if let Some(value) = value + && sanitize_arbitrary_json_media_secrets(value) + { + mark_sanitized_arbitrary_json(value); + self.record_incomplete( + &format!("{}:json:{field}", block.id), + "arbitrary_json", + "sanitized_embedded_media_secret", + ); + } + } + } + + fn capture_payload( + &mut self, + block_id: &str, + kind: &str, + payload: &mut FileBlockDto, + ) -> LocalityResult<()> { + let external_present = payload.external.is_some(); + if let Some(external) = payload.external.as_mut() { + external.url.clear(); + } + if self.limit_exceeded { + if let Some(hosted) = payload.file.as_mut() { + hosted.url.clear(); + hosted.expiry_time = None; + } + return Ok(()); + } + let Some(hosted) = payload.file.as_mut() else { + self.record_incomplete( + block_id, + kind, + if external_present { + "external_media" + } else { + "missing_file" + }, + ); + return Ok(()); + }; + let original_url = hosted.url.clone(); + let expiry_time = hosted.expiry_time.take(); + hosted.url.clear(); + if external_present { + self.record_incomplete(block_id, kind, "ambiguous_file_source"); + return Ok(()); + } + if original_url.is_empty() { + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); + return Ok(()); + } + let sanitized_url = match sanitize_portable_hosted_media_url(&original_url) { + Ok(url) => url, + Err(_) => { + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); + return Ok(()); + } + }; + if let Some(expiry_time) = expiry_time.as_deref() { + match portable_media_expired(expiry_time) { + Ok(false) => {} + Ok(true) => { + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); + return Ok(()); + } + Err(_) => { + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); + return Ok(()); + } + } + } + let captured = match self + .fetcher + .fetch(&original_url, PORTABLE_MEDIA_MAX_ASSET_BYTES) + { + Ok(captured) => captured, + Err(_) => { + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); + return Ok(()); + } + }; + if captured.bytes.len() > PORTABLE_MEDIA_MAX_ASSET_BYTES { + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); + return Ok(()); + } + let Some(aggregate_bytes) = + checked_portable_media_aggregate(self.aggregate_bytes, captured.bytes.len()) + else { + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); + return Ok(()); + }; + self.aggregate_bytes = aggregate_bytes; + hosted.url = sanitized_url; + self.captured.push(NotionPortableCapturedMediaV1 { + block_id: block_id.to_string(), + kind: kind.to_string(), + media_type: sanitize_portable_media_type(Some(&captured.media_type)), + bytes: captured.bytes, + }); + Ok(()) + } + + fn sanitize_page_file_properties( + &mut self, + bundle: &mut NotionPageBundle, + ) -> LocalityResult<()> { + let mut property_asset_index = 0_usize; + for (property_index, property) in bundle.page.properties.values_mut().enumerate() { + for file in &mut property.files { + property_asset_index += 1; + if let Some(external) = file.external.as_mut() { + external.url.clear(); + } + if let Some(hosted) = file.file.as_mut() { + hosted.url.clear(); + hosted.expiry_time = None; + } + if !self.limit_exceeded { + self.record_incomplete( + &format!("page-property-file-{property_asset_index}"), + "file_property", + "unsupported_page_property_media", + ); + } + } + for (field, value) in [ + ("formula", property.formula.as_mut()), + ("rollup", property.rollup.as_mut()), + ] { + if let Some(value) = value + && sanitize_arbitrary_json_media_secrets(value) + { + mark_sanitized_arbitrary_json(value); + self.record_incomplete( + &format!("page-property-json-{property_index}:{field}"), + "arbitrary_json", + "sanitized_embedded_media_secret", + ); + } + } + } + Ok(()) + } + + fn record_incomplete(&mut self, block_id: &str, kind: &str, code: &str) { + self.incomplete.push(NotionPortableIncompleteMediaV1 { + block_id: block_id.to_string(), + kind: kind.to_string(), + code: code.to_string(), + }); + } +} + +fn portable_media_completeness( + incomplete: &[NotionPortableIncompleteMediaV1], +) -> PortableCompleteness { + let mut completeness = PortableCompleteness::complete(); + for media in incomplete { + completeness.merge(PortableCompleteness::incomplete( + PortableIncompleteReason::ConnectorLimitation { + code: format!("notion_media_{}", media.code), + remote_id: Some(RemoteId::new(media.block_id.clone())), + }, + )); + } + completeness +} + +fn selected_media_payload_mut(block: &mut BlockDto) -> Option<&mut FileBlockDto> { + match block.kind.as_str() { + "image" => block.image.as_mut(), + "video" => block.video.as_mut(), + "file" => block.file.as_mut(), + "pdf" => block.pdf.as_mut(), + "audio" => block.audio.as_mut(), + _ => None, + } +} + +fn is_media_kind(kind: &str) -> bool { + matches!(kind, "image" | "video" | "file" | "pdf" | "audio") +} + +fn portable_media_asset_count(bundle: &NotionPageBundle) -> LocalityResult { + fn count_blocks( + trees: &[BlockTreeDto], + seen_media_ids: &mut BTreeSet, + ) -> LocalityResult { + let mut count = 0_usize; + for tree in trees { + validate_exclusive_typed_media_payload(&tree.block)?; + if is_media_kind(&tree.block.kind) { + if !seen_media_ids.insert(tree.block.id.clone()) { + return Err(LocalityError::InvalidState( + "Notion portable media contains a duplicate block identity".to_string(), + )); + } + count = count.checked_add(1).ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media asset count overflowed".to_string(), + ) + })?; + } + count = count + .checked_add(count_blocks(&tree.children, seen_media_ids)?) + .ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media asset count overflowed".to_string(), + ) + })?; + } + Ok(count) + } + + let mut seen_media_ids = BTreeSet::new(); + bundle.page.properties.values().try_fold( + count_blocks(&bundle.blocks, &mut seen_media_ids)?, + |count, property| { + count.checked_add(property.files.len()).ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media asset count overflowed".to_string(), + ) + }) + }, + ) +} + +fn validate_exclusive_typed_media_payload(block: &BlockDto) -> LocalityResult<()> { + let present = [ + ("image", block.image.is_some()), + ("video", block.video.is_some()), + ("file", block.file.is_some()), + ("pdf", block.pdf.is_some()), + ("audio", block.audio.is_some()), + ]; + let present_count = present.iter().filter(|(_, present)| *present).count(); + if is_media_kind(&block.kind) { + let selected_present = present + .iter() + .any(|(kind, present)| *kind == block.kind && *present); + if !selected_present || present_count != 1 { + return Err(LocalityError::InvalidState( + "Notion portable media block must contain exactly its selected typed payload" + .to_string(), + )); + } + } else if present_count != 0 { + return Err(LocalityError::InvalidState( + "Notion portable non-media block contains a typed media payload".to_string(), + )); + } + Ok(()) +} + +fn sanitize_arbitrary_json_media_secrets(value: &mut serde_json::Value) -> bool { + sanitize_arbitrary_json_media_secrets_in_context(value, false) +} + +fn sanitize_arbitrary_json_media_secrets_in_context( + value: &mut serde_json::Value, + inherited_media_context: bool, +) -> bool { + match value { + serde_json::Value::String(string) => { + if sanitize_portable_hosted_media_url(string).is_ok() + || looks_like_exact_credential_value(string) + || (inherited_media_context && media_url_has_query_or_fragment(string)) + { + string.clear(); + true + } else { + false + } + } + serde_json::Value::Array(values) => { + let mut changed = false; + for value in values { + changed |= sanitize_arbitrary_json_media_secrets_in_context( + value, + inherited_media_context, + ); + } + changed + } + serde_json::Value::Object(object) => { + let media_context = inherited_media_context + || arbitrary_json_object_looks_like_media(object) + || object.values().any(arbitrary_json_contains_provider_url); + let sensitive_keys = object + .keys() + .filter(|key| { + key.as_str() == PORTABLE_MEDIA_SANITIZED_MARKER + || is_unconditional_secret_key(key) + || (media_context && is_media_metadata_key(key)) + }) + .cloned() + .collect::>(); + let mut changed = !sensitive_keys.is_empty(); + for key in sensitive_keys { + object.remove(&key); + } + for child in object.values_mut() { + changed |= sanitize_arbitrary_json_media_secrets_in_context(child, media_context); + } + changed + } + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => { + false + } + } +} + +fn arbitrary_json_contains_provider_url(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::String(value) => sanitize_portable_hosted_media_url(value).is_ok(), + serde_json::Value::Array(values) => values.iter().any(arbitrary_json_contains_provider_url), + serde_json::Value::Object(object) => { + object.values().any(arbitrary_json_contains_provider_url) + } + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => { + false + } + } +} + +fn arbitrary_json_object_looks_like_media( + object: &serde_json::Map, +) -> bool { + object.get("file").is_some_and(serde_json::Value::is_object) + || object + .get("external") + .is_some_and(serde_json::Value::is_object) + || object.contains_key("expiry_time") + || matches!( + object.get("type").and_then(serde_json::Value::as_str), + Some("file" | "files") + ) +} + +fn normalized_json_key(key: &str) -> String { + key.chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() +} + +fn is_unconditional_secret_key(key: &str) -> bool { + let normalized = normalized_json_key(key); + normalized.contains("xamz") + || normalized.contains("signature") + || normalized.contains("credential") + || normalized.contains("authorization") + || normalized.contains("secret") + || normalized == "token" + || normalized.ends_with("token") +} + +fn is_media_metadata_key(key: &str) -> bool { + let normalized = normalized_json_key(key); + normalized == "expirytime" || normalized == "query" || normalized == "fragment" +} + +fn looks_like_exact_credential_value(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + lower.contains("x-amz-signature=") + || lower.contains("x-amz-credential=") + || lower.contains("x-amz-security-token=") + || lower.contains("signature=") + || lower.contains("token=") + || lower.contains("secret=") + || lower.contains("authorization=") + || lower.contains("authorization: bearer ") + || looks_like_bearer_credential(value) +} + +fn looks_like_bearer_credential(value: &str) -> bool { + let Some((scheme, credential)) = value.trim().split_once(' ') else { + return false; + }; + scheme.eq_ignore_ascii_case("bearer") + && credential.len() >= 16 + && credential.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '~') + }) +} + +fn media_url_has_query_or_fragment(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + (lower.starts_with("https://") || lower.starts_with("http://")) + && (lower.contains('?') || lower.contains('#')) +} + +fn mark_sanitized_arbitrary_json(value: &mut serde_json::Value) { + let marker = || { + serde_json::Value::Object(serde_json::Map::from_iter([( + PORTABLE_MEDIA_SANITIZED_MARKER.to_string(), + serde_json::Value::Bool(true), + )])) + }; + match value { + serde_json::Value::Object(object) => { + object.insert( + PORTABLE_MEDIA_SANITIZED_MARKER.to_string(), + serde_json::Value::Bool(true), + ); + } + serde_json::Value::Array(values) => values.push(marker()), + _ => *value = marker(), + } +} + +fn sanitized_marker_count(value: &serde_json::Value) -> usize { + match value { + serde_json::Value::Array(values) => values + .iter() + .map(sanitized_marker_count) + .fold(0_usize, usize::saturating_add), + serde_json::Value::Object(object) => { + usize::from(object.contains_key(PORTABLE_MEDIA_SANITIZED_MARKER)) + + object + .values() + .map(sanitized_marker_count) + .fold(0_usize, usize::saturating_add) + } + _ => 0, + } +} + +fn take_top_sanitized_marker(value: &mut serde_json::Value) -> LocalityResult { + let marker_count = sanitized_marker_count(value); + let has_valid_top_marker = match value { + serde_json::Value::Object(object) => object + .get(PORTABLE_MEDIA_SANITIZED_MARKER) + .is_some_and(|marker| marker == &serde_json::Value::Bool(true)), + serde_json::Value::Array(values) => values.last().is_some_and(|last| { + last.as_object().is_some_and(|object| { + object.len() == 1 + && object.get(PORTABLE_MEDIA_SANITIZED_MARKER) + == Some(&serde_json::Value::Bool(true)) + }) + }), + _ => false, + }; + if marker_count != usize::from(has_valid_top_marker) { + return Err(LocalityError::InvalidState( + "Notion portable arbitrary JSON has an invalid sanitization marker".to_string(), + )); + } + if has_valid_top_marker { + match value { + serde_json::Value::Object(object) => { + object.remove(PORTABLE_MEDIA_SANITIZED_MARKER); + } + serde_json::Value::Array(values) => { + values.pop(); + } + _ => unreachable!("validated marker has a container"), + } + } + Ok(has_valid_top_marker) +} + +fn validate_sanitized_arbitrary_json(value: &serde_json::Value) -> LocalityResult { + let mut sanitized = value.clone(); + let has_marker = take_top_sanitized_marker(&mut sanitized)?; + if sanitize_arbitrary_json_media_secrets(&mut sanitized) { + return Err(LocalityError::InvalidState( + "Notion portable arbitrary JSON retained media credentials".to_string(), + )); + } + Ok(has_marker) +} + +fn checked_portable_media_aggregate(current: usize, next: usize) -> Option { + current + .checked_add(next) + .filter(|total| *total <= PORTABLE_MEDIA_MAX_AGGREGATE_BYTES) +} + +fn fetch_page_result( + bundle: NotionPageBundle, + requested_remote_id: &RemoteId, +) -> LocalityResult { let provider_version = bundle.page.last_edited_time.clone(); let remote_id = RemoteId::new(bundle.page.id.clone()); - if !notion_ids_equal(remote_id.as_str(), request.remote_id.as_str()) { + if !notion_ids_equal(remote_id.as_str(), requested_remote_id.as_str()) { return Err(LocalityError::InvalidState( "Notion portable fetch returned a different remote object".to_string(), )); @@ -151,8 +840,475 @@ pub(crate) fn render(request: &PortableRenderRequest) -> LocalityResult render_page(request), + PORTABLE_MEDIA_NATIVE_KIND => render_portable_media_page(request), + "notion_database" => render_database(request), + kind => Err(LocalityError::InvalidState(format!( + "Notion portable render received unsupported native kind `{kind}`" + ))), + } +} + +struct ExactJsonWriter<'a> { + expected: &'a [u8], + offset: usize, + matches: bool, +} + +impl<'a> ExactJsonWriter<'a> { + fn new(expected: &'a [u8]) -> Self { + Self { + expected, + offset: 0, + matches: true, + } + } + + fn is_exact(&self) -> bool { + self.matches && self.offset == self.expected.len() + } +} + +impl std::io::Write for ExactJsonWriter<'_> { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let Some(end) = self.offset.checked_add(bytes.len()) else { + self.matches = false; + self.offset = usize::MAX; + return Ok(bytes.len()); + }; + if end > self.expected.len() || self.expected[self.offset..end] != *bytes { + self.matches = false; + } + self.offset = end; + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn validate_exact_portable_media_native( + bundle: &NotionPortablePageBundleV1, + raw: &[u8], +) -> LocalityResult<()> { + let mut writer = ExactJsonWriter::new(raw); + serde_json::to_writer(&mut writer, bundle).map_err(|_| { + LocalityError::Io("Notion portable media canonical validation failed".to_string()) + })?; + if !writer.is_exact() { + return Err(LocalityError::InvalidState( + "Notion portable media native payload is not canonical".to_string(), + )); + } + Ok(()) +} + +fn render_portable_media_page( + request: &PortableRenderRequest, +) -> LocalityResult { + let bundle = serde_json::from_slice::(&request.native.raw) + .map_err(|_| LocalityError::Io("Notion portable media native decode failed".to_string()))?; + validate_exact_portable_media_native(&bundle, &request.native.raw)?; + if bundle.format_version != PORTABLE_MEDIA_NATIVE_FORMAT_VERSION { + return Err(LocalityError::InvalidState( + "Notion portable media native format version is unsupported".to_string(), + )); + } + if !notion_ids_equal(&bundle.page.page.id, request.native.remote_id.as_str()) { + return Err(LocalityError::InvalidState( + "Notion portable media page native payload does not match its remote ID".to_string(), + )); + } + validate_portable_media_bundle(&bundle)?; + + let captured_block_ids = bundle + .captured_media + .iter() + .map(|media| media.block_id.clone()) + .collect::>(); + let page_native = NativeEntity { + remote_id: request.native.remote_id.clone(), + kind: "notion_page".to_string(), + raw: serde_json::to_vec(&bundle.page).map_err(|_| { + LocalityError::Io("Notion sanitized page native encode failed".to_string()) + })?, + }; + let rendered = render_native_entity_with_options( + &page_native, + &RenderOptions::with_page_path(Path::new(request.logical_path.as_str())) + .with_local_media_block_ids(captured_block_ids), + )?; + let canonical_bytes = render_canonical_markdown(&rendered.document).into_bytes(); + let canonical = PortableContentArtifact { + artifact_key: artifact_key( + &request.native.remote_id, + "canonical_markdown", + request.format_version, + ), + media_type: "text/markdown; charset=utf-8".to_string(), + body: canonical_bytes.clone(), + }; + let mut projections = vec![PortableProjectionArtifact { + artifact: PortableContentArtifact { + artifact_key: artifact_key( + &request.native.remote_id, + "page_markdown", + request.format_version, + ), + media_type: "text/markdown; charset=utf-8".to_string(), + body: canonical_bytes, + }, + logical_path: request.logical_path.clone(), + file_kind: ProjectionFileKind::Markdown, + format_version: request.format_version, + supported_actions: [SourceAction::Read, SourceAction::Search] + .into_iter() + .collect(), + }]; + + let mut captured_by_block = bundle + .captured_media + .into_iter() + .map(|media| (media.block_id.clone(), media)) + .collect::>(); + let mut projected_paths = BTreeSet::new(); + for rendered_asset in rendered.media_assets { + let Some(captured) = captured_by_block.remove(&rendered_asset.block_id) else { + return Err(LocalityError::InvalidState( + "Notion portable media render produced an uncaptured asset".to_string(), + )); + }; + if captured.kind != rendered_asset.kind { + return Err(LocalityError::InvalidState( + "Notion portable media kind does not match its block".to_string(), + )); + } + let logical_path = rendered_asset + .local_path + .to_str() + .ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media path is not valid UTF-8".to_string(), + ) + })? + .replace('\\', "/"); + if !projected_paths.insert(logical_path.clone()) { + return Err(LocalityError::InvalidState( + "Notion portable media paths collide".to_string(), + )); + } + projections.push(PortableProjectionArtifact { + artifact: PortableContentArtifact { + artifact_key: media_artifact_key( + &request.native.remote_id, + &captured.block_id, + request.format_version, + ), + media_type: captured.media_type, + body: captured.bytes, + }, + logical_path: LogicalPath::new(logical_path).map_err(|_| { + LocalityError::InvalidState( + "Notion portable media path is not a valid logical path".to_string(), + ) + })?, + file_kind: ProjectionFileKind::Binary, + format_version: request.format_version, + supported_actions: [SourceAction::Read, SourceAction::DownloadAttachment] + .into_iter() + .collect(), + }); + } + if !captured_by_block.is_empty() { + return Err(LocalityError::InvalidState( + "Notion portable media native payload contains an unrendered asset".to_string(), + )); + } + + Ok(PortableRenderResult { + canonical, + projections, + completeness: portable_media_completeness(&bundle.incomplete_media), + }) +} + +fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> LocalityResult<()> { + let mut captured = BTreeMap::new(); + for media in &bundle.captured_media { + if captured.insert(media.block_id.clone(), media).is_some() { + return Err(LocalityError::InvalidState( + "Notion portable media native payload has duplicate captures".to_string(), + )); + } + } + let aggregate_bytes = bundle + .captured_media + .iter() + .try_fold(0_usize, |total, media| { + if media.bytes.len() > PORTABLE_MEDIA_MAX_ASSET_BYTES { + return None; + } + total.checked_add(media.bytes.len()) + }) + .filter(|total| *total <= PORTABLE_MEDIA_MAX_AGGREGATE_BYTES) + .ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media native payload exceeds its byte limits".to_string(), + ) + })?; + let _ = aggregate_bytes; + for media in &bundle.captured_media { + if sanitize_portable_media_type(Some(&media.media_type)) != media.media_type { + return Err(LocalityError::InvalidState( + "Notion portable media type is not canonical".to_string(), + )); + } + } + + let mut media_blocks = BTreeMap::new(); + let mut expected_incomplete = BTreeMap::new(); + collect_media_blocks( + &bundle.page.blocks, + &mut media_blocks, + &mut expected_incomplete, + )?; + let asset_count = portable_media_asset_count(&bundle.page)?; + let limit_exceeded = asset_count > PORTABLE_MEDIA_MAX_ASSETS; + if limit_exceeded && !bundle.captured_media.is_empty() { + return Err(LocalityError::InvalidState( + "Notion portable over-limit native payload contains captured media".to_string(), + )); + } + for (block_id, (kind, payload)) in &media_blocks { + if payload + .external + .as_ref() + .is_some_and(|file| !file.url.is_empty()) + || payload + .file + .as_ref() + .is_some_and(|file| file.expiry_time.is_some()) + { + return Err(LocalityError::InvalidState( + "Notion portable media native payload is not sanitized".to_string(), + )); + } + let hosted_url = payload + .file + .as_ref() + .map(|file| file.url.as_str()) + .unwrap_or(""); + if let Some(capture) = captured.get(block_id) { + if payload.external.is_some() || payload.file.is_none() || hosted_url.is_empty() { + return Err(LocalityError::InvalidState( + "Notion portable captured media source is ambiguous".to_string(), + )); + } + let sanitized = sanitize_portable_hosted_media_url(hosted_url).map_err(|_| { + LocalityError::InvalidState( + "Notion portable captured media URL is not allowed".to_string(), + ) + })?; + if sanitized != hosted_url { + return Err(LocalityError::InvalidState( + "Notion portable captured media URL is not sanitized".to_string(), + )); + } + if capture.kind != *kind { + return Err(LocalityError::InvalidState( + "Notion portable captured media kind does not match its block".to_string(), + )); + } + } else { + if !hosted_url.is_empty() { + return Err(LocalityError::InvalidState( + "Notion portable incomplete media retained a remote URL".to_string(), + )); + } + if !limit_exceeded { + let code = match (payload.external.is_some(), payload.file.is_some()) { + (true, true) => "ambiguous_file_source", + (true, false) => "external_media", + (false, true) => "unavailable_hosted_media", + (false, false) => "missing_file", + }; + insert_expected_incomplete(&mut expected_incomplete, block_id, kind, code)?; + } + } + } + for media in &bundle.captured_media { + if !media_blocks.contains_key(&media.block_id) { + return Err(LocalityError::InvalidState( + "Notion portable media capture has no matching block".to_string(), + )); + } + } + + let mut property_asset_index = 0_usize; + for (property_index, property) in bundle.page.page.properties.values().enumerate() { + for file in &property.files { + property_asset_index += 1; + if file + .external + .as_ref() + .is_some_and(|file| !file.url.is_empty()) + || file + .file + .as_ref() + .is_some_and(|file| !file.url.is_empty() || file.expiry_time.is_some()) + { + return Err(LocalityError::InvalidState( + "Notion portable page property media is not sanitized".to_string(), + )); + } + if !limit_exceeded { + insert_expected_incomplete( + &mut expected_incomplete, + &format!("page-property-file-{property_asset_index}"), + "file_property", + "unsupported_page_property_media", + )?; + } + } + for (field, value) in [ + ("formula", property.formula.as_ref()), + ("rollup", property.rollup.as_ref()), + ] { + if let Some(value) = value + && validate_sanitized_arbitrary_json(value)? + { + insert_expected_incomplete( + &mut expected_incomplete, + &format!("page-property-json-{property_index}:{field}"), + "arbitrary_json", + "sanitized_embedded_media_secret", + )?; + } + } + } + + if limit_exceeded { + insert_expected_incomplete( + &mut expected_incomplete, + PORTABLE_MEDIA_LIMIT_OUTCOME_ID, + "page", + "asset_limit_exceeded", + )?; + } + + if captured + .keys() + .any(|block_id| expected_incomplete.contains_key(block_id)) + { + return Err(LocalityError::InvalidState( + "Notion portable media native payload has ambiguous outcomes".to_string(), + )); + } + let mut actual_incomplete = BTreeMap::new(); + for outcome in &bundle.incomplete_media { + if actual_incomplete + .insert( + outcome.block_id.clone(), + (outcome.kind.clone(), outcome.code.clone()), + ) + .is_some() + { + return Err(LocalityError::InvalidState( + "Notion portable media native payload has duplicate incomplete outcomes" + .to_string(), + )); + } + } + if actual_incomplete != expected_incomplete { + return Err(LocalityError::InvalidState( + "Notion portable media native payload has invalid incomplete outcomes".to_string(), + )); + } + Ok(()) +} + +fn insert_expected_incomplete( + expected: &mut BTreeMap, + block_id: &str, + kind: &str, + code: &str, +) -> LocalityResult<()> { + if expected + .insert(block_id.to_string(), (kind.to_string(), code.to_string())) + .is_some() + { + return Err(LocalityError::InvalidState( + "Notion portable media native payload has colliding outcome identities".to_string(), + )); + } + Ok(()) +} + +fn collect_media_blocks<'a>( + trees: &'a [BlockTreeDto], + media: &mut BTreeMap, + expected_incomplete: &mut BTreeMap, +) -> LocalityResult<()> { + for tree in trees { + validate_exclusive_typed_media_payload(&tree.block)?; + for (field, value) in [ + ("tab", tree.block.tab.as_ref()), + ("ai_block", tree.block.ai_block.as_ref()), + ("custom_block", tree.block.custom_block.as_ref()), + ("button", tree.block.button.as_ref()), + ] { + if let Some(value) = value + && validate_sanitized_arbitrary_json(value)? + { + insert_expected_incomplete( + expected_incomplete, + &format!("{}:json:{field}", tree.block.id), + "arbitrary_json", + "sanitized_embedded_media_secret", + )?; + } + } + if is_media_kind(&tree.block.kind) { + let payload = media_payload(&tree.block).ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media block is missing its payload".to_string(), + ) + })?; + if media + .insert(tree.block.id.clone(), (tree.block.kind.clone(), payload)) + .is_some() + { + return Err(LocalityError::InvalidState( + "Notion portable media block identity is duplicated".to_string(), + )); + } + } + collect_media_blocks(&tree.children, media, expected_incomplete)?; + } + Ok(()) +} + +fn media_payload(block: &BlockDto) -> Option<&FileBlockDto> { + match block.kind.as_str() { + "image" => block.image.as_ref(), + "video" => block.video.as_ref(), + "file" => block.file.as_ref(), + "pdf" => block.pdf.as_ref(), + "audio" => block.audio.as_ref(), + _ => None, + } +} + +fn render_page(request: &PortableRenderRequest) -> LocalityResult { let native_bundle = serde_json::from_slice::(&request.native.raw) .map_err(|error| LocalityError::Io(format!("notion native decode failed: {error}")))?; + if !notion_ids_equal(&native_bundle.page.id, request.native.remote_id.as_str()) { + return Err(LocalityError::InvalidState( + "Notion portable page native payload does not match its remote ID".to_string(), + )); + } let contains_media = native_bundle.blocks.iter().any(contains_media_block); let rendered = render_native_entity(&request.native)?; let canonical_bytes = render_canonical_markdown(&rendered.document).into_bytes(); @@ -200,6 +1356,124 @@ pub(crate) fn render(request: &PortableRenderRequest) -> LocalityResult LocalityResult { + let bundle = + serde_json::from_slice::(&request.native.raw).map_err(|error| { + LocalityError::Io(format!("notion database native decode failed: {error}")) + })?; + validate_database_bundle(&bundle, &request.native.remote_id)?; + let body = render_database_bundle_schema(&bundle).into_bytes(); + let canonical = PortableContentArtifact { + artifact_key: database_artifact_key( + &request.native.remote_id, + "canonical_schema", + request.format_version, + )?, + media_type: "application/yaml; charset=utf-8".to_string(), + body: body.clone(), + }; + let projection = PortableProjectionArtifact { + artifact: PortableContentArtifact { + artifact_key: database_artifact_key( + &request.native.remote_id, + "database_schema", + request.format_version, + )?, + media_type: "application/yaml; charset=utf-8".to_string(), + body, + }, + logical_path: request.logical_path.clone(), + file_kind: ProjectionFileKind::Yaml, + format_version: request.format_version, + supported_actions: [SourceAction::Read, SourceAction::Search] + .into_iter() + .collect(), + }; + + Ok(PortableRenderResult { + canonical, + projections: vec![projection], + completeness: PortableCompleteness::complete(), + }) +} + +fn validate_database_bundle( + bundle: &NotionDatabaseBundle, + remote_id: &RemoteId, +) -> LocalityResult<()> { + let database_id = canonical_notion_uuid(&bundle.database.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion portable database payload contains non-canonical database ID `{}`", + bundle.database.id + )) + })?; + let native_remote_id = canonical_notion_uuid(remote_id.as_str()).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion portable database native entity contains non-canonical remote ID `{}`", + remote_id.as_str() + )) + })?; + if database_id != native_remote_id { + return Err(LocalityError::InvalidState( + "Notion portable database native payload does not match its remote ID".to_string(), + )); + } + + let mut declared_data_source_ids = Vec::new(); + for summary in &bundle.database.data_sources { + let data_source_id = canonical_notion_uuid(&summary.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion portable database payload contains non-canonical data source ID `{}`", + summary.id + )) + })?; + if !declared_data_source_ids.contains(&data_source_id) { + declared_data_source_ids.push(data_source_id); + } + } + + let mut fetched_data_source_ids = Vec::with_capacity(bundle.data_sources.len()); + for data_source in &bundle.data_sources { + let data_source_id = canonical_notion_uuid(&data_source.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion portable database payload contains non-canonical data source ID `{}`", + data_source.id + )) + })?; + if fetched_data_source_ids.contains(&data_source_id) { + return Err(LocalityError::InvalidState(format!( + "Notion portable database payload contains duplicate data source `{}`", + data_source.id + ))); + } + let parent_database_id = data_source + .parent + .as_ref() + .and_then(|parent| parent.database_id.as_deref()) + .and_then(canonical_notion_uuid) + .ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion portable data source `{}` does not expose a canonical parent database", + data_source.id + )) + })?; + if parent_database_id != database_id { + return Err(LocalityError::InvalidState(format!( + "Notion portable data source `{}` belongs to a different database", + data_source.id + ))); + } + fetched_data_source_ids.push(data_source_id); + } + + if declared_data_source_ids != fetched_data_source_ids { + return Err(LocalityError::InvalidState( + "Notion portable database payload data sources do not match its summaries".to_string(), + )); + } + Ok(()) +} + fn contains_media_block(tree: &BlockTreeDto) -> bool { matches!( tree.block.kind.as_str(), @@ -207,34 +1481,81 @@ fn contains_media_block(tree: &BlockTreeDto) -> bool { ) || tree.children.iter().any(contains_media_block) } -fn validate_explicit_root<'a>( - configured_root: Option<&'a RemoteId>, +pub(crate) fn validate_configured_roots(configured_roots: &[RemoteId]) -> LocalityResult<()> { + canonical_root_set(configured_roots).map(|_| ()) +} + +fn validate_explicit_roots( + configured_roots: &[RemoteId], requested_roots: &[RemoteId], -) -> LocalityResult<&'a RemoteId> { - let configured_root = configured_root.ok_or(LocalityError::Unsupported( - "Notion portable bootstrap requires a configured root page", - ))?; - if requested_roots.len() != 1 - || !notion_ids_equal(requested_roots[0].as_str(), configured_root.as_str()) - { +) -> LocalityResult { + if configured_roots.is_empty() { + return Err(LocalityError::Unsupported( + "Notion portable bootstrap requires a configured root page or explicit root set", + )); + } + let configured = canonical_root_set(configured_roots)?; + let requested = canonical_root_set(requested_roots)?; + if configured.normalized_ids != requested.normalized_ids { + return Err(LocalityError::InvalidState( + "Notion portable scope must exactly match the configured explicit root set".to_string(), + )); + } + Ok(configured) +} + +fn canonical_root_set(roots: &[RemoteId]) -> LocalityResult { + if roots.is_empty() { return Err(LocalityError::InvalidState( - "Notion portable scope must contain only the configured root page".to_string(), + "Notion explicit root set must not be empty".to_string(), )); } - Ok(configured_root) + if roots.len() > MAX_EXPLICIT_ROOTS { + return Err(LocalityError::InvalidState(format!( + "Notion explicit root set exceeds the limit of {MAX_EXPLICIT_ROOTS}" + ))); + } + let mut canonical = BTreeMap::new(); + for root in roots { + let normalized = normalize_notion_id(root.as_str()); + if normalized.is_empty() { + return Err(LocalityError::InvalidState( + "Notion explicit root IDs must not be empty".to_string(), + )); + } + if canonical.insert(normalized.clone(), root.clone()).is_some() { + return Err(LocalityError::InvalidState(format!( + "Notion explicit root set contains duplicate root `{}`", + root.as_str() + ))); + } + } + let normalized_ids = canonical.keys().cloned().collect::>(); + let mut hasher = Sha256::new(); + for root in &normalized_ids { + hash_field(&mut hasher, root); + } + let identity = format!("sha256:{:x}", hasher.finalize()); + Ok(CanonicalRootSet { + roots: canonical.into_values().collect(), + normalized_ids, + identity, + }) } fn inventory( api: &dyn NotionApi, source_connection_id: &locality_core::portable::SourceConnectionId, - root: &RemoteId, + roots: &[RemoteId], + include_root_provenance: bool, ) -> LocalityResult> { // The sentinel mount identity is consumed inside the legacy traversal and // is never returned in a portable value. - let entries = enumerate_root_page_tree(api, MountId::new("portable-notion"), root)?; + let entries = enumerate_explicit_root_trees(api, MountId::new("portable-notion"), roots)?; let mut changes = entries .into_iter() - .map(|entry| { + .map(|projected| { + let entry = projected.entry; let logical_path = entry .path .iter() @@ -248,12 +1569,17 @@ fn inventory( }) .collect::>>()? .join("/"); + let logical_path = if entry.kind == EntityKind::Database { + format!("{logical_path}/_schema.yaml") + } else { + logical_path + }; let logical_path = LogicalPath::new(logical_path).map_err(|error| { LocalityError::InvalidState(format!( "Notion portable projection produced an invalid logical path: {error}" )) })?; - let requires_fetch = entry.kind == EntityKind::Page; + let requires_fetch = matches!(entry.kind, EntityKind::Page | EntityKind::Database); let mut connector_metadata = BTreeMap::new(); connector_metadata.insert("title".to_string(), entry.title); Ok(PortableSourceChange { @@ -261,7 +1587,15 @@ fn inventory( source_connection_id: source_connection_id.clone(), remote_id: entry.remote_id, kind: entry.kind, - edges: Vec::new(), + edges: include_root_provenance + .then(|| SourceEdge { + relationship: PORTABLE_SCOPE_ROOT_RELATIONSHIP.to_string(), + target_remote_id: RemoteId::new(normalize_notion_id( + projected.scope_root_remote_id.as_str(), + )), + }) + .into_iter() + .collect(), opaque_version: entry.remote_edited_at, deleted: false, connector_metadata, @@ -290,11 +1624,12 @@ fn inventory( fn page_batch( inventory: Vec, - root: &RemoteId, + roots: &CanonicalRootSet, digest: String, operation: CheckpointOperation, offset: usize, max_changes: u32, + explicit_root_set: bool, ) -> LocalityResult { if max_changes == 0 { return Err(LocalityError::InvalidState( @@ -327,17 +1662,30 @@ fn page_batch( )); } - let next_checkpoint = encode_checkpoint(&NotionCheckpoint { - operation, - root_remote_id: root.as_str().to_string(), - inventory_sha256: digest, - offset: u64::try_from(end).map_err(|_| { - LocalityError::InvalidState( - "Notion portable inventory is too large to checkpoint".to_string(), - ) - })?, - complete: end == inventory.len(), + let checkpoint_offset = u64::try_from(end).map_err(|_| { + LocalityError::InvalidState( + "Notion portable inventory is too large to checkpoint".to_string(), + ) })?; + let next_checkpoint = if explicit_root_set { + encode_checkpoint(&NotionCheckpoint { + component_version: CHECKPOINT_COMPONENT_VERSION, + operation, + root_set_sha256: roots.identity.clone(), + root_remote_ids: roots.normalized_ids.clone(), + inventory_sha256: digest, + offset: checkpoint_offset, + complete: end == inventory.len(), + })? + } else { + encode_legacy_checkpoint(&LegacyNotionCheckpoint { + operation, + root_remote_id: roots.roots[0].as_str().to_string(), + inventory_sha256: digest, + offset: checkpoint_offset, + complete: end == inventory.len(), + })? + }; Ok(PortableChangeBatch { changes: inventory[offset..end].to_vec(), @@ -363,7 +1711,35 @@ fn artifact_key(remote_id: &RemoteId, role: &str, format_version: u32) -> Portab )) } -fn inventory_sha256(inventory: &[PortableSourceChange]) -> String { +fn media_artifact_key( + remote_id: &RemoteId, + block_id: &str, + format_version: u32, +) -> PortableArtifactKey { + PortableArtifactKey::new(format!( + "notion:page:{}:block:{}:media:v{format_version}", + normalize_notion_id(remote_id.as_str()), + normalize_notion_id(block_id) + )) +} + +fn database_artifact_key( + remote_id: &RemoteId, + role: &str, + format_version: u32, +) -> LocalityResult { + let canonical_id = canonical_notion_uuid(remote_id.as_str()).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database artifact key requires a canonical remote ID, got `{}`", + remote_id.as_str() + )) + })?; + Ok(PortableArtifactKey::new(format!( + "notion:database:{canonical_id}:{role}:v{format_version}" + ))) +} + +fn inventory_sha256(inventory: &[PortableSourceChange], include_root_provenance: bool) -> String { let mut hasher = Sha256::new(); for change in inventory { hash_field(&mut hasher, change.source_object.remote_id.as_str()); @@ -376,6 +1752,15 @@ fn inventory_sha256(inventory: &[PortableSourceChange]) -> String { .as_deref() .unwrap_or_default(), ); + if include_root_provenance { + let scope_root = change + .source_object + .edges + .iter() + .find(|edge| edge.relationship == PORTABLE_SCOPE_ROOT_RELATIONSHIP) + .expect("explicit-root inventory includes owning-root edge"); + hash_field(&mut hasher, scope_root.target_remote_id.as_str()); + } hash_field( &mut hasher, change @@ -403,35 +1788,106 @@ fn encode_checkpoint(checkpoint: &NotionCheckpoint) -> LocalityResult LocalityResult { - if checkpoint.format_version != CHECKPOINT_FORMAT_VERSION { - return Err(LocalityError::InvalidState(format!( - "Notion portable checkpoint version {} requires an update (supported: {CHECKPOINT_FORMAT_VERSION})", - checkpoint.format_version - ))); - } - serde_json::from_str(&checkpoint.opaque).map_err(|_| { - LocalityError::InvalidState("Notion portable checkpoint is invalid".to_string()) +fn encode_legacy_checkpoint( + checkpoint: &LegacyNotionCheckpoint, +) -> LocalityResult { + let opaque = serde_json::to_string(checkpoint).map_err(|error| { + LocalityError::Io(format!("Notion portable checkpoint encode failed: {error}")) + })?; + Ok(PortableCheckpoint { + format_version: LEGACY_CHECKPOINT_FORMAT_VERSION, + opaque, }) } fn validate_checkpoint( - checkpoint: &NotionCheckpoint, + checkpoint: &DecodedCheckpoint, operation: CheckpointOperation, - root: &RemoteId, - inventory_sha256: &str, + roots: &CanonicalRootSet, + inventory_sha256: Option<&str>, + explicit_root_set: bool, ) -> LocalityResult<()> { - if checkpoint.operation != operation - || !notion_ids_equal(&checkpoint.root_remote_id, root.as_str()) - || checkpoint.inventory_sha256 != inventory_sha256 + if let DecodedCheckpoint::Current(checkpoint) = checkpoint + && checkpoint.component_version > CHECKPOINT_COMPONENT_VERSION { + return Err(LocalityError::InvalidState(format!( + "Notion portable checkpoint component version {} requires an update", + checkpoint.component_version + ))); + } + let matches = match checkpoint { + DecodedCheckpoint::Legacy(checkpoint) => { + !explicit_root_set + && roots.roots.len() == 1 + && checkpoint.operation == operation + && notion_ids_equal(&checkpoint.root_remote_id, roots.roots[0].as_str()) + && inventory_sha256.is_none_or(|digest| checkpoint.inventory_sha256 == digest) + } + DecodedCheckpoint::Current(checkpoint) => { + explicit_root_set + && checkpoint.component_version == CHECKPOINT_COMPONENT_VERSION + && checkpoint.operation == operation + && checkpoint.root_remote_ids == roots.normalized_ids + && checkpoint.root_set_sha256 == roots.identity + && inventory_sha256.is_none_or(|digest| checkpoint.inventory_sha256 == digest) + } + }; + if !matches { return Err(LocalityError::InvalidState( - "Notion portable checkpoint does not match the current inventory".to_string(), + "Notion portable checkpoint does not match the current root set and inventory" + .to_string(), )); } Ok(()) } +fn decode_checkpoint(checkpoint: &PortableCheckpoint) -> LocalityResult { + let decoded = match checkpoint.format_version { + LEGACY_CHECKPOINT_FORMAT_VERSION => { + serde_json::from_str(&checkpoint.opaque).map(DecodedCheckpoint::Legacy) + } + CHECKPOINT_FORMAT_VERSION => { + serde_json::from_str(&checkpoint.opaque).map(DecodedCheckpoint::Current) + } + version => { + return Err(LocalityError::InvalidState(format!( + "Notion portable checkpoint version {version} requires an update (supported: {LEGACY_CHECKPOINT_FORMAT_VERSION}, {CHECKPOINT_FORMAT_VERSION})" + ))); + } + }; + decoded.map_err(|_| { + LocalityError::InvalidState("Notion portable checkpoint is invalid".to_string()) + }) +} + +fn checkpoint_operation(checkpoint: &DecodedCheckpoint) -> CheckpointOperation { + match checkpoint { + DecodedCheckpoint::Legacy(checkpoint) => checkpoint.operation, + DecodedCheckpoint::Current(checkpoint) => checkpoint.operation, + } +} + +fn checkpoint_inventory_sha256(checkpoint: &DecodedCheckpoint) -> &str { + match checkpoint { + DecodedCheckpoint::Legacy(checkpoint) => &checkpoint.inventory_sha256, + DecodedCheckpoint::Current(checkpoint) => &checkpoint.inventory_sha256, + } +} + +fn checkpoint_offset(checkpoint: &DecodedCheckpoint) -> u64 { + match checkpoint { + DecodedCheckpoint::Legacy(checkpoint) => checkpoint.offset, + DecodedCheckpoint::Current(checkpoint) => checkpoint.offset, + } +} + +fn checkpoint_complete(checkpoint: &DecodedCheckpoint) -> bool { + match checkpoint { + DecodedCheckpoint::Legacy(checkpoint) => checkpoint.complete, + DecodedCheckpoint::Current(checkpoint) => checkpoint.complete, + } +} + fn normalize_notion_id(value: &str) -> String { value .chars() @@ -440,6 +1896,85 @@ fn normalize_notion_id(value: &str) -> String { .collect() } +fn canonical_notion_uuid(value: &str) -> Option { + let bytes = value.as_bytes(); + let valid = match bytes.len() { + 32 => bytes.iter().all(u8::is_ascii_hexdigit), + 36 => bytes.iter().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + *byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }), + _ => false, + }; + valid.then(|| normalize_notion_id(value)) +} + fn notion_ids_equal(left: &str, right: &str) -> bool { normalize_notion_id(left) == normalize_notion_id(right) } + +#[cfg(test)] +mod tests { + use super::{ + FileBlockDto, PORTABLE_MEDIA_MAX_AGGREGATE_BYTES, PortableMediaCaptureFetcher, + PortableMediaCaptureState, checked_portable_media_aggregate, + }; + use crate::dto::HostedFileDto; + use crate::media::PortableMediaCapture; + + #[test] + fn portable_media_aggregate_limit_is_exact_and_overflow_safe() { + assert_eq!( + checked_portable_media_aggregate(PORTABLE_MEDIA_MAX_AGGREGATE_BYTES - 1, 1), + Some(PORTABLE_MEDIA_MAX_AGGREGATE_BYTES) + ); + assert_eq!( + checked_portable_media_aggregate(PORTABLE_MEDIA_MAX_AGGREGATE_BYTES - 1, 2), + None + ); + assert_eq!(checked_portable_media_aggregate(usize::MAX, 1), None); + } + + #[test] + fn portable_media_aggregate_overflow_clears_url_and_records_incompleteness() { + struct OneByteFetcher; + + impl PortableMediaCaptureFetcher for OneByteFetcher { + fn fetch( + &self, + _hosted_url: &str, + _max_bytes: usize, + ) -> locality_core::LocalityResult { + Ok(PortableMediaCapture { + bytes: vec![1], + media_type: "image/png".to_string(), + }) + } + } + + let fetcher = OneByteFetcher; + let mut state = PortableMediaCaptureState::new(&fetcher, false); + state.aggregate_bytes = PORTABLE_MEDIA_MAX_AGGREGATE_BYTES; + let mut payload = FileBlockDto { + kind: "file".to_string(), + external: None, + file: Some(HostedFileDto { + url: "https://secure.notion-static.com/image.png?X-Amz-Signature=secret" + .to_string(), + expiry_time: None, + }), + caption: Vec::new(), + }; + + state + .capture_payload("block-1", "image", &mut payload) + .expect("aggregate capture"); + + assert!(state.captured.is_empty()); + assert_eq!(state.incomplete[0].code, "unavailable_hosted_media"); + assert_eq!(payload.file.expect("hosted").url, ""); + } +} diff --git a/crates/locality-notion/src/projection.rs b/crates/locality-notion/src/projection.rs index f57dde90..e6ea254f 100644 --- a/crates/locality-notion/src/projection.rs +++ b/crates/locality-notion/src/projection.rs @@ -20,35 +20,90 @@ use crate::dto::{ }; use crate::render::{page_frontmatter, page_title, rich_text_plain_text}; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ExplicitRootTreeEntry { + pub entry: TreeEntry, + pub scope_root_remote_id: RemoteId, +} + pub fn enumerate_root_page_tree( api: &dyn NotionApi, mount_id: MountId, root_page_id: &RemoteId, ) -> LocalityResult> { - let root_page = api.retrieve_page(root_page_id.as_str())?; + Ok( + enumerate_explicit_root_trees(api, mount_id, std::slice::from_ref(root_page_id))? + .into_iter() + .map(|projected| projected.entry) + .collect(), + ) +} + +pub(crate) fn enumerate_explicit_root_trees( + api: &dyn NotionApi, + mount_id: MountId, + root_page_ids: &[RemoteId], +) -> LocalityResult> { let mut used_paths = BTreeSet::new(); let mut entries = Vec::new(); - let root_title = page_title(&root_page); - let root_path = allocate_page_path(Path::new(""), &root_title, &root_page.id, &mut used_paths); - - entries.push(page_entry( - mount_id.clone(), - &root_page, - root_title, - root_path.clone(), - )); - enumerate_page_children( - api, - &mount_id, - root_page.id.as_str(), - page_child_dir(&root_path), - &mut used_paths, - &mut entries, - )?; + let mut owners = BTreeMap::new(); + let mut root_children = root_page_ids + .iter() + .map(|root_id| retrieve_explicit_root(api, root_id)) + .collect::>>()?; + root_children.sort_by(|left, right| { + explicit_root_identity_key(left.remote_id()) + .cmp(&explicit_root_identity_key(right.remote_id())) + }); + + for projected in allocate_child_paths(Path::new(""), root_children, &mut used_paths) { + let scope_root_remote_id = RemoteId::new(projected.child.remote_id().to_string()); + let mut sink = ExplicitRootSink { + scope_root_remote_id: &scope_root_remote_id, + entries: &mut entries, + owners: &mut owners, + }; + push_projected_tree_entry(api, &mount_id, projected, &mut used_paths, &mut sink)?; + } Ok(entries) } +fn retrieve_explicit_root( + api: &dyn NotionApi, + root_id: &RemoteId, +) -> LocalityResult { + match api.retrieve_page(root_id.as_str()) { + Ok(page) => { + validate_explicit_root_identity(root_id, &page.id, "page")?; + let title = page_title(&page); + Ok(ProjectedChild::Page { page, title }) + } + Err(LocalityError::RemoteNotFound(_)) => { + let database = api.retrieve_database(root_id.as_str())?; + validate_explicit_root_identity(root_id, &database.id, "database")?; + let title = + database_title(&database).unwrap_or_else(|| "Untitled database".to_string()); + Ok(ProjectedChild::Database { database, title }) + } + Err(error) => Err(error), + } +} + +fn validate_explicit_root_identity( + requested: &RemoteId, + returned: &str, + kind: &str, +) -> LocalityResult<()> { + if explicit_root_identity_key(requested.as_str()) != explicit_root_identity_key(returned) { + return Err(LocalityError::InvalidState(format!( + "Notion explicit root request `{}` returned {kind} `{returned}`", + requested.as_str() + ))); + } + Ok(()) +} + pub fn enumerate_shared_pages( api: &dyn NotionApi, mount_id: MountId, @@ -430,21 +485,65 @@ fn push_projected_listing_entry( } } -fn push_projected_tree_entry( +trait TreeEntrySink { + fn push_entry(&mut self, entry: TreeEntry) -> LocalityResult<()>; +} + +impl TreeEntrySink for Vec { + fn push_entry(&mut self, entry: TreeEntry) -> LocalityResult<()> { + self.push(entry); + Ok(()) + } +} + +struct ExplicitRootSink<'a> { + scope_root_remote_id: &'a RemoteId, + entries: &'a mut Vec, + owners: &'a mut BTreeMap, +} + +impl TreeEntrySink for ExplicitRootSink<'_> { + fn push_entry(&mut self, entry: TreeEntry) -> LocalityResult<()> { + let key = explicit_root_identity_key(entry.remote_id.as_str()); + if let Some(existing_owner) = self.owners.insert(key, self.scope_root_remote_id.clone()) { + return Err(LocalityError::InvalidState(format!( + "Notion explicit roots overlap or project object `{}` ambiguously between `{}` and `{}`", + entry.remote_id.as_str(), + existing_owner.as_str(), + self.scope_root_remote_id.as_str() + ))); + } + self.entries.push(ExplicitRootTreeEntry { + entry, + scope_root_remote_id: self.scope_root_remote_id.clone(), + }); + Ok(()) + } +} + +fn explicit_root_identity_key(value: &str) -> String { + value + .chars() + .filter(|character| *character != '-') + .flat_map(char::to_lowercase) + .collect() +} + +fn push_projected_tree_entry( api: &dyn NotionApi, mount_id: &MountId, projected: ProjectedChildWithPath, used_paths: &mut BTreeSet, - entries: &mut Vec, + entries: &mut S, ) -> LocalityResult<()> { match projected.child { ProjectedChild::Page { page, title } => { - entries.push(page_entry( + entries.push_entry(page_entry( mount_id.clone(), &page, title, projected.path.clone(), - )); + ))?; enumerate_page_children( api, mount_id, @@ -455,12 +554,12 @@ fn push_projected_tree_entry( )?; } ProjectedChild::Database { database, title } => { - entries.push(database_entry( + entries.push_entry(database_entry( mount_id.clone(), &database, title, projected.path.clone(), - )); + ))?; enumerate_database_rows( api, mount_id, @@ -737,13 +836,13 @@ fn list_database_rows( Ok(entries) } -fn enumerate_page_children( +fn enumerate_page_children( api: &dyn NotionApi, mount_id: &MountId, block_id: &str, parent_dir: PathBuf, used_paths: &mut BTreeSet, - entries: &mut Vec, + entries: &mut S, ) -> LocalityResult<()> { let children = collect_page_child_projections(api, block_id)?; for projected in allocate_child_paths(&parent_dir, children, used_paths) { @@ -753,23 +852,23 @@ fn enumerate_page_children( Ok(()) } -fn enumerate_database_rows( +fn enumerate_database_rows( api: &dyn NotionApi, mount_id: &MountId, database: &DatabaseDto, database_dir: &Path, used_paths: &mut BTreeSet, - entries: &mut Vec, + entries: &mut S, ) -> LocalityResult<()> { let rows = collect_database_row_projections(api, database)?; for projected in allocate_child_paths(database_dir, rows, used_paths) { if let ProjectedChild::Page { page, title } = projected.child { - entries.push(page_entry( + entries.push_entry(page_entry( mount_id.clone(), &page, title, projected.path.clone(), - )); + ))?; enumerate_page_children( api, mount_id, @@ -1416,8 +1515,8 @@ mod tests { use std::path::Path; use super::{ - ProjectedChild, allocate_child_paths, allocate_page_path, projected_title_stem, - resolve_notion_object_path_entries, resolve_page_path_entries, + ProjectedChild, allocate_child_paths, allocate_page_path, enumerate_explicit_root_trees, + projected_title_stem, resolve_notion_object_path_entries, resolve_page_path_entries, }; use locality_core::model::{EntityKind, MountId, RemoteId}; use locality_core::path_projection::PAGE_DOCUMENT_FILENAME; @@ -1793,6 +1892,54 @@ mod tests { )); } + #[test] + fn explicit_root_rejects_page_identity_mismatch() { + let mut api = FakeNotionApi::new(); + api.pages.insert( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + page("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + ); + + let error = enumerate_explicit_root_trees( + &api, + MountId::new("notion-main"), + &[RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")], + ) + .expect_err("mismatched page identity"); + + assert_eq!( + error, + LocalityError::InvalidState( + "Notion explicit root request `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa` returned page `bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`" + .to_string() + ) + ); + } + + #[test] + fn explicit_root_rejects_database_identity_mismatch() { + let mut api = FakeNotionApi::new(); + api.databases.insert( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + database("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + ); + + let error = enumerate_explicit_root_trees( + &api, + MountId::new("notion-main"), + &[RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")], + ) + .expect_err("mismatched database identity"); + + assert_eq!( + error, + LocalityError::InvalidState( + "Notion explicit root request `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa` returned database `bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`" + .to_string() + ) + ); + } + fn page(id: &str) -> PageDto { PageDto { id: id.to_string(), diff --git a/crates/locality-notion/tests/fetch_render.rs b/crates/locality-notion/tests/fetch_render.rs index 654ad59c..18baedf3 100644 --- a/crates/locality-notion/tests/fetch_render.rs +++ b/crates/locality-notion/tests/fetch_render.rs @@ -1,15 +1,20 @@ use std::collections::BTreeMap; use std::path::Path; use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; use locality_connector::{ - ChildContainer, Connector, EnumerateRequest, FetchRequest, ListChildrenRequest, NativeEntity, - PortableBootstrapRequest, PortableFetchReason, PortableFetchRequest, PortableIncompleteReason, - PortableRenderRequest, PortableSourceScope, PortableSyncRequest, + ChildContainer, Connector, ConnectorExecutionPolicy, EnumerateRequest, FetchRequest, + ListChildrenRequest, NativeEntity, PORTABLE_SCOPE_ROOT_RELATIONSHIP, PortableBootstrapRequest, + PortableFetchReason, PortableFetchRequest, PortableIncompleteReason, PortableRenderRequest, + PortableSourceScope, PortableSyncRequest, }; use locality_core::canonical::render_canonical_markdown; use locality_core::model::{EntityKind, MountId, RemoteId}; -use locality_core::portable::{LogicalPath, SourceConnectionId}; +use locality_core::portable::{ + LogicalPath, ProjectionFileKind, SourceAction, SourceConnectionId, SourceEdge, +}; use locality_core::shadow::MarkdownBlockKind; use locality_notion::client::NotionApi; use locality_notion::dto::{ @@ -17,11 +22,16 @@ use locality_notion::dto::{ DataSourceSummaryDto, DatabaseDto, DatabaseListDto, DateMentionDto, EmptyBlockDto, EquationBlockDto, EquationRichTextDto, ExternalFileDto, FileBlockDto, FilePropertyDto, HostedFileDto, IdRefDto, LinkDto, LinkToPageBlockDto, MeetingNotesBlockDto, MentionRichTextDto, - PageDto, PageListDto, PagePropertyDto, PaginatedListDto, ParentDto, RichTextAnnotationsDto, + NotionDatabaseBundle, NotionPortableIncompleteMediaV1, NotionPortablePageBundleV1, PageDto, + PageListDto, PagePropertyDto, PaginatedListDto, ParentDto, RichTextAnnotationsDto, RichTextBlockDto, RichTextDto, SelectOptionDto, SelectPropertySchemaDto, SyncedBlockDto, SyncedFromDto, TableBlockDto, TableRowBlockDto, TextRichTextDto, TitleBlockDto, UniqueIdPropertyDto, UrlBlockDto, VerificationPropertyDto, }; +use locality_notion::media::{ + PORTABLE_MEDIA_MAX_ASSET_BYTES, PortableMediaCapture, PortableMediaCaptureFetcher, + PortableMediaCapturePolicy, +}; use locality_notion::{NotionConfig, NotionConnector}; use serde_json::json; @@ -2017,7 +2027,7 @@ fn enumerate_projects_root_page_tree_to_stable_paths() { } #[test] -fn portable_bootstrap_resumes_and_never_claims_unsupported_database_coverage() { +fn portable_bootstrap_resumes_and_completes_database_coverage() { let root_page_id = RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); let connector = NotionConnector::with_api( NotionConfig::default().with_root_page_id(root_page_id.clone()), @@ -2052,19 +2062,14 @@ fn portable_bootstrap_resumes_and_never_claims_unsupported_database_coverage() { }) .expect("resumed checkpoint"); assert_eq!(second.changes.len(), 2); - assert!(!second.completeness.is_complete()); + assert!(second.completeness.is_complete()); assert!( !second .completeness .incomplete_reasons() .contains(&PortableIncompleteReason::CheckpointContinuation) ); - assert!(second.completeness.incomplete_reasons().iter().any( - |reason| matches!(reason, PortableIncompleteReason::UnsupportedSourceKind { - source_kind, - .. - } if source_kind == "database") - )); + assert!(second.completeness.incomplete_reasons().is_empty()); let synchronized = connector .sync_portable(PortableSyncRequest { @@ -2078,7 +2083,7 @@ fn portable_bootstrap_resumes_and_never_claims_unsupported_database_coverage() { }) .expect("scheduled explicit-root synchronization"); assert_eq!(synchronized.changes.len(), 4); - assert!(!synchronized.completeness.is_complete()); + assert!(synchronized.completeness.is_complete()); } #[test] @@ -2135,6 +2140,358 @@ fn portable_bootstrap_requires_the_configured_explicit_root_without_search_fallb assert!(error.to_string().contains("configured root page")); } +#[test] +fn explicit_multi_root_paths_match_workspace_projection_and_are_order_invariant() { + let first_root = RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let second_root = RemoteId::new("ffffffffffffffffffffffffffffffff"); + let workspace = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(FixtureNotionApi::multi_root_workspace()), + ); + let workspace_entries = workspace + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect("workspace enumerate"); + + let explicit = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::multi_root_workspace())), + ) + .with_root_ids([second_root.clone(), first_root.clone()]); + let explicit_entries = explicit + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect("explicit enumerate"); + let paths = |entries: Vec| { + entries + .into_iter() + .map(|entry| (entry.remote_id, entry.path)) + .collect::>() + }; + assert_eq!(paths(explicit_entries), paths(workspace_entries)); + + let request = |roots| PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots(roots), + checkpoint: None, + max_changes: 2, + }; + let first = explicit + .bootstrap_portable(request([first_root.clone(), second_root.clone()])) + .expect("multi-root bootstrap"); + let reversed = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::multi_root_workspace())), + ) + .with_root_ids([first_root.clone(), second_root.clone()]) + .bootstrap_portable(request([second_root, first_root])) + .expect("reversed multi-root bootstrap"); + + assert_eq!(first, reversed); + assert_eq!(first.next_checkpoint.format_version, 2); + assert_eq!(first.changes.len(), 2, "max_changes is aggregate"); + assert!(first.changes.iter().all(|change| { + change.source_object.edges.len() == 1 + && change.source_object.edges[0].relationship == PORTABLE_SCOPE_ROOT_RELATIONSHIP + })); + assert_eq!( + first + .changes + .iter() + .map(|change| ( + change.source_object.remote_id.as_str(), + change + .source_object + .edges + .first() + .expect("owning-root edge") + .target_remote_id + .as_str(), + change.logical_path.as_ref().expect("logical path").as_str(), + )) + .collect::>(), + vec![ + ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "Roadmap aaaaaa/page.md", + ), + ( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "Roadmap aaaaaa/Notes/page.md", + ), + ] + ); + let checkpoint: serde_json::Value = + serde_json::from_str(&first.next_checkpoint.opaque).expect("v2 checkpoint json"); + assert_eq!(checkpoint["component_version"], 2); + assert_eq!( + checkpoint["root_remote_ids"], + json!([ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "ffffffffffffffffffffffffffffffff" + ]) + ); + + let one_root = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::multi_root_workspace())), + ) + .with_root_ids([RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]); + let mismatch = one_root + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([RemoteId::new( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + )]), + checkpoint: Some(first.next_checkpoint.clone()), + max_changes: 2, + }) + .expect_err("checkpoint root set must match exactly"); + assert!(mismatch.to_string().contains("does not match")); + + let mut newer_checkpoint = first.next_checkpoint; + let mut newer_opaque: serde_json::Value = + serde_json::from_str(&newer_checkpoint.opaque).expect("v2 checkpoint json"); + newer_opaque["component_version"] = json!(3); + newer_checkpoint.opaque = serde_json::to_string(&newer_opaque).expect("checkpoint json"); + let newer_error = explicit + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([ + RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + RemoteId::new("ffffffffffffffffffffffffffffffff"), + ]), + checkpoint: Some(newer_checkpoint), + max_changes: 2, + }) + .expect_err("newer component version must fail cleanly"); + assert!(newer_error.to_string().contains("requires an update")); +} + +#[test] +fn explicit_multi_root_rejects_overlap_duplicates_empty_and_scope_mismatch() { + let first_root = RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let nested_root = RemoteId::new("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + let overlapping = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::multi_root_workspace())), + ) + .with_root_ids([first_root.clone(), nested_root]); + let overlap_error = overlapping + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect_err("nested roots must be rejected"); + assert!(overlap_error.to_string().contains("overlap")); + + let duplicate = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::new())), + ) + .with_root_ids([RemoteId::new("page-1"), RemoteId::new("PAGE1")]); + assert!( + duplicate + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect_err("duplicates must fail") + .to_string() + .contains("duplicate") + ); + + let empty = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::new())), + ) + .with_root_ids([]); + assert!( + empty + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect_err("empty set must fail") + .to_string() + .contains("must not be empty") + ); + + let mismatch = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::multi_root_workspace())), + ) + .with_root_ids([first_root]); + assert!( + mismatch + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([RemoteId::new( + "ffffffffffffffffffffffffffffffff", + )]), + checkpoint: None, + max_changes: 10, + }) + .expect_err("scope mismatch must fail") + .to_string() + .contains("exactly match") + ); + + let sixteen_ids = (0..16) + .map(|index| RemoteId::new(format!("root-{index:02}"))) + .collect::>(); + let sixteen = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::many_roots(16))), + ) + .with_root_ids(sixteen_ids.clone()); + assert_eq!( + sixteen + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect("sixteen roots are supported") + .len(), + 16 + ); + let seventeen = + sixteen.with_root_ids(sixteen_ids.into_iter().chain([RemoteId::new("root-16")])); + assert_eq!( + seventeen + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect_err("seventeen roots exceed the bound") + .to_string(), + "invalid state: Notion explicit root set exceeds the limit of 16" + ); +} + +#[test] +fn explicit_database_root_matches_workspace_projection_without_search() { + let database_id = RemoteId::new("root-db"); + let workspace = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(FixtureNotionApi::workspace()), + ); + let workspace_database = workspace + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect("workspace enumerate") + .into_iter() + .find(|entry| entry.remote_id == database_id) + .expect("workspace database root"); + + let explicit = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::workspace())), + ) + .with_root_ids([database_id.clone()]); + let entries = explicit + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect("explicit database enumerate"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].kind, EntityKind::Database); + assert_eq!(entries[0].path, workspace_database.path); + assert_eq!(entries[0].path, Path::new("Tasks")); + + let batch = explicit + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([database_id.clone()]), + checkpoint: None, + max_changes: 10, + }) + .expect("explicit database bootstrap"); + assert_eq!(batch.changes.len(), 1); + assert_eq!(batch.changes[0].source_object.remote_id, database_id); + assert_eq!( + batch.changes[0].source_object.edges, + vec![SourceEdge { + relationship: PORTABLE_SCOPE_ROOT_RELATIONSHIP.to_string(), + target_remote_id: RemoteId::new("rootdb"), + }] + ); + assert!(batch.changes[0].requires_fetch); + assert_eq!( + batch.changes[0] + .logical_path + .as_ref() + .expect("database schema path") + .as_str(), + "Tasks/_schema.yaml" + ); + assert!(batch.completeness.is_complete()); +} + +#[test] +fn explicit_root_page_non_not_found_error_does_not_fall_back_to_database() { + let connector = + NotionConnector::with_api(NotionConfig::default(), Arc::new(NonNotFoundPageErrorApi)) + .with_root_ids([RemoteId::new("database-id")]); + let error = connector + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect_err("non-not-found page failure must be preserved"); + assert_eq!( + error.to_string(), + "invalid state: injected page retrieval failure" + ); +} + +#[test] +fn legacy_single_root_keeps_v1_checkpoint_and_v2_set_mode_rejects_it() { + let root = RemoteId::new("page-1"); + let legacy = NotionConnector::with_api( + NotionConfig::default().with_root_page_id(root.clone()), + Arc::new(NoSearchNotionApi(FixtureNotionApi::new())), + ); + let legacy_batch = legacy + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([root.clone()]), + checkpoint: None, + max_changes: 10, + }) + .expect("legacy bootstrap"); + assert_eq!(legacy_batch.next_checkpoint.format_version, 1); + assert_eq!( + legacy_batch.next_checkpoint.opaque, + "{\"operation\":\"bootstrap\",\"root_remote_id\":\"page-1\",\"inventory_sha256\":\"sha256:a870998d440a30dccfac26d79f3b2345e1bbec1aa1bcdb26e3682ac92680b3dc\",\"offset\":1,\"complete\":true}" + ); + assert!(legacy_batch.changes[0].source_object.edges.is_empty()); + + let set_mode = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::new())), + ) + .with_root_page_ids([root.clone()]); + let error = set_mode + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([root]), + checkpoint: Some(legacy_batch.next_checkpoint), + max_changes: 10, + }) + .expect_err("set mode must not accept legacy checkpoints"); + assert!(error.to_string().contains("does not match")); +} + #[test] fn portable_render_matches_direct_canonical_bytes_and_keys_survive_rename() { let root_page_id = RemoteId::new("page-1"); @@ -2188,81 +2545,1460 @@ fn portable_render_matches_direct_canonical_bytes_and_keys_survive_rename() { } #[test] -fn portable_render_marks_media_incomplete_instead_of_silently_omitting_it() { - let bundle = locality_notion::dto::NotionPageBundle { - page: page("page-media", "Media"), - blocks: vec![BlockTreeDto { - block: file_block("image-1", "image", "https://example.com/image.png", "Image"), - children: Vec::new(), - }], - }; - let connector = NotionConnector::with_api( - NotionConfig::default().with_root_page_id(RemoteId::new("page-media")), - Arc::new(NoSearchNotionApi(FixtureNotionApi::new())), - ); - let rendered = connector - .render_portable(&PortableRenderRequest { - source_connection_id: SourceConnectionId::new("source-notion"), - logical_path: LogicalPath::new("Media/page.md").expect("path"), - native: NativeEntity { - remote_id: RemoteId::new("page-media"), - kind: "notion_page".to_string(), - raw: serde_json::to_vec(&bundle).expect("native fixture"), - }, - format_version: 1, - }) - .expect("render media page"); - - assert!(!rendered.completeness.is_complete()); - assert!(rendered.completeness.incomplete_reasons().iter().any( - |reason| matches!(reason, PortableIncompleteReason::UnsupportedArtifact { - artifact_kind, - .. - } if artifact_kind == "notion_media") - )); -} - -#[test] -fn enumerate_suffixes_every_colliding_sibling_name() { +fn portable_database_root_fetches_and_renders_exact_shared_schema_projection() { let root_page_id = RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); - let api = FixtureNotionApi::colliding_tree(root_page_id.as_str()); + let compact_database_id = "cccccccccccccccccccccccccccccccc"; + let provider_database_id = "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"; + let compact_data_source_id = "dddddddddddddddddddddddddddddddd"; + let provider_data_source_id = "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"; + let mut api = FixtureNotionApi::tree(root_page_id.as_str()); + let mut database = api + .databases + .get(compact_database_id) + .cloned() + .expect("database fixture"); + database.id = provider_database_id.to_string(); + api.databases + .insert(compact_database_id.to_string(), database.clone()); + api.databases + .insert(provider_database_id.to_string(), database.clone()); + let data_source = api + .data_sources + .get_mut(compact_data_source_id) + .expect("data-source fixture"); + data_source.id = provider_data_source_id.to_string(); + data_source.parent = Some(ParentDto { + kind: "database_id".to_string(), + database_id: Some(provider_database_id.to_string()), + ..Default::default() + }); + let expected_bundle = NotionDatabaseBundle { + database: database.clone(), + data_sources: vec![data_source.clone()], + }; let connector = NotionConnector::with_api( - NotionConfig::default().with_root_page_id(root_page_id), - Arc::new(api), + NotionConfig::default().with_root_page_id(root_page_id.clone()), + Arc::new(NoSearchNotionApi(api)), ); - let entries = connector + let direct_entries = connector .enumerate(EnumerateRequest { mount_id: MountId::new("notion-main"), cursor: None, }) - .expect("enumerate"); + .expect("direct enumerate"); + let direct_database = direct_entries + .iter() + .find(|entry| entry.kind == EntityKind::Database) + .expect("direct database"); + let direct_row = direct_entries + .iter() + .find(|entry| entry.remote_id == RemoteId::new("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")) + .expect("direct row"); - assert_eq!(entries.len(), 5); - assert_eq!(entries[0].path, Path::new("Root/page.md")); - assert_eq!(entries[1].path, Path::new("Root/Notes bbbbbb/page.md")); - assert_eq!(entries[2].path, Path::new("Root/Notes cccccc/page.md")); - assert_eq!(entries[3].path, Path::new("Root/Notes dddddd")); + let batch = connector + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([root_page_id]), + checkpoint: None, + max_changes: 10, + }) + .expect("portable bootstrap"); + assert!(batch.completeness.is_complete()); + let database_change = batch + .changes + .iter() + .find(|change| change.source_object.kind == EntityKind::Database) + .expect("portable database change"); + let row_change = batch + .changes + .iter() + .find(|change| { + change.source_object.remote_id == RemoteId::new("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + }) + .expect("portable row change"); + let portable_path = |path: &Path| { + path.iter() + .map(|component| component.to_str().expect("UTF-8 direct path component")) + .collect::>() + .join("/") + }; + assert!(database_change.requires_fetch); assert_eq!( - entries[4].path, - Path::new("Root/Notes dddddd/Fix login/page.md") + database_change + .logical_path + .as_ref() + .expect("database schema path") + .as_str(), + format!("{}/_schema.yaml", portable_path(&direct_database.path)) + ); + assert_eq!( + row_change.logical_path.as_ref().expect("row path").as_str(), + portable_path(&direct_row.path) ); -} - -#[test] -fn list_children_returns_workspace_root_pages_without_nested_duplicates() { - let api = FixtureNotionApi::workspace(); - let connector = NotionConnector::with_api(NotionConfig::default(), Arc::new(api)); - let result = connector - .list_children(ListChildrenRequest { - mount_id: MountId::new("notion-main"), - container: ChildContainer::Root, - parent_path: Path::new("").to_path_buf(), + let fetched = connector + .fetch_portable(PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: database_change.source_object.remote_id.clone(), + reason: PortableFetchReason::Bootstrap, }) - .expect("list workspace root"); + .expect("portable database fetch"); + assert_eq!(fetched.native.kind, "notion_database"); + assert_eq!( + fetched.native.remote_id, + RemoteId::new(provider_database_id) + ); + assert_eq!( + fetched.native.raw, + serde_json::to_vec(&expected_bundle).expect("exact native fixture") + ); + assert_eq!( + fetched.provider_version.as_deref(), + Some(concat!( + "{\"format_version\":1,", + "\"database\":{\"id\":\"cccccccccccccccccccccccccccccccc\",", + "\"last_edited_time\":\"2026-06-10T01:00:00.000Z\"},", + "\"data_sources\":[{", + "\"id\":\"dddddddddddddddddddddddddddddddd\",", + "\"last_edited_time\":\"2026-06-10T01:01:00.000Z\"}]}" + )) + ); - assert_eq!(result.entries.len(), 2); + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: database_change + .logical_path + .clone() + .expect("database schema path"), + native: fetched.native.clone(), + format_version: 1, + }) + .expect("portable database render"); + let exact_schema = concat!( + "loc:\n", + " type: notion_database_schema\n", + " database_id: \"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC\"\n", + "title: \"Tasks\"\n", + "data_sources:\n", + " - id: \"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD\"\n", + " name: \"Tasks\"\n", + " properties:\n", + " \"Name\":\n", + " id: \"title\"\n", + " type: \"title\"\n", + " \"Status\":\n", + " id: \"status-id\"\n", + " type: \"select\"\n", + " options:\n", + " - name: \"Todo\"\n", + " id: \"todo-id\"\n", + ); + assert_eq!(rendered.canonical.body, exact_schema.as_bytes()); + assert_eq!( + rendered.projections[0].artifact.body, + exact_schema.as_bytes() + ); + assert_eq!( + connector + .database_schema_yaml(&RemoteId::new(provider_database_id)) + .expect("direct schema"), + exact_schema + ); + assert_eq!( + rendered.canonical.artifact_key.as_str(), + "notion:database:cccccccccccccccccccccccccccccccc:canonical_schema:v1" + ); + assert_eq!( + rendered.projections[0].artifact.artifact_key.as_str(), + "notion:database:cccccccccccccccccccccccccccccccc:database_schema:v1" + ); + assert_eq!( + rendered.canonical.media_type, + "application/yaml; charset=utf-8" + ); + assert_eq!(rendered.projections[0].file_kind, ProjectionFileKind::Yaml); + assert_eq!(rendered.projections[0].supported_actions.len(), 2); + assert!( + rendered.projections[0] + .supported_actions + .contains(&SourceAction::Read) + ); + assert!( + rendered.projections[0] + .supported_actions + .contains(&SourceAction::Search) + ); + assert!(rendered.completeness.is_complete()); + + let renamed = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Renamed Tasks/_schema.yaml").expect("renamed path"), + native: fetched.native, + format_version: 1, + }) + .expect("renamed database render"); + assert_eq!( + rendered.canonical.artifact_key, + renamed.canonical.artifact_key + ); + assert_eq!( + rendered.projections[0].artifact.artifact_key, + renamed.projections[0].artifact.artifact_key + ); + assert_ne!( + rendered.projections[0].logical_path, + renamed.projections[0].logical_path + ); +} + +#[test] +fn portable_database_fetch_and_render_fail_closed_on_identity_and_ownership_mismatch() { + let database_id = "cccccccccccccccccccccccccccccccc"; + let data_source_id = "dddddddddddddddddddddddddddddddd"; + let mut mismatched_api = FixtureNotionApi::tree("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + mismatched_api + .databases + .get_mut(database_id) + .expect("database") + .id = "ffffffffffffffffffffffffffffffff".to_string(); + let mismatched_connector = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(mismatched_api)), + ); + let returned_id_error = mismatched_connector + .fetch_portable(PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: RemoteId::new(database_id), + reason: PortableFetchReason::Bootstrap, + }) + .expect_err("different returned database ID"); + assert_eq!( + returned_id_error.to_string(), + concat!( + "invalid state: Notion database bundle returned database ", + "`ffffffffffffffffffffffffffffffff` for requested database ", + "`cccccccccccccccccccccccccccccccc`" + ) + ); + + let mut api = FixtureNotionApi::tree("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + api.data_sources + .get_mut(data_source_id) + .expect("data source") + .parent = Some(ParentDto { + kind: "database_id".to_string(), + database_id: Some("ffffffffffffffffffffffffffffffff".to_string()), + ..Default::default() + }); + let connector = + NotionConnector::with_api(NotionConfig::default(), Arc::new(NoSearchNotionApi(api))); + + let ownership_error = connector + .fetch_portable(PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: RemoteId::new(database_id), + reason: PortableFetchReason::Bootstrap, + }) + .expect_err("foreign data-source owner"); + assert_eq!( + ownership_error.to_string(), + concat!( + "invalid state: Notion data source `dddddddddddddddddddddddddddddddd` ", + "belongs to database `ffffffffffffffffffffffffffffffff`, ", + "not `cccccccccccccccccccccccccccccccc`" + ) + ); + + let valid_api = FixtureNotionApi::tree("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let valid_connector = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(valid_api)), + ); + let fetched = valid_connector + .fetch_portable(PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: RemoteId::new(database_id), + reason: PortableFetchReason::Bootstrap, + }) + .expect("valid database fetch"); + let identity_error = valid_connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Tasks/_schema.yaml").expect("path"), + native: NativeEntity { + remote_id: RemoteId::new("ffffffffffffffffffffffffffffffff"), + ..fetched.native.clone() + }, + format_version: 1, + }) + .expect_err("mismatched native remote ID"); + assert_eq!( + identity_error.to_string(), + "invalid state: Notion portable database native payload does not match its remote ID" + ); + + let unsupported_kind = valid_connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Tasks/_schema.yaml").expect("path"), + native: NativeEntity { + remote_id: RemoteId::new(database_id), + kind: "notion_database_view".to_string(), + raw: Vec::new(), + }, + format_version: 1, + }) + .expect_err("unsupported native kind"); + assert_eq!( + unsupported_kind.to_string(), + "invalid state: Notion portable render received unsupported native kind `notion_database_view`" + ); + + let malformed = valid_connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Tasks/_schema.yaml").expect("path"), + native: NativeEntity { + remote_id: RemoteId::new(database_id), + kind: "notion_database".to_string(), + raw: b"{}".to_vec(), + }, + format_version: 1, + }) + .expect_err("malformed database bundle"); + assert!( + malformed + .to_string() + .starts_with("io error: notion database native decode failed:") + ); +} + +#[test] +fn portable_fetch_does_not_fall_back_to_database_after_non_not_found_page_error() { + let connector = + NotionConnector::with_api(NotionConfig::default(), Arc::new(NonNotFoundPageErrorApi)); + + let error = connector + .fetch_portable(PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + reason: PortableFetchReason::Bootstrap, + }) + .expect_err("page error must not trigger database fallback"); + + assert_eq!( + error.to_string(), + "invalid state: injected page retrieval failure" + ); +} + +#[test] +fn portable_fetch_preserves_descendant_not_found_without_database_fallback() { + let retrieve_page_calls = Arc::new(AtomicUsize::new(0)); + let connector = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(DescendantNotFoundApi { + retrieve_page_calls: Arc::clone(&retrieve_page_calls), + }), + ); + + let error = connector + .fetch_portable(PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + reason: PortableFetchReason::Bootstrap, + }) + .expect_err("descendant block lookup must fail as a page fetch"); + + assert_eq!( + error, + locality_core::LocalityError::RemoteNotFound( + "missing descendant block `bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`".to_string() + ) + ); + assert_eq!(retrieve_page_calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn portable_render_marks_media_incomplete_instead_of_silently_omitting_it() { + let bundle = locality_notion::dto::NotionPageBundle { + page: page("page-media", "Media"), + blocks: vec![BlockTreeDto { + block: file_block("image-1", "image", "https://example.com/image.png", "Image"), + children: Vec::new(), + }], + }; + let connector = NotionConnector::with_api( + NotionConfig::default().with_root_page_id(RemoteId::new("page-media")), + Arc::new(NoSearchNotionApi(FixtureNotionApi::new())), + ); + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Media/page.md").expect("path"), + native: NativeEntity { + remote_id: RemoteId::new("page-media"), + kind: "notion_page".to_string(), + raw: serde_json::to_vec(&bundle).expect("native fixture"), + }, + format_version: 1, + }) + .expect("render media page"); + + assert!(!rendered.completeness.is_complete()); + assert!(rendered.completeness.incomplete_reasons().iter().any( + |reason| matches!(reason, PortableIncompleteReason::UnsupportedArtifact { + artifact_kind, + .. + } if artifact_kind == "notion_media") + )); +} + +#[test] +fn portable_hosted_media_capture_is_sanitized_local_and_exact() { + let page_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let block_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let signed_url = concat!( + "https://secure.notion-static.com/assets/Cover.PNG?", + "X-Amz-Signature=signature-secret&token=token-secret&", + "Authorization=Bearer%20authorization-secret#fragment-secret" + ); + let calls = Arc::new(Mutex::new(Vec::new())); + let fetcher = Arc::new(FixturePortableMediaFetcher { + outcomes: BTreeMap::from([( + signed_url.to_string(), + FixturePortableMediaOutcome::Success(PortableMediaCapture { + bytes: vec![0x89, b'P', b'N', b'G'], + media_type: "image/png; charset=binary".to_string(), + }), + )]), + calls: Arc::clone(&calls), + }); + let connector = portable_media_connector( + page_id, + vec![hosted_file_block( + block_id, + "image", + signed_url, + Some("2099-06-12T10:00:00.000Z"), + )], + ) + .with_portable_media_capture_fetcher(PortableMediaCapturePolicy::HostedPilot, fetcher.clone()); + + assert_eq!( + connector.portable_media_capture_policy(), + PortableMediaCapturePolicy::HostedPilot + ); + let fetcher_references = Arc::strong_count(&fetcher); + let rooted = connector.with_root_page_id(RemoteId::new(page_id)); + assert_eq!(Arc::strong_count(&fetcher), fetcher_references + 1); + assert_eq!( + rooted.portable_media_capture_policy(), + PortableMediaCapturePolicy::HostedPilot + ); + drop(rooted); + let deferred = connector.with_execution_policy(ConnectorExecutionPolicy::DeferProviderCooldown); + assert_eq!(Arc::strong_count(&fetcher), fetcher_references + 1); + assert_eq!( + deferred.portable_media_capture_policy(), + PortableMediaCapturePolicy::HostedPilot + ); + drop(deferred); + + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("portable media fetch"); + assert!(fetched.completeness.is_complete()); + assert_eq!(fetched.native.kind, "notion_page_portable_media_v1"); + assert_eq!( + calls.lock().expect("calls").as_slice(), + [(signed_url.to_string(), PORTABLE_MEDIA_MAX_ASSET_BYTES)] + ); + let native: locality_notion::dto::NotionPortablePageBundleV1 = + serde_json::from_slice(&fetched.native.raw).expect("portable media native"); + assert_eq!(native.format_version, 1); + assert_eq!( + native.captured_media, + vec![locality_notion::dto::NotionPortableCapturedMediaV1 { + block_id: block_id.to_string(), + kind: "image".to_string(), + media_type: "image/png".to_string(), + bytes: vec![0x89, b'P', b'N', b'G'], + }] + ); + assert!(native.incomplete_media.is_empty()); + let hosted = native.page.blocks[0] + .block + .image + .as_ref() + .and_then(|file| file.file.as_ref()) + .expect("sanitized hosted file"); + assert_eq!( + hosted.url, + "https://secure.notion-static.com/assets/Cover.PNG" + ); + assert_eq!(hosted.expiry_time, None); + let native_raw = String::from_utf8_lossy(&fetched.native.raw); + assert!(native_raw.contains(r#""bytes":"iVBORw==""#)); + assert!(!native_raw.contains(signed_url)); + let desktop_paths = locality_notion::render::render_page_bundle_with_options( + &native.page, + &locality_notion::render::RenderOptions::with_page_path("Docs/Coverage/page.md"), + ) + .expect("desktop path render"); + assert_eq!( + desktop_paths.media_assets[0].local_path, + Path::new(".loc/media/Docs/Coverage/image-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.png") + ); + for forbidden in [ + "X-Amz-Signature", + "signature-secret", + "token-secret", + "Authorization", + "authorization-secret", + "fragment-secret", + "2099-06-12", + ] { + assert!(!native_raw.contains(forbidden)); + assert!(!format!("{fetched:?}").contains(forbidden)); + } + + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Docs/Coverage/page.md").expect("path"), + native: fetched.native, + format_version: 1, + }) + .expect("portable media render"); + let exact_markdown = concat!( + "---\n", + "loc:\n", + " id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + " type: page\n", + " synced_at: \"2026-06-10T00:00:00.000Z\"\n", + " remote_edited_at: \"2026-06-10T00:00:00.000Z\"\n", + "title: \"Coverage\"\n", + "---\n", + "![Image](../../.loc/media/Docs/Coverage/image-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.png)\n", + ); + assert_eq!(rendered.canonical.body, exact_markdown.as_bytes()); + assert_eq!(rendered.projections.len(), 2); + assert_eq!( + rendered.projections[0].artifact.body, + exact_markdown.as_bytes() + ); + let binary = &rendered.projections[1]; + assert_eq!( + binary.artifact.artifact_key.as_str(), + concat!( + "notion:page:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:", + "block:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:media:v1" + ) + ); + assert_eq!(binary.artifact.media_type, "image/png"); + assert_eq!(binary.artifact.body, vec![0x89, b'P', b'N', b'G']); + assert_eq!( + binary.logical_path.as_str(), + ".loc/media/Docs/Coverage/image-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.png" + ); + assert_eq!(binary.file_kind, ProjectionFileKind::Binary); + assert_eq!( + binary.supported_actions, + [SourceAction::Read, SourceAction::DownloadAttachment] + .into_iter() + .collect() + ); + assert!(rendered.completeness.is_complete()); + for projection in &rendered.projections { + let body = String::from_utf8_lossy(&projection.artifact.body); + assert!(!body.contains("X-Amz-Signature")); + assert!(!body.contains("token-secret")); + assert!(!body.contains("fragment-secret")); + } +} + +#[test] +fn portable_media_default_preserves_native_and_remains_incomplete() { + let page_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let signed_url = "https://secure.notion-static.com/image.png?X-Amz-Signature=direct"; + let block = hosted_file_block( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image", + signed_url, + Some("2099-06-12T10:00:00.000Z"), + ); + let connector = portable_media_connector(page_id, vec![block.clone()]); + let direct = connector + .fetch(FetchRequest { + remote_id: RemoteId::new(page_id), + }) + .expect("direct fetch"); + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("default portable fetch"); + assert_eq!(fetched.native.kind, "notion_page"); + assert_eq!(fetched.native.raw, direct.raw); + assert!(String::from_utf8_lossy(&fetched.native.raw).contains(signed_url)); + + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Coverage/page.md").expect("path"), + native: fetched.native, + format_version: 1, + }) + .expect("default portable render"); + assert!(!rendered.completeness.is_complete()); + assert_eq!(rendered.projections.len(), 1); +} + +#[test] +fn portable_capture_keeps_pages_without_media_byte_exact() { + let page_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let blocks = vec![paragraph_block( + "paragraph-1", + vec![rich_text("No media here.")], + )]; + let direct_connector = portable_media_connector(page_id, blocks.clone()); + let direct_native = direct_connector + .fetch(FetchRequest { + remote_id: RemoteId::new(page_id), + }) + .expect("direct fetch"); + let direct_document = direct_connector + .render(&direct_native) + .expect("direct render"); + let connector = portable_media_connector(page_id, blocks) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("portable fetch"); + assert!(fetched.completeness.is_complete()); + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Coverage/page.md").expect("path"), + native: fetched.native, + format_version: 1, + }) + .expect("portable render"); + let direct_bytes = render_canonical_markdown(&direct_document).into_bytes(); + assert_eq!(rendered.canonical.body, direct_bytes); + assert_eq!(rendered.projections.len(), 1); + assert_eq!(rendered.projections[0].artifact.body, direct_bytes); + assert!(rendered.completeness.is_complete()); +} + +#[test] +fn portable_media_denials_are_incomplete_and_never_publish_remote_urls() { + let denied = [ + ("external", "https://example.com/external.png"), + ("http", "http://secure.notion-static.com/image.png"), + ("ip", "https://127.0.0.1/image.png"), + ( + "userinfo", + "https://user:pass@secure.notion-static.com/image.png", + ), + ("bad_port", "https://secure.notion-static.com:444/image.png"), + ("unlisted", "https://notion-static.com/image.png"), + ( + "s3_prefix", + "https://s3.us-west-2.amazonaws.com/other/image.png", + ), + ]; + for (index, (case, url)) in denied.into_iter().enumerate() { + let page_id = format!("page-denied-{index}"); + let block_id = format!("block-denied-{index}"); + let block = if case == "external" { + file_block(&block_id, "image", url, "Image") + } else { + hosted_file_block(&block_id, "image", url, None) + }; + let connector = portable_media_connector(&page_id, vec![block]) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + let fetched = connector + .fetch_portable(portable_fetch_request(&page_id)) + .unwrap_or_else(|error| panic!("{case} fetch failed closed unexpectedly: {error}")); + assert!(!fetched.completeness.is_complete(), "{case}"); + let raw = String::from_utf8_lossy(&fetched.native.raw); + assert!(!raw.contains(url), "{case}: {raw}"); + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new(format!("Denied {index}/page.md")).expect("path"), + native: fetched.native, + format_version: 1, + }) + .unwrap_or_else(|error| panic!("{case} render: {error}")); + assert!(!rendered.completeness.is_complete(), "{case}"); + assert_eq!(rendered.projections.len(), 1, "{case}"); + assert!(!String::from_utf8_lossy(&rendered.canonical.body).contains(url)); + } +} + +#[test] +fn portable_media_expired_failed_and_oversized_captures_are_redacted() { + let cases = [ + ( + "expired", + Some("2000-01-01T00:00:00.000Z"), + FixturePortableMediaOutcome::Success(PortableMediaCapture { + bytes: b"unused".to_vec(), + media_type: "image/png".to_string(), + }), + ), + ( + "failed", + Some("2099-01-01T00:00:00.000Z"), + FixturePortableMediaOutcome::Failure( + "X-Amz-Signature=must-not-escape token-secret".to_string(), + ), + ), + ( + "oversized", + Some("2099-01-01T00:00:00.000Z"), + FixturePortableMediaOutcome::Success(PortableMediaCapture { + bytes: vec![0; PORTABLE_MEDIA_MAX_ASSET_BYTES + 1], + media_type: "image/png".to_string(), + }), + ), + ]; + for (case, expiry, outcome) in cases { + let page_id = format!("page-{case}"); + let block_id = format!("block-{case}"); + let url = + format!("https://secure.notion-static.com/{case}.png?X-Amz-Signature=signature-secret"); + let fetcher = Arc::new(FixturePortableMediaFetcher { + outcomes: BTreeMap::from([(url.clone(), outcome)]), + calls: Arc::new(Mutex::new(Vec::new())), + }); + let connector = portable_media_connector( + &page_id, + vec![hosted_file_block(&block_id, "image", &url, expiry)], + ) + .with_portable_media_capture_fetcher(PortableMediaCapturePolicy::HostedPilot, fetcher); + let fetched = connector + .fetch_portable(portable_fetch_request(&page_id)) + .unwrap_or_else(|error| panic!("{case}: {error}")); + assert!(!fetched.completeness.is_complete(), "{case}"); + let raw = String::from_utf8_lossy(&fetched.native.raw); + assert!(!raw.contains("X-Amz-Signature"), "{case}"); + assert!(!raw.contains("signature-secret"), "{case}"); + assert!(!raw.contains("token-secret"), "{case}"); + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new(format!("{case}/page.md")).expect("path"), + native: fetched.native, + format_version: 1, + }) + .expect("render incomplete media"); + assert_eq!(rendered.projections.len(), 1, "{case}"); + assert!(!String::from_utf8_lossy(&rendered.canonical.body).contains("https://")); + } +} + +#[test] +fn portable_media_sanitizes_nested_arbitrary_json_and_preserves_ordinary_values() { + let page_id = "arbitrary-json-page"; + let signed_url = concat!( + "https://secure.notion-static.com/nested/file.pdf?", + "X-Amz-Credential=credential-secret&X-Amz-Signature=signature-secret&", + "token=token-secret#fragment-secret" + ); + let ordinary_formula = json!({ + "type": "string", + "string": "ordinary formula result", + "nested": [1, true, { "status": "ready" }] + }); + let mut fixture_page = page(page_id, "Arbitrary JSON"); + fixture_page.properties.insert( + "formula".to_string(), + PagePropertyDto { + kind: "formula".to_string(), + formula: Some(ordinary_formula.clone()), + ..Default::default() + }, + ); + fixture_page.properties.insert( + "rollup".to_string(), + PagePropertyDto { + kind: "rollup".to_string(), + rollup: Some(json!({ + "type": "array", + "array": [{ + "type": "files", + "files": [{ + "name": "private.pdf", + "type": "file", + "file": { + "url": signed_url, + "expiry_time": "2099-01-01T00:00:00.000Z", + "authorization": "Bearer authorization-secret", + "secret": "nested-secret" + } + }] + }] + })), + ..Default::default() + }, + ); + let mut custom = block("custom-secret", "custom_block"); + custom.custom_block = Some(json!({ + "payload": [{ + "file": { + "url": signed_url, + "expiry_time": "2099-01-01T00:00:00.000Z", + "token": "custom-token-secret" + } + }] + })); + let connector = portable_media_connector_with_page(fixture_page, vec![custom]) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("sanitized arbitrary JSON fetch"); + assert!(!fetched.completeness.is_complete()); + let native: NotionPortablePageBundleV1 = + serde_json::from_slice(&fetched.native.raw).expect("portable native"); + assert_eq!( + native.page.page.properties["formula"].formula.as_ref(), + Some(&ordinary_formula) + ); + assert_eq!( + native.page.page.properties["rollup"].rollup, + Some(json!({ + "type": "array", + "array": [{ + "type": "files", + "files": [{ + "name": "private.pdf", + "type": "file", + "file": { "url": "" } + }] + }], + "_locality_portable_media_sanitized_v1": true + })) + ); + assert_eq!( + native.page.blocks[0].block.custom_block, + Some(json!({ + "payload": [{ "file": { "url": "" } }], + "_locality_portable_media_sanitized_v1": true + })) + ); + assert_eq!(native.incomplete_media.len(), 2); + assert!(native.incomplete_media.iter().all(|outcome| { + outcome.kind == "arbitrary_json" && outcome.code == "sanitized_embedded_media_secret" + })); + let raw = String::from_utf8_lossy(&fetched.native.raw); + for forbidden in [ + signed_url, + "secure.notion-static.com", + "X-Amz", + "credential-secret", + "signature-secret", + "token-secret", + "authorization-secret", + "nested-secret", + "fragment-secret", + "expiry_time", + "2099-01-01", + ] { + assert!(!raw.contains(forbidden), "native retained {forbidden}"); + assert!(!format!("{fetched:?}").contains(forbidden)); + } + assert_eq!( + raw.matches("_locality_portable_media_sanitized_v1").count(), + 2 + ); + + let mut tampered = native.clone(); + tampered + .page + .page + .properties + .get_mut("rollup") + .and_then(|property| property.rollup.as_mut()) + .and_then(serde_json::Value::as_object_mut) + .expect("rollup object") + .insert("leaked_url".to_string(), json!(signed_url)); + let tampered_native = NativeEntity { + remote_id: RemoteId::new(page_id), + kind: "notion_page_portable_media_v1".to_string(), + raw: serde_json::to_vec(&tampered).expect("tampered arbitrary native"), + }; + let error = connector + .render_portable(&portable_render_request(page_id, tampered_native)) + .expect_err("render must revalidate arbitrary JSON"); + assert_eq!( + error.to_string(), + "invalid state: Notion portable arbitrary JSON retained media credentials" + ); + for forbidden in [signed_url, "signature-secret", "token-secret"] { + assert!(!format!("{error:?}").contains(forbidden)); + } + + let rendered = connector + .render_portable(&portable_render_request(page_id, fetched.native)) + .expect("render sanitized arbitrary JSON"); + assert!(!rendered.completeness.is_complete()); + assert_eq!(rendered.projections.len(), 1); + let rendered_debug = format!("{rendered:?}"); + for forbidden in ["https://", "X-Amz", "token-secret", "authorization-secret"] { + assert!(!rendered_debug.contains(forbidden)); + } +} + +#[test] +fn portable_media_preserves_ordinary_authentication_words_exactly() { + let page_id = "ordinary-auth-words"; + let formula = json!({ + "type": "string", + "string": "Authorization approved", + "note": "unrelated token words remain ordinary prose" + }); + let rollup = json!({ + "type": "array", + "array": ["bearer bonds", "token budget approved"] + }); + let mut fixture_page = page(page_id, "Ordinary Words"); + fixture_page.properties.insert( + "formula".to_string(), + PagePropertyDto { + kind: "formula".to_string(), + formula: Some(formula.clone()), + ..Default::default() + }, + ); + fixture_page.properties.insert( + "rollup".to_string(), + PagePropertyDto { + kind: "rollup".to_string(), + rollup: Some(rollup.clone()), + ..Default::default() + }, + ); + let connector = portable_media_connector_with_page(fixture_page, Vec::new()) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("ordinary prose fetch"); + assert!(fetched.completeness.is_complete()); + let native: NotionPortablePageBundleV1 = + serde_json::from_slice(&fetched.native.raw).expect("portable native"); + assert_eq!( + native.page.page.properties["formula"].formula, + Some(formula) + ); + assert_eq!(native.page.page.properties["rollup"].rollup, Some(rollup)); + assert!(native.incomplete_media.is_empty()); + + let rendered = connector + .render_portable(&portable_render_request(page_id, fetched.native)) + .expect("ordinary prose render"); + assert!(rendered.completeness.is_complete()); +} + +#[test] +fn portable_media_rejects_extra_typed_payloads_without_echoing_credentials() { + let first_url = "https://secure.notion-static.com/image.png?X-Amz-Signature=first-secret"; + let second_url = "https://secure.notion-static.com/video.mp4?X-Amz-Signature=second-secret"; + let mut malformed = hosted_file_block("malformed", "image", first_url, None); + malformed.video = hosted_file_block("unused", "video", second_url, None).video; + let connector = portable_media_connector("malformed-page", vec![malformed]) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + let error = connector + .fetch_portable(portable_fetch_request("malformed-page")) + .expect_err("extra typed media payload must fail"); + assert_eq!( + error.to_string(), + "invalid state: Notion portable media block must contain exactly its selected typed payload" + ); + let error_debug = format!("{error:?}"); + for forbidden in [first_url, second_url, "first-secret", "second-secret"] { + assert!(!error_debug.contains(forbidden)); + } + + let mut non_media = paragraph_block("paragraph", vec![rich_text("hello")]); + non_media.image = hosted_file_block("unused", "image", first_url, None).image; + let connector = portable_media_connector("non-media-page", vec![non_media]) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + assert_eq!( + connector + .fetch_portable(portable_fetch_request("non-media-page")) + .expect_err("typed media on non-media block must fail") + .to_string(), + "invalid state: Notion portable non-media block contains a typed media payload" + ); +} + +#[test] +fn portable_media_render_binds_every_incomplete_outcome_exactly() { + let page_id = "outcome-binding-page"; + let mut fixture_page = page(page_id, "Outcome Binding"); + fixture_page.properties.insert( + "attachments".to_string(), + PagePropertyDto { + kind: "files".to_string(), + files: vec![FilePropertyDto { + name: Some("private.pdf".to_string()), + kind: "file".to_string(), + external: None, + file: Some(HostedFileDto { + url: "https://secure.notion-static.com/private.pdf?X-Amz-Signature=secret" + .to_string(), + expiry_time: Some("2099-01-01T00:00:00.000Z".to_string()), + }), + }], + ..Default::default() + }, + ); + let connector = portable_media_connector_with_page(fixture_page, Vec::new()) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("property media fetch"); + let native: NotionPortablePageBundleV1 = + serde_json::from_slice(&fetched.native.raw).expect("portable native"); + assert_eq!( + native.incomplete_media, + vec![NotionPortableIncompleteMediaV1 { + block_id: "page-property-file-1".to_string(), + kind: "file_property".to_string(), + code: "unsupported_page_property_media".to_string(), + }] + ); + + let mut variants = Vec::new(); + let mut missing = native.clone(); + missing.incomplete_media.clear(); + variants.push(missing); + let mut wrong_kind = native.clone(); + wrong_kind.incomplete_media[0].kind = "image".to_string(); + variants.push(wrong_kind); + let mut wrong_code = native.clone(); + wrong_code.incomplete_media[0].code = "external_media".to_string(); + variants.push(wrong_code); + let mut spurious = native.clone(); + spurious + .incomplete_media + .push(NotionPortableIncompleteMediaV1 { + block_id: "spurious".to_string(), + kind: "file_property".to_string(), + code: "unsupported_page_property_media".to_string(), + }); + variants.push(spurious); + for variant in variants { + let native = NativeEntity { + remote_id: RemoteId::new(page_id), + kind: "notion_page_portable_media_v1".to_string(), + raw: serde_json::to_vec(&variant).expect("tampered native"), + }; + assert_eq!( + connector + .render_portable(&portable_render_request(page_id, native)) + .expect_err("tampered outcome must fail") + .to_string(), + "invalid state: Notion portable media native payload has invalid incomplete outcomes" + ); + } + + let mut duplicate = native.clone(); + duplicate + .incomplete_media + .push(duplicate.incomplete_media[0].clone()); + let duplicate_native = NativeEntity { + remote_id: RemoteId::new(page_id), + kind: "notion_page_portable_media_v1".to_string(), + raw: serde_json::to_vec(&duplicate).expect("duplicate outcome native"), + }; + assert_eq!( + connector + .render_portable(&portable_render_request(page_id, duplicate_native)) + .expect_err("duplicate outcome must fail") + .to_string(), + "invalid state: Notion portable media native payload has duplicate incomplete outcomes" + ); + + let mut retained_secret = native; + retained_secret + .page + .page + .properties + .get_mut("attachments") + .expect("attachments") + .files[0] + .file + .as_mut() + .expect("hosted") + .url = "https://secure.notion-static.com/private.pdf?X-Amz-Signature=secret".to_string(); + let tampered = NativeEntity { + remote_id: RemoteId::new(page_id), + kind: "notion_page_portable_media_v1".to_string(), + raw: serde_json::to_vec(&retained_secret).expect("tampered secret native"), + }; + let error = connector + .render_portable(&portable_render_request(page_id, tampered)) + .expect_err("render must independently reject retained credentials"); + assert!(!format!("{error:?}").contains("X-Amz-Signature")); + assert!(!format!("{error:?}").contains("secret")); +} + +#[test] +fn portable_media_render_rejects_unknown_fields_without_echoing_secrets() { + let page_id = "unknown-fields-page"; + let connector = portable_media_connector( + page_id, + vec![file_block( + "image-unknown", + "image", + "https://example.com/image.png", + "Image", + )], + ) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("base portable native"); + let raw = String::from_utf8(fetched.native.raw).expect("UTF-8 JSON"); + + let top_secret = concat!( + "https://secure.notion-static.com/top.png?", + "X-Amz-Signature=top-secret&token=top-token" + ); + let top = format!(r#"{{"unknown_top":"{top_secret}",{}"#, &raw[1..]); + let deep_secret = concat!( + "https://secure.notion-static.com/deep.png?", + "X-Amz-Signature=deep-secret&token=deep-token" + ); + let deep = raw.replacen( + r#"},"video":null"#, + &format!( + r#","unknown_nested":{{"url":"{deep_secret}","token":"deep-token"}}}},"video":null"# + ), + 1, + ); + assert_ne!(deep, raw); + + for (tampered, secrets) in [ + (top, ["top-secret", "top-token"]), + (deep, ["deep-secret", "deep-token"]), + ] { + let error = connector + .render_portable(&portable_render_request( + page_id, + NativeEntity { + remote_id: RemoteId::new(page_id), + kind: "notion_page_portable_media_v1".to_string(), + raw: tampered.into_bytes(), + }, + )) + .expect_err("unknown field must fail canonical validation"); + assert_eq!( + error.to_string(), + "invalid state: Notion portable media native payload is not canonical" + ); + for secret in secrets { + assert!(!format!("{error:?}").contains(secret)); + } + } +} + +#[test] +fn portable_media_duplicate_and_count_limits_fail_closed() { + let duplicate = hosted_file_block( + "duplicate-block", + "image", + "https://secure.notion-static.com/image.png", + None, + ); + let duplicate_connector = + portable_media_connector("duplicate-page", vec![duplicate.clone(), duplicate]); + let duplicate_calls = Arc::new(Mutex::new(Vec::new())); + let duplicate_connector = duplicate_connector.with_portable_media_capture_fetcher( + PortableMediaCapturePolicy::HostedPilot, + Arc::new(FixturePortableMediaFetcher { + outcomes: BTreeMap::new(), + calls: Arc::clone(&duplicate_calls), + }), + ); + assert_eq!( + duplicate_connector + .fetch_portable(portable_fetch_request("duplicate-page")) + .expect_err("duplicate media must fail") + .to_string(), + "invalid state: Notion portable media contains a duplicate block identity" + ); + assert!(duplicate_calls.lock().expect("duplicate calls").is_empty()); + + let too_many = (0..=128) + .map(|index| { + hosted_file_block( + &format!("media-{index}"), + "image", + &format!( + "https://secure.notion-static.com/{index}.png?X-Amz-Signature=secret-{index}" + ), + Some("2099-01-01T00:00:00.000Z"), + ) + }) + .collect(); + let calls = Arc::new(Mutex::new(Vec::new())); + let fetcher = Arc::new(FixturePortableMediaFetcher { + outcomes: BTreeMap::new(), + calls: Arc::clone(&calls), + }); + let count_connector = portable_media_connector("count-page", too_many) + .with_portable_media_capture_fetcher(PortableMediaCapturePolicy::HostedPilot, fetcher); + let fetched = count_connector + .fetch_portable(portable_fetch_request("count-page")) + .expect("over-limit capture must become explicitly incomplete"); + assert!(calls.lock().expect("fetch calls").is_empty()); + assert!(!fetched.completeness.is_complete()); + let raw = String::from_utf8_lossy(&fetched.native.raw); + for forbidden in ["secure.notion-static.com", "X-Amz", "secret-", "2099-01-01"] { + assert!(!raw.contains(forbidden)); + } + let native: NotionPortablePageBundleV1 = + serde_json::from_slice(&fetched.native.raw).expect("over-limit native"); + assert!(native.captured_media.is_empty()); + assert_eq!( + native.incomplete_media, + vec![NotionPortableIncompleteMediaV1 { + block_id: "__locality_portable_media_limit_v1".to_string(), + kind: "page".to_string(), + code: "asset_limit_exceeded".to_string(), + }] + ); + for extra in [ + NotionPortableIncompleteMediaV1 { + block_id: "media-0".to_string(), + kind: "image".to_string(), + code: "unavailable_hosted_media".to_string(), + }, + NotionPortableIncompleteMediaV1 { + block_id: "spurious".to_string(), + kind: "page".to_string(), + code: "asset_limit_exceeded".to_string(), + }, + ] { + let mut tampered = native.clone(); + tampered.incomplete_media.push(extra); + let error = count_connector + .render_portable(&portable_render_request( + "count-page", + NativeEntity { + remote_id: RemoteId::new("count-page"), + kind: "notion_page_portable_media_v1".to_string(), + raw: serde_json::to_vec(&tampered).expect("tampered outcome native"), + }, + )) + .expect_err("over-limit per-asset or spurious outcome must fail"); + assert_eq!( + error.to_string(), + "invalid state: Notion portable media native payload has invalid incomplete outcomes" + ); + } + let mut captured = native.clone(); + captured + .captured_media + .push(locality_notion::dto::NotionPortableCapturedMediaV1 { + block_id: "media-0".to_string(), + kind: "image".to_string(), + media_type: "image/png".to_string(), + bytes: vec![1], + }); + assert_eq!( + count_connector + .render_portable(&portable_render_request( + "count-page", + NativeEntity { + remote_id: RemoteId::new("count-page"), + kind: "notion_page_portable_media_v1".to_string(), + raw: serde_json::to_vec(&captured).expect("tampered capture native"), + }, + )) + .expect_err("over-limit capture must fail") + .to_string(), + "invalid state: Notion portable over-limit native payload contains captured media" + ); + let rendered = count_connector + .render_portable(&portable_render_request("count-page", fetched.native)) + .expect("render over-limit incomplete page"); + assert!(!rendered.completeness.is_complete()); + assert_eq!(rendered.projections.len(), 1); + assert_eq!( + rendered.projections[0].file_kind, + ProjectionFileKind::Markdown + ); + assert!(!format!("{rendered:?}").contains("X-Amz")); +} + +#[test] +fn portable_media_large_over_limit_page_has_bounded_outcomes() { + const PROPERTY_ASSETS: usize = 4_096; + let page_id = "large-over-limit-page"; + let property_url = concat!( + "https://secure.notion-static.com/property.pdf?", + "X-Amz-Signature=property-secret" + ); + let block_url = concat!( + "https://secure.notion-static.com/image.png?", + "X-Amz-Signature=block-secret" + ); + let mut fixture_page = page(page_id, "Large Over Limit"); + fixture_page.properties.insert( + "attachments".to_string(), + PagePropertyDto { + kind: "files".to_string(), + files: (0..PROPERTY_ASSETS) + .map(|index| FilePropertyDto { + name: Some(format!("file-{index}.pdf")), + kind: "file".to_string(), + external: None, + file: Some(HostedFileDto { + url: property_url.to_string(), + expiry_time: Some("2099-01-01T00:00:00.000Z".to_string()), + }), + }) + .collect(), + ..Default::default() + }, + ); + fixture_page.properties.insert( + "rollup".to_string(), + PagePropertyDto { + kind: "rollup".to_string(), + rollup: Some(json!({ + "type": "files", + "file": { + "url": property_url, + "expiry_time": "2099-01-01T00:00:00.000Z" + } + })), + ..Default::default() + }, + ); + let calls = Arc::new(Mutex::new(Vec::new())); + let connector = portable_media_connector_with_page( + fixture_page, + vec![hosted_file_block( + "large-image", + "image", + block_url, + Some("2099-01-01T00:00:00.000Z"), + )], + ) + .with_portable_media_capture_fetcher( + PortableMediaCapturePolicy::HostedPilot, + Arc::new(FixturePortableMediaFetcher { + outcomes: BTreeMap::new(), + calls: Arc::clone(&calls), + }), + ); + + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("large over-limit fetch"); + assert!(calls.lock().expect("media calls").is_empty()); + let native: NotionPortablePageBundleV1 = + serde_json::from_slice(&fetched.native.raw).expect("large native"); + assert!(native.captured_media.is_empty()); + assert_eq!(native.incomplete_media.len(), 2); + assert!(native.incomplete_media.iter().any(|outcome| { + outcome.block_id == "__locality_portable_media_limit_v1" + && outcome.kind == "page" + && outcome.code == "asset_limit_exceeded" + })); + assert!(native.incomplete_media.iter().any(|outcome| { + outcome.kind == "arbitrary_json" && outcome.code == "sanitized_embedded_media_secret" + })); + assert!( + native.page.page.properties["attachments"] + .files + .iter() + .all(|file| file + .file + .as_ref() + .is_some_and(|hosted| { hosted.url.is_empty() && hosted.expiry_time.is_none() })) + ); + let hosted = native.page.blocks[0] + .block + .image + .as_ref() + .and_then(|payload| payload.file.as_ref()) + .expect("hosted block"); + assert!(hosted.url.is_empty()); + assert!(hosted.expiry_time.is_none()); + let raw = String::from_utf8_lossy(&fetched.native.raw); + for forbidden in [ + "secure.notion-static.com", + "X-Amz", + "property-secret", + "block-secret", + "2099-01-01", + ] { + assert!(!raw.contains(forbidden)); + } + + let rendered = connector + .render_portable(&portable_render_request(page_id, fetched.native)) + .expect("large over-limit render"); + assert_eq!(rendered.projections.len(), 1); + assert_eq!( + rendered.projections[0].file_kind, + ProjectionFileKind::Markdown + ); +} + +#[test] +fn enumerate_suffixes_every_colliding_sibling_name() { + let root_page_id = RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let api = FixtureNotionApi::colliding_tree(root_page_id.as_str()); + let connector = NotionConnector::with_api( + NotionConfig::default().with_root_page_id(root_page_id), + Arc::new(api), + ); + + let entries = connector + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect("enumerate"); + + assert_eq!(entries.len(), 5); + assert_eq!(entries[0].path, Path::new("Root/page.md")); + assert_eq!(entries[1].path, Path::new("Root/Notes bbbbbb/page.md")); + assert_eq!(entries[2].path, Path::new("Root/Notes cccccc/page.md")); + assert_eq!(entries[3].path, Path::new("Root/Notes dddddd")); + assert_eq!( + entries[4].path, + Path::new("Root/Notes dddddd/Fix login/page.md") + ); +} + +#[test] +fn list_children_returns_workspace_root_pages_without_nested_duplicates() { + let api = FixtureNotionApi::workspace(); + let connector = NotionConnector::with_api(NotionConfig::default(), Arc::new(api)); + + let result = connector + .list_children(ListChildrenRequest { + mount_id: MountId::new("notion-main"), + container: ChildContainer::Root, + parent_path: Path::new("").to_path_buf(), + }) + .expect("list workspace root"); + + assert_eq!(result.entries.len(), 2); assert_eq!(result.entries[0].remote_id, RemoteId::new("root-page")); assert_eq!(result.entries[0].kind, EntityKind::Page); assert_eq!(result.entries[0].path, Path::new("Root/page.md")); @@ -2444,6 +4180,85 @@ fn normalize_notion_id(input: &str) -> String { .collect::() } +#[derive(Clone)] +enum FixturePortableMediaOutcome { + Success(PortableMediaCapture), + Failure(String), +} + +struct FixturePortableMediaFetcher { + outcomes: BTreeMap, + calls: Arc>>, +} + +impl PortableMediaCaptureFetcher for FixturePortableMediaFetcher { + fn fetch( + &self, + hosted_url: &str, + max_bytes: usize, + ) -> locality_core::LocalityResult { + self.calls + .lock() + .expect("media fetch calls") + .push((hosted_url.to_string(), max_bytes)); + match self.outcomes.get(hosted_url) { + Some(FixturePortableMediaOutcome::Success(capture)) => Ok(capture.clone()), + Some(FixturePortableMediaOutcome::Failure(error)) => { + Err(locality_core::LocalityError::Io(error.clone())) + } + None => Err(locality_core::LocalityError::InvalidState( + "unexpected fixture media URL".to_string(), + )), + } + } +} + +fn portable_media_connector(page_id: &str, blocks: Vec) -> NotionConnector { + portable_media_connector_with_page(page(page_id, "Coverage"), blocks) +} + +fn portable_media_connector_with_page( + fixture_page: PageDto, + blocks: Vec, +) -> NotionConnector { + let page_id = fixture_page.id.clone(); + let api = FixtureNotionApi { + pages: BTreeMap::from([(page_id.clone(), fixture_page)]), + children: BTreeMap::from([( + (page_id.clone(), None), + PaginatedListDto { + results: blocks, + next_cursor: None, + has_more: false, + }, + )]), + databases: BTreeMap::new(), + data_sources: BTreeMap::new(), + data_source_pages: BTreeMap::new(), + }; + NotionConnector::with_api( + NotionConfig::default().with_root_page_id(RemoteId::new(page_id)), + Arc::new(NoSearchNotionApi(api)), + ) +} + +fn portable_fetch_request(page_id: &str) -> PortableFetchRequest { + PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: RemoteId::new(page_id), + reason: PortableFetchReason::Bootstrap, + } +} + +fn portable_render_request(page_id: &str, native: NativeEntity) -> PortableRenderRequest { + PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new(format!("{page_id}/page.md")).expect("portable path"), + native, + format_version: 1, + } +} + #[derive(Debug)] struct FixtureNotionApi { pages: BTreeMap, @@ -2498,6 +4313,26 @@ impl FixtureNotionApi { } } + fn many_roots(count: usize) -> Self { + let pages = (0..count) + .map(|index| { + let id = format!("root-{index:02}"); + (id.clone(), page(&id, &format!("Root {index:02}"))) + }) + .collect::>(); + let children = pages + .keys() + .map(|id| ((id.clone(), None), PaginatedListDto::default())) + .collect(); + Self { + pages, + children, + databases: BTreeMap::new(), + data_sources: BTreeMap::new(), + data_source_pages: BTreeMap::new(), + } + } + fn tree(root_page_id: &str) -> Self { let child_page_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let database_id = "cccccccccccccccccccccccccccccccc"; @@ -2533,6 +4368,7 @@ impl FixtureNotionApi { database_id.to_string(), DatabaseDto { id: database_id.to_string(), + last_edited_time: Some("2026-06-10T01:00:00.000Z".to_string()), title: vec![rich_text("Tasks")], data_sources: vec![DataSourceSummaryDto { id: data_source_id.to_string(), @@ -2545,7 +4381,13 @@ impl FixtureNotionApi { data_source_id.to_string(), DataSourceDto { id: data_source_id.to_string(), + parent: Some(ParentDto { + kind: "database_id".to_string(), + database_id: Some(database_id.to_string()), + ..Default::default() + }), name: Some("Tasks".to_string()), + last_edited_time: Some("2026-06-10T01:01:00.000Z".to_string()), properties: BTreeMap::from([ ( "Name".to_string(), @@ -2695,6 +4537,65 @@ impl FixtureNotionApi { } } + fn multi_root_workspace() -> Self { + let first_root_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let nested_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let second_root_id = "ffffffffffffffffffffffffffffffff"; + let first_root = page_with_parent( + first_root_id, + "Roadmap", + Some(ParentDto { + kind: "workspace".to_string(), + workspace: Some(true), + ..Default::default() + }), + ); + let nested = page_with_parent( + nested_id, + "Notes", + Some(ParentDto { + kind: "page_id".to_string(), + page_id: Some(first_root_id.to_string()), + ..Default::default() + }), + ); + let second_root = page_with_parent( + second_root_id, + "Roadmap", + Some(ParentDto { + kind: "workspace".to_string(), + workspace: Some(true), + ..Default::default() + }), + ); + let children = BTreeMap::from([ + ( + (first_root_id.to_string(), None), + PaginatedListDto { + results: vec![child_page_block(nested_id, "Notes")], + next_cursor: None, + has_more: false, + }, + ), + ((nested_id.to_string(), None), PaginatedListDto::default()), + ( + (second_root_id.to_string(), None), + PaginatedListDto::default(), + ), + ]); + Self { + pages: BTreeMap::from([ + (first_root.id.clone(), first_root), + (nested.id.clone(), nested), + (second_root.id.clone(), second_root), + ]), + children, + databases: BTreeMap::new(), + data_sources: BTreeMap::new(), + data_source_pages: BTreeMap::new(), + } + } + fn workspace_nested_tree() -> Self { let root_page_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; let child_page_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; @@ -2968,7 +4869,7 @@ impl FixtureNotionApi { impl NotionApi for FixtureNotionApi { fn retrieve_page(&self, page_id: &str) -> locality_core::LocalityResult { self.pages.get(page_id).cloned().ok_or_else(|| { - locality_core::LocalityError::InvalidState(format!("missing fixture page {page_id}")) + locality_core::LocalityError::RemoteNotFound(format!("missing fixture page {page_id}")) }) } @@ -3067,6 +4968,118 @@ impl NotionApi for FixtureNotionApi { } } +#[derive(Debug)] +struct NonNotFoundPageErrorApi; + +impl NotionApi for NonNotFoundPageErrorApi { + fn retrieve_page(&self, _page_id: &str) -> locality_core::LocalityResult { + Err(locality_core::LocalityError::InvalidState( + "injected page retrieval failure".to_string(), + )) + } + + fn retrieve_database(&self, _database_id: &str) -> locality_core::LocalityResult { + panic!("database fallback must only follow RemoteNotFound") + } + + fn retrieve_block_children( + &self, + _block_id: &str, + _start_cursor: Option<&str>, + ) -> locality_core::LocalityResult { + unreachable!("page failure must stop traversal") + } + + fn search_pages( + &self, + _start_cursor: Option<&str>, + ) -> locality_core::LocalityResult { + panic!("explicit-root traversal must not call search") + } + + fn update_block( + &self, + _block_id: &str, + _body: serde_json::Value, + ) -> locality_core::LocalityResult { + unreachable!("page failure must stop traversal") + } + + fn append_block_children( + &self, + _block_id: &str, + _body: serde_json::Value, + ) -> locality_core::LocalityResult { + unreachable!("page failure must stop traversal") + } + + fn delete_block(&self, _block_id: &str) -> locality_core::LocalityResult { + unreachable!("page failure must stop traversal") + } +} + +#[derive(Debug)] +struct DescendantNotFoundApi { + retrieve_page_calls: Arc, +} + +impl NotionApi for DescendantNotFoundApi { + fn retrieve_page(&self, page_id: &str) -> locality_core::LocalityResult { + self.retrieve_page_calls.fetch_add(1, Ordering::SeqCst); + Ok(page(page_id, "Known page")) + } + + fn retrieve_database(&self, _database_id: &str) -> locality_core::LocalityResult { + panic!("a descendant block error must not trigger database fallback") + } + + fn retrieve_block_children( + &self, + block_id: &str, + _start_cursor: Option<&str>, + ) -> locality_core::LocalityResult { + if block_id == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { + return Ok(PaginatedListDto { + results: vec![ + toggle_block("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "Nested").with_children(), + ], + next_cursor: None, + has_more: false, + }); + } + Err(locality_core::LocalityError::RemoteNotFound(format!( + "missing descendant block `{block_id}`" + ))) + } + + fn search_pages( + &self, + _start_cursor: Option<&str>, + ) -> locality_core::LocalityResult { + panic!("portable fetch must not search") + } + + fn update_block( + &self, + _block_id: &str, + _body: serde_json::Value, + ) -> locality_core::LocalityResult { + unreachable!("not used by portable read tests") + } + + fn append_block_children( + &self, + _block_id: &str, + _body: serde_json::Value, + ) -> locality_core::LocalityResult { + unreachable!("not used by portable read tests") + } + + fn delete_block(&self, _block_id: &str) -> locality_core::LocalityResult { + unreachable!("not used by portable read tests") + } +} + #[derive(Debug)] struct NoSearchNotionApi(FixtureNotionApi); @@ -3319,6 +5332,28 @@ fn file_block(id: &str, kind: &str, url: &str, caption: &str) -> BlockDto { block } +fn hosted_file_block(id: &str, kind: &str, url: &str, expiry_time: Option<&str>) -> BlockDto { + let mut block = block(id, kind); + let payload = Some(FileBlockDto { + kind: "file".to_string(), + external: None, + file: Some(HostedFileDto { + url: url.to_string(), + expiry_time: expiry_time.map(str::to_string), + }), + caption: Vec::new(), + }); + match kind { + "image" => block.image = payload, + "video" => block.video = payload, + "file" => block.file = payload, + "pdf" => block.pdf = payload, + "audio" => block.audio = payload, + _ => panic!("unsupported hosted fixture file block kind: {kind}"), + } + block +} + fn synced_block(id: &str, source_block_id: &str) -> BlockDto { let mut block = block(id, "synced_block"); block.synced_block = Some(SyncedBlockDto { diff --git a/crates/localityd/Cargo.toml b/crates/localityd/Cargo.toml index a23f0a98..ebea018c 100644 --- a/crates/localityd/Cargo.toml +++ b/crates/localityd/Cargo.toml @@ -40,7 +40,7 @@ unicode-normalization = "0.1" zstd = { version = "0.13", default-features = false } [target.'cfg(unix)'.dependencies] -rustix = { version = "1.1", features = ["fs"] } +rustix = { version = "1.1.4", features = ["fs"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } diff --git a/crates/localityd/src/replica_materializer.rs b/crates/localityd/src/replica_materializer.rs index 4d86cbb2..a54d1b77 100644 --- a/crates/localityd/src/replica_materializer.rs +++ b/crates/localityd/src/replica_materializer.rs @@ -5,11 +5,27 @@ //! or per-file SQLite state. use std::collections::{BTreeMap, BTreeSet}; +#[cfg(unix)] +use std::ffi::{OsStr, OsString}; use std::fmt::{Display, Formatter}; -use std::fs::{self, OpenOptions}; +use std::fs; +#[cfg(not(unix))] +use std::fs::OpenOptions; use std::io::{self, Read, Write}; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; use std::path::{Path, PathBuf}; +#[cfg(unix)] +use rustix::fd::{AsFd, OwnedFd}; +#[cfg(all( + unix, + any(target_vendor = "apple", target_os = "linux", target_os = "android") +))] +use rustix::fs::RenameFlags; +#[cfg(unix)] +use rustix::fs::{AtFlags, Dir, FileType, Mode, OFlags, Stat}; + use locality_core::portable::LogicalPath; use sha2::{Digest, Sha256}; use unicode_normalization::UnicodeNormalization; @@ -321,7 +337,7 @@ fn materialize_replica_archive_inner( let (summary, decoded_tar_sha256) = match archive.encoding { ReplicaArchiveEncoding::Identity => { let mut decoded = DecodedLimitReader::new(archive.body, limits.max_decoded_bytes); - let result = extract_tar(&mut decoded, staging.path(), limits); + let result = extract_tar(&mut decoded, &staging, limits); let exceeded = decoded.exceeded(); let decoded_bytes = decoded.consumed(); let decoded_tar_sha256 = decoded.finish_sha256(); @@ -343,7 +359,7 @@ fn materialize_replica_archive_inner( let mut decoder = decoder.single_frame(); let (result, exceeded, decoded_bytes, decoded_tar_sha256) = { let mut decoded = DecodedLimitReader::new(&mut decoder, limits.max_decoded_bytes); - let result = extract_tar(&mut decoded, staging.path(), limits); + let result = extract_tar(&mut decoded, &staging, limits); let exceeded = decoded.exceeded(); let decoded_bytes = decoded.consumed(); let decoded_tar_sha256 = decoded.finish_sha256(); @@ -371,7 +387,7 @@ fn materialize_replica_archive_inner( validate_receipt(expected, summary, decoded_tar_sha256)?; } - make_tree_read_only(staging.path()).map_err(|source| ReplicaMaterializationError::Write { + make_tree_read_only(&staging).map_err(|source| ReplicaMaterializationError::Write { path: staging.path().to_path_buf(), source, })?; @@ -419,7 +435,7 @@ fn write_sha256(formatter: &mut Formatter<'_>, digest: &[u8; 32]) -> std::fmt::R fn extract_tar( reader: &mut R, - staging: &Path, + staging: &StagingDirectory, limits: ReplicaMaterializationLimits, ) -> Result { let mut state = ExtractionState::default(); @@ -455,8 +471,6 @@ fn extract_tar( .header() .mode() .map_err(|error| ReplicaMaterializationError::MalformedTar(error.to_string()))?; - let target = staging.join(Path::new(&path)); - if is_directory { if mode != READ_ONLY_DIRECTORY_MODE { return Err(ReplicaMaterializationError::InvalidDirectoryMode { path, mode }); @@ -464,7 +478,7 @@ fn extract_tar( if entry.size() != 0 { return Err(ReplicaMaterializationError::NonEmptyDirectory { path }); } - create_directory(&target)?; + staging.create_directory(&path)?; } else { if mode != READ_ONLY_FILE_MODE { return Err(ReplicaMaterializationError::InvalidFileMode { path, mode }); @@ -484,10 +498,7 @@ fn extract_tar( limit: limits.max_disk_bytes, }); } - if let Some(parent) = target.parent() { - create_directory(parent)?; - } - write_file(&target, &mut entry, size)?; + staging.write_file(&path, &mut entry, size)?; state.summary.files += 1; state.summary.materialized_bytes = disk_size; } @@ -620,14 +631,8 @@ impl ExtractionState { } } -fn create_directory(path: &Path) -> Result<(), ReplicaMaterializationError> { - fs::create_dir_all(path).map_err(|source| ReplicaMaterializationError::Write { - path: path.to_path_buf(), - source, - }) -} - -fn write_file( +#[cfg(not(unix))] +fn write_file_at_path( path: &Path, reader: &mut R, expected_size: u64, @@ -662,7 +667,44 @@ fn write_file( }) } -fn make_tree_read_only(root: &Path) -> io::Result<()> { +#[cfg(unix)] +fn make_tree_read_only(staging: &StagingDirectory) -> io::Result<()> { + // macOS refuses to rename a directory whose own mode is 0555. Finalize + // only children here; `publish` chmods the still-open root after rename. + make_child_directories_read_only(&staging.root) +} + +#[cfg(unix)] +fn make_child_directories_read_only(directory: &OwnedFd) -> io::Result<()> { + let entries = Dir::read_from(directory)?; + for entry in entries { + let entry = entry?; + let name = entry.file_name(); + if name.to_bytes() == b"." || name.to_bytes() == b".." { + continue; + } + let metadata = rustix::fs::statat(directory, name, AtFlags::SYMLINK_NOFOLLOW)?; + match FileType::from_raw_mode(metadata.st_mode) { + FileType::Directory => { + let child = open_directory_at(directory, name)?; + make_child_directories_read_only(&child)?; + rustix::fs::fchmod(&child, Mode::from_raw_mode(0o555))?; + } + FileType::RegularFile => {} + _ => { + return Err(io::Error::other(format!( + "staging tree contains a non-file, non-directory entry: {}", + name.to_string_lossy() + ))); + } + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn make_tree_read_only(staging: &StagingDirectory) -> io::Result<()> { + let root = staging.path(); let mut directories = Vec::new(); collect_directories(root, &mut directories)?; directories.sort_by_key(|path| std::cmp::Reverse(path.components().count())); @@ -677,6 +719,7 @@ fn make_tree_read_only(root: &Path) -> io::Result<()> { Ok(()) } +#[cfg(not(unix))] fn collect_directories(root: &Path, directories: &mut Vec) -> io::Result<()> { directories.push(root.to_path_buf()); for entry in fs::read_dir(root)? { @@ -688,12 +731,6 @@ fn collect_directories(root: &Path, directories: &mut Vec) -> io::Resul Ok(()) } -#[cfg(unix)] -fn set_file_read_only(path: &Path) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(READ_ONLY_FILE_MODE)) -} - #[cfg(not(unix))] fn set_file_read_only(path: &Path) -> io::Result<()> { let mut permissions = fs::metadata(path)?.permissions(); @@ -701,12 +738,6 @@ fn set_file_read_only(path: &Path) -> io::Result<()> { fs::set_permissions(path, permissions) } -#[cfg(unix)] -fn set_directory_read_only(path: &Path) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(READ_ONLY_DIRECTORY_MODE)) -} - #[cfg(not(unix))] fn set_directory_read_only(path: &Path) -> io::Result<()> { let mut permissions = fs::metadata(path)?.permissions(); @@ -714,6 +745,7 @@ fn set_directory_read_only(path: &Path) -> io::Result<()> { fs::set_permissions(path, permissions) } +#[cfg(not(unix))] fn make_tree_removable(root: &Path) { let Ok(metadata) = fs::symlink_metadata(root) else { return; @@ -730,12 +762,6 @@ fn make_tree_removable(root: &Path) { } } -#[cfg(unix)] -fn make_directory_writable(path: &Path) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o700)) -} - #[cfg(not(unix))] fn make_directory_writable(path: &Path) -> io::Result<()> { let mut permissions = fs::metadata(path)?.permissions(); @@ -743,12 +769,6 @@ fn make_directory_writable(path: &Path) -> io::Result<()> { fs::set_permissions(path, permissions) } -#[cfg(unix)] -fn make_file_writable(path: &Path) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) -} - #[cfg(not(unix))] fn make_file_writable(path: &Path) -> io::Result<()> { let mut permissions = fs::metadata(path)?.permissions(); @@ -756,13 +776,172 @@ fn make_file_writable(path: &Path) -> io::Result<()> { fs::set_permissions(path, permissions) } +#[cfg(unix)] +fn open_directory_at( + directory: Fd, + path: P, +) -> io::Result { + rustix::fs::openat( + directory, + path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(Into::into) +} + +#[cfg(unix)] +fn remove_directory_contents(directory: &OwnedFd) { + let _ = rustix::fs::fchmod(directory, Mode::from_raw_mode(0o700)); + let Ok(entries) = Dir::read_from(directory) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + if name.to_bytes() == b"." || name.to_bytes() == b".." { + continue; + } + let Ok(metadata) = rustix::fs::statat(directory, name, AtFlags::SYMLINK_NOFOLLOW) else { + continue; + }; + if FileType::from_raw_mode(metadata.st_mode) == FileType::Directory + && let Ok(child) = open_directory_at(directory, name) + { + remove_directory_contents(&child); + if rustix::fs::unlinkat(directory, name, AtFlags::REMOVEDIR).is_ok() { + continue; + } + } + // unlinkat without REMOVEDIR never follows symlinks and safely + // removes regular files, links, devices, fifos, and sockets. + let _ = rustix::fs::unlinkat(directory, name, AtFlags::empty()); + } +} + +#[cfg(unix)] +fn same_file_identity(left: &Stat, right: &Stat) -> bool { + left.st_dev == right.st_dev && left.st_ino == right.st_ino +} + +#[cfg(unix)] +fn named_entry_matches(directory: &OwnedFd, name: &OsStr, expected: &Stat) -> io::Result { + let observed = rustix::fs::statat(directory, name, AtFlags::SYMLINK_NOFOLLOW)?; + Ok(same_file_identity(&observed, expected)) +} + +#[cfg(all( + unix, + any(target_vendor = "apple", target_os = "linux", target_os = "android") +))] +fn rename_directory_noreplace( + directory: &OwnedFd, + source: &OsStr, + destination: &OsStr, +) -> io::Result<()> { + rustix::fs::renameat_with( + directory, + source, + directory, + destination, + RenameFlags::NOREPLACE, + ) + .map_err(Into::into) +} + +#[cfg(all( + unix, + not(any(target_vendor = "apple", target_os = "linux", target_os = "android")) +))] +fn rename_directory_noreplace( + _directory: &OwnedFd, + _source: &OsStr, + _destination: &OsStr, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "this Unix platform lacks atomic no-replace directory publication", + )) +} + +#[cfg(unix)] +fn unlink_named_entry(directory: &OwnedFd, name: &OsStr) { + let Ok(metadata) = rustix::fs::statat(directory, name, AtFlags::SYMLINK_NOFOLLOW) else { + return; + }; + let flags = if FileType::from_raw_mode(metadata.st_mode) == FileType::Directory { + AtFlags::REMOVEDIR + } else { + AtFlags::empty() + }; + let _ = rustix::fs::unlinkat(directory, name, flags); +} + +#[cfg(unix)] +fn remove_open_staging_directory(parent: &OwnedFd, root: &OwnedFd, hinted_name: &OsStr) { + remove_directory_contents(root); + let Ok(root_identity) = rustix::fs::fstat(root) else { + return; + }; + + let matching_name = Dir::read_from(parent).ok().and_then(|entries| { + entries.filter_map(Result::ok).find_map(|entry| { + let name = entry.file_name(); + if name.to_bytes() == b"." || name.to_bytes() == b".." { + return None; + } + let name = OsString::from_vec(name.to_bytes().to_vec()); + named_entry_matches(parent, &name, &root_identity) + .ok() + .filter(|matches| *matches) + .map(|_| name) + }) + }); + if let Some(name) = matching_name { + let _ = rustix::fs::unlinkat(parent, &name, AtFlags::REMOVEDIR); + } + + // A same-user substitution may leave a symlink or regular file at the + // expected name. Remove that entry without following it. A non-empty + // replacement directory is deliberately left untouched. + unlink_named_entry(parent, hinted_name); +} + struct StagingDirectory { path: PathBuf, + #[cfg(unix)] + parent: OwnedFd, + #[cfg(unix)] + root: OwnedFd, + #[cfg(unix)] + name: OsString, published: bool, } impl StagingDirectory { fn create(parent: &Path) -> Result { + // The pre-open metadata check alone is not enough: the path can be + // replaced with a symlink before `open`. Keep the descriptor anchored + // to the requested directory and let the identity checks below detect + // replacements after this point. + #[cfg(unix)] + let parent_descriptor = rustix::fs::open( + parent, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|error| ReplicaMaterializationError::Staging(error.into()))?; + #[cfg(unix)] + { + let opened = rustix::fs::fstat(&parent_descriptor) + .map_err(|error| ReplicaMaterializationError::Staging(error.into()))?; + let named = rustix::fs::stat(parent) + .map_err(|error| ReplicaMaterializationError::Staging(error.into()))?; + if !same_file_identity(&opened, &named) { + return Err(ReplicaMaterializationError::Staging(io::Error::other( + "replica destination parent changed while staging was created", + ))); + } + } for _ in 0..16 { let mut random = [0_u8; 16]; getrandom::fill(&mut random).map_err(|error| { @@ -772,12 +951,49 @@ impl StagingDirectory { .iter() .map(|byte| format!("{byte:02x}")) .collect::(); + #[cfg(unix)] + let name = OsString::from(format!(".locality-stage-{suffix}")); + #[cfg(unix)] + let path = parent.join(&name); + #[cfg(not(unix))] let path = parent.join(format!(".locality-stage-{suffix}")); - match fs::create_dir(&path) { + #[cfg(unix)] + let create_result = + rustix::fs::mkdirat(&parent_descriptor, &name, Mode::from_raw_mode(0o700)) + .map_err(io::Error::from); + #[cfg(not(unix))] + let create_result = fs::create_dir(&path); + match create_result { Ok(()) => { + #[cfg(unix)] + let root = match rustix::fs::openat( + &parent_descriptor, + &name, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(root) => root, + Err(error) => { + let _ = + rustix::fs::unlinkat(&parent_descriptor, &name, AtFlags::REMOVEDIR); + return Err(ReplicaMaterializationError::Staging(error.into())); + } + }; + #[cfg(unix)] + if let Err(error) = rustix::fs::fchmod(&root, Mode::from_raw_mode(0o700)) { + let _ = rustix::fs::unlinkat(&parent_descriptor, &name, AtFlags::REMOVEDIR); + return Err(ReplicaMaterializationError::Staging(error.into())); + } + #[cfg(not(unix))] make_directory_writable(&path).map_err(ReplicaMaterializationError::Staging)?; return Ok(Self { path, + #[cfg(unix)] + parent: parent_descriptor, + #[cfg(unix)] + root, + #[cfg(unix)] + name, published: false, }); } @@ -795,6 +1011,185 @@ impl StagingDirectory { &self.path } + #[cfg(unix)] + fn open_or_create_directory(&self, logical_path: &str) -> io::Result { + let mut current = None; + for component in logical_path.split('/') { + let parent = current + .as_ref() + .map(AsFd::as_fd) + .unwrap_or_else(|| self.root.as_fd()); + let child = match rustix::fs::openat( + parent, + component, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(child) => child, + Err(rustix::io::Errno::NOENT) => { + match rustix::fs::mkdirat(parent, component, Mode::from_raw_mode(0o700)) { + Ok(()) | Err(rustix::io::Errno::EXIST) => {} + Err(error) => return Err(error.into()), + } + open_directory_at(parent, component)? + } + Err(error) => return Err(error.into()), + }; + current = Some(child); + } + current.ok_or_else(|| io::Error::other("empty logical directory path")) + } + + #[cfg(unix)] + fn create_directory(&self, logical_path: &str) -> Result<(), ReplicaMaterializationError> { + self.open_or_create_directory(logical_path) + .map(|_| ()) + .map_err(|source| ReplicaMaterializationError::Write { + path: self.path.join(logical_path), + source, + }) + } + + #[cfg(not(unix))] + fn create_directory(&self, logical_path: &str) -> Result<(), ReplicaMaterializationError> { + let path = self.path.join(logical_path); + fs::create_dir_all(&path) + .map_err(|source| ReplicaMaterializationError::Write { path, source }) + } + + #[cfg(unix)] + fn write_file( + &self, + logical_path: &str, + reader: &mut R, + expected_size: u64, + ) -> Result<(), ReplicaMaterializationError> { + let target = self.path.join(logical_path); + let (parent_path, name) = logical_path + .rsplit_once('/') + .map_or((None, logical_path), |(parent, name)| (Some(parent), name)); + let parent = parent_path + .map(|parent| self.open_or_create_directory(parent)) + .transpose() + .map_err(|source| ReplicaMaterializationError::Write { + path: target.clone(), + source, + })?; + let directory = parent + .as_ref() + .map(AsFd::as_fd) + .unwrap_or_else(|| self.root.as_fd()); + let descriptor = rustix::fs::openat( + directory, + name, + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_raw_mode(0o600), + ) + .map_err(|source| ReplicaMaterializationError::Write { + path: target.clone(), + source: source.into(), + })?; + let mut file = fs::File::from(descriptor); + let written = + io::copy(reader, &mut file).map_err(|source| ReplicaMaterializationError::Write { + path: target.clone(), + source, + })?; + if written != expected_size { + return Err(ReplicaMaterializationError::MalformedTar(format!( + "entry `{}` ended after {written} of {expected_size} bytes", + target.display() + ))); + } + file.flush() + .map_err(|source| ReplicaMaterializationError::Write { + path: target.clone(), + source, + })?; + rustix::fs::fchmod(&file, Mode::from_raw_mode(0o444)).map_err(|source| { + ReplicaMaterializationError::Write { + path: target, + source: source.into(), + } + }) + } + + #[cfg(not(unix))] + fn write_file( + &self, + logical_path: &str, + reader: &mut R, + expected_size: u64, + ) -> Result<(), ReplicaMaterializationError> { + let path = self.path.join(logical_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| ReplicaMaterializationError::Write { + path: parent.to_path_buf(), + source, + })?; + } + write_file_at_path(&path, reader, expected_size) + } + + #[cfg(unix)] + fn publish(&mut self, destination: &Path) -> Result<(), ReplicaMaterializationError> { + let parent_path = self + .path + .parent() + .ok_or(ReplicaMaterializationError::InvalidDestination)? + .to_path_buf(); + if destination.parent() != Some(parent_path.as_path()) { + return Err(ReplicaMaterializationError::InvalidDestination); + } + let destination_name = destination + .file_name() + .ok_or(ReplicaMaterializationError::InvalidDestination)?; + let root_identity = rustix::fs::fstat(&self.root) + .map_err(|error| ReplicaMaterializationError::Publish(error.into()))?; + let parent_identity = rustix::fs::fstat(&self.parent) + .map_err(|error| ReplicaMaterializationError::Publish(error.into()))?; + let named_parent = rustix::fs::stat(&parent_path) + .map_err(|error| ReplicaMaterializationError::Publish(error.into()))?; + if !same_file_identity(&parent_identity, &named_parent) + || !named_entry_matches(&self.parent, &self.name, &root_identity).unwrap_or(false) + { + return Err(ReplicaMaterializationError::Publish(io::Error::other( + "replica staging root identity changed before publication", + ))); + } + + if let Err(error) = rename_directory_noreplace(&self.parent, &self.name, destination_name) { + if error.kind() == io::ErrorKind::AlreadyExists { + return Err(ReplicaMaterializationError::DestinationExists( + destination.to_path_buf(), + )); + } + return Err(ReplicaMaterializationError::Publish(error)); + } + self.name = destination_name.to_os_string(); + self.path = destination.to_path_buf(); + + if !named_entry_matches(&self.parent, &self.name, &root_identity).unwrap_or(false) { + return Err(ReplicaMaterializationError::Publish(io::Error::other( + "replica staging root identity changed during publication", + ))); + } + rustix::fs::fchmod(&self.root, Mode::from_raw_mode(0o555)) + .map_err(|error| ReplicaMaterializationError::Publish(error.into()))?; + let named_parent = rustix::fs::stat(&parent_path) + .map_err(|error| ReplicaMaterializationError::Publish(error.into()))?; + if !same_file_identity(&parent_identity, &named_parent) + || !named_entry_matches(&self.parent, &self.name, &root_identity).unwrap_or(false) + { + return Err(ReplicaMaterializationError::Publish(io::Error::other( + "replica staging root identity changed while publication was finalized", + ))); + } + self.published = true; + Ok(()) + } + + #[cfg(not(unix))] fn publish(&mut self, destination: &Path) -> Result<(), ReplicaMaterializationError> { match fs::rename(&self.path, destination) { Ok(()) => { @@ -813,8 +1208,15 @@ impl StagingDirectory { impl Drop for StagingDirectory { fn drop(&mut self) { if !self.published { - make_tree_removable(&self.path); - let _ = fs::remove_dir_all(&self.path); + #[cfg(unix)] + { + remove_open_staging_directory(&self.parent, &self.root, &self.name); + } + #[cfg(not(unix))] + { + make_tree_removable(&self.path); + let _ = fs::remove_dir_all(&self.path); + } } } } diff --git a/crates/localityd/tests/replica_materializer.rs b/crates/localityd/tests/replica_materializer.rs index a820a36d..ffceef92 100644 --- a/crates/localityd/tests/replica_materializer.rs +++ b/crates/localityd/tests/replica_materializer.rs @@ -1,6 +1,10 @@ use std::fs; use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::sync::Arc; +#[cfg(unix)] +use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicU64, Ordering}; use localityd::remote_truth::{ReplicaArchive, ReplicaArchiveEncoding}; @@ -219,6 +223,164 @@ impl Read for ChunkedReader { } } +#[cfg(unix)] +struct ComponentSwapReader { + inner: Cursor>, + staging_parent: PathBuf, + outside: PathBuf, + swapped: Arc, +} + +#[cfg(unix)] +impl Read for ComponentSwapReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + const FIRST_DIRECTORY_HEADER_END: u64 = 512; + if self.inner.position() == FIRST_DIRECTORY_HEADER_END + && !self.swapped.swap(true, Ordering::SeqCst) + { + let staging = fs::read_dir(&self.staging_parent)? + .filter_map(Result::ok) + .find(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".locality-stage-") + }) + .expect("private staging directory exists before archive extraction"); + let component = staging.path().join("legitimate"); + assert!(component.is_dir(), "first tar entry created its directory"); + fs::remove_dir(&component)?; + std::os::unix::fs::symlink(&self.outside, component)?; + } + + let remaining_before_swap = + FIRST_DIRECTORY_HEADER_END.saturating_sub(self.inner.position()); + let allowed = if remaining_before_swap == 0 { + buffer.len() + } else { + buffer.len().min(remaining_before_swap as usize) + }; + self.inner.read(&mut buffer[..allowed]) + } +} + +#[cfg(unix)] +struct RootSwapReader { + inner: Cursor>, + staging_parent: PathBuf, + outside: PathBuf, + swapped: Arc, +} + +#[cfg(unix)] +impl Read for RootSwapReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let read = self.inner.read(buffer)?; + if read == 0 && !self.swapped.swap(true, Ordering::SeqCst) { + let staging = fs::read_dir(&self.staging_parent)? + .filter_map(Result::ok) + .find(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".locality-stage-") + }) + .expect("private staging directory exists before publication"); + let detached = self.staging_parent.join("attacker-detached-staging"); + fs::rename(staging.path(), &detached)?; + std::os::unix::fs::symlink(&self.outside, staging.path())?; + } + Ok(read) + } +} + +#[cfg(unix)] +#[test] +fn rejects_component_replaced_by_symlink_without_writing_or_chmodding_outside() { + use std::os::unix::fs::PermissionsExt; + + let root = TestDirectory::new("component-swap"); + let outside = TestDirectory::new("component-swap-outside"); + fs::write(outside.0.join("sentinel.txt"), b"outside\n").expect("write outside sentinel"); + fs::set_permissions(&outside.0, fs::Permissions::from_mode(0o711)) + .expect("set distinctive outside mode"); + let swapped = Arc::new(AtomicBool::new(false)); + let archive = tar_archive(&[ + TestMember::directory("legitimate/"), + TestMember::file("legitimate/escaped.txt", "must stay contained\n"), + ]); + let reader = ComponentSwapReader { + inner: Cursor::new(archive), + staging_parent: root.0.clone(), + outside: outside.0.clone(), + swapped: Arc::clone(&swapped), + }; + + let error = materialize_replica_archive( + ReplicaArchive::new(ReplicaArchiveEncoding::Identity, reader), + &root.destination(), + ReplicaMaterializationLimits::default(), + ) + .expect_err("a symlink substituted for an opened component must be rejected"); + + assert!(swapped.load(Ordering::SeqCst), "test performed the swap"); + assert!( + error.to_string().contains("legitimate/escaped.txt"), + "rejection identifies the affected logical path: {error}" + ); + root.assert_no_staging_or_destination(); + assert_eq!( + fs::read(outside.0.join("sentinel.txt")).expect("read untouched sentinel"), + b"outside\n" + ); + assert!(!outside.0.join("escaped.txt").exists()); + assert_modes(&outside.0, 0o711); +} + +#[cfg(unix)] +#[test] +fn rejects_staging_root_replaced_by_symlink_before_publication() { + use std::os::unix::fs::PermissionsExt; + + let root = TestDirectory::new("root-swap"); + let outside = TestDirectory::new("root-swap-outside"); + fs::write(outside.0.join("sentinel.txt"), b"outside\n").expect("write outside sentinel"); + fs::set_permissions(&outside.0, fs::Permissions::from_mode(0o711)) + .expect("set distinctive outside mode"); + let swapped = Arc::new(AtomicBool::new(false)); + let reader = RootSwapReader { + inner: Cursor::new(tar_archive(&[TestMember::file( + "payload.txt", + "must stay in the held staging root\n", + )])), + staging_parent: root.0.clone(), + outside: outside.0.clone(), + swapped: Arc::clone(&swapped), + }; + + let error = materialize_replica_archive( + ReplicaArchive::new(ReplicaArchiveEncoding::Identity, reader), + &root.destination(), + ReplicaMaterializationLimits::default(), + ) + .expect_err("a symlink substituted for the staging root must be rejected"); + + assert!(swapped.load(Ordering::SeqCst), "test performed the swap"); + assert!( + error + .to_string() + .contains("staging root identity changed before publication"), + "rejection identifies the root publication race: {error}" + ); + root.assert_no_staging_or_destination(); + assert_eq!( + fs::read(outside.0.join("sentinel.txt")).expect("read untouched sentinel"), + b"outside\n" + ); + assert!(!outside.0.join("payload.txt").exists()); + assert_modes(&outside.0, 0o711); +} + #[test] fn single_frame_zstd_stream_materializes_from_small_chunks() { let root = TestDirectory::new("zstd"); diff --git a/docs/cloud-sandbox-data-plane.md b/docs/cloud-sandbox-data-plane.md index 013ae960..ec43e0f8 100644 --- a/docs/cloud-sandbox-data-plane.md +++ b/docs/cloud-sandbox-data-plane.md @@ -2292,6 +2292,14 @@ POST /v1/changesets/{id}/cancel POST /v1/search # current policy, bounded results ``` +`POST /v1/sessions` uses an `Idempotency-Key` derived once from the opaque +bootstrap token and reused only for bounded ambiguous-response retries. An +exact replay during the short recovery window returns the original session, +capability, and expiry; it cannot create a second session or extend authority. +The backend derives the recoverable capability with a domain-separated keyed +hash and PostgreSQL stores only token, idempotency-key, and capability hashes, +never the plaintext values or an encrypted response blob. + An SSE/revision-delta endpoint is a later desktop/long-session extension, not a sandbox v1 endpoint. Human-review and advisory-claim endpoints are later extensions to the changeset/session state machines, not v1 placeholders. diff --git a/docs/notion-connector.md b/docs/notion-connector.md index e56c7f26..009a4ae9 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -188,6 +188,49 @@ When Locality writes a Notion page into a local projection, media blocks with `e The media tree mirrors the Notion page directory under the reserved `.loc/` namespace in the projection output root. This keeps binary files out of content directories while giving agents a stable local file they can open, and avoids collision with a projected Notion page or database named `media`. Locality records downloaded media metadata and checksums in `.loc/media/manifest.json` using mount-relative paths. `loc status`, `loc inspect`, `loc diff`, and `loc push` treat equivalent relative and projection-output-root absolute media hrefs as the same asset. If the resolved local media path, bytes, or caption changes, `loc diff` plans an `update_media` operation and `loc push` uploads the local file to the existing Notion media block. Appending a new Markdown image or link whose href resolves under the projection output root's `.loc/media/` tree uploads that file and creates a Notion image, video, audio, PDF, or generic file block based on the file MIME type. Single-part uploads are capped at 20 MB until multipart upload support exists. +Portable hosted-media capture is a separate, opt-in read policy. The default +portable path and every direct desktop/headless path keep the behavior above: +portable pages containing media remain explicitly incomplete, while direct +projection continues to download through the existing media manifest workflow. +Enabling the hosted-media pilot on `NotionConnector` does not create or consume +that manifest. Instead, portable fetch captures Notion-hosted `file` payloads +into a versioned sanitized native page bundle using canonical padded base64 for +bounded byte overhead, and portable render emits one binary projection per +successful asset at the exact same +`.loc/media//...` path used by desktop rendering. The page Markdown +uses that local path, and each binary supports only `read` and +`download_attachment`. + +Signed query parameters, URL fragments, and Notion expiry timestamps are +removed before native serialization. Failed or uncaptured payloads have no +remote URL in native or rendered Markdown and make the result incomplete. +The same recursive sanitization covers opaque Notion JSON fields, including +formula and rollup results and raw AI/custom block payloads. A non-secret marker +binds each such redaction to an exact incomplete outcome; portable render +recomputes that outcome ledger and rejects missing, duplicate, spurious, or +mismatched entries before producing an artifact. Durable v1 native JSON is +canonical: render streams a byte-for-byte reserialization comparison and +rejects unknown fields, alternate field order, or noncanonical whitespace. +External media stays unsupported and incomplete in this policy. Production +capture accepts HTTPS on the default port only, rejects user information and IP +literals, and permits exactly these origins: + +- `secure.notion-static.com`; +- `prod-files-secure.s3.us-west-2.amazonaws.com`; +- `s3.us-west-2.amazonaws.com` only below `/secure.notion-static.com/`. + +Redirects are disabled in the HTTP client and followed manually at most three +times, with every destination revalidated. Responses must use absent or +`identity` content encoding. The pilot uses a five-second connection timeout, a +30-second overall request deadline, reads at most 64 KiB per streaming read, +and enforces 128 assets per page, 20 MiB per asset, and 100 MiB total captured +bytes. Asset shape, identity, and count are preflighted before downloading. An +over-limit page is fully sanitized and explicitly incomplete without issuing a +media request or publishing binary projections. Its asset bookkeeping stays +bounded at one page-level `asset_limit_exceeded` outcome, plus any independent +opaque-JSON redaction outcomes. Missing, expired, duplicate, oversized, denied, +or failed media cannot silently fall back to a provider URL. + ## Path Projection Root-page mounts use a stable directory shape based on the remote title: @@ -198,6 +241,21 @@ to the same filesystem-equivalent path, all colliding siblings use a short remote ID suffix such as `Exact Page Title aaaaaa/page.md`. The suffix lengthens only when needed to keep sibling names unique. +Portable and direct enumeration can also use an explicit set of up to 16 page +or full-page database roots. Those roots are retrieved by ID without search and +are projected together by the same root allocator and recursive traversal as +the desktop workspace view, including title collision suffixes. The configured +and requested root sets must match exactly; empty, duplicate, overlapping, or +ambiguous roots fail closed. Every set-mode source change carries exactly one +`SourceObject.edges` entry with relationship `locality_scope_root` and the +canonical owning root as its target, including a self-edge for each root. +Consumers decode it with `portable_scope_root_remote_id`; mixed or multiple +owning-root edges fail closed. +Notion does not expose reliable teamspace/private membership here, so Locality +does not infer it. Database and media coverage remains explicitly incomplete. +Legacy `with_root_page_id` synchronization keeps its v1 checkpoint format; +`with_root_ids` uses component-versioned v2 root-set checkpoints. + Each Notion page is a directory. The page body lives in `page.md`; sibling entries in the same directory are child Notion content: ```text