From 50f7d5e9d7ec24e4408bf869be14c029d093b32e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:08:40 -0700 Subject: [PATCH 01/13] Bump js-yaml from 4.3.1 to 4.3.2 in /tools/WinGetLogViewer (#6522) --- tools/WinGetLogViewer/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/WinGetLogViewer/package-lock.json b/tools/WinGetLogViewer/package-lock.json index 657e1a1afd..102ceb0326 100644 --- a/tools/WinGetLogViewer/package-lock.json +++ b/tools/WinGetLogViewer/package-lock.json @@ -2117,9 +2117,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { From 8902e3c4eae3bd0dc0affa40080a6b84beb15f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Flor=20Chac=C3=B3n?= <14323496+florelis@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:31:16 -0700 Subject: [PATCH 02/13] Use custom build pool with additional resources (#6521) Created a custom pool for our build pipeline that has additional resources compared to the standard Microsoft-hosted agents. This should help with the recurring issues of running out of disk space. Only using the new pool for the build jobs, since they are the ones that need more resources and I ran into issues getting the tests working in the new pool. ###### Microsoft Reviewers: [Open in CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/microsoft/winget-cli/pull/6521) --- azure-pipelines.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 908444fec1..aefe720813 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -46,6 +46,9 @@ jobs: timeoutInMinutes: 120 dependsOn: 'GetReleaseTag' condition: always() + # Use custom agent with additional resources. + pool: + name: 1es-pool-winget strategy: matrix: @@ -135,7 +138,7 @@ jobs: /p:AppxPackageDir="$(appxPackageDir)" /p:AppxBundle=Always /p:UapAppxPackageBuildMode=SideloadOnly - /p:WingetCleanIntermediateFiles=true + /p:PreferredToolArchitecture=x64 /p:WingetDisableIncrementalLinkTimeCodeGeneration=true' maximumCpuCount: true @@ -681,6 +684,9 @@ jobs: - job: 'Fuzzing' timeoutInMinutes: 60 condition: not(eq(variables['Build.Reason'], 'PullRequest')) + # Use custom agent with additional resources. + pool: + name: 1es-pool-winget strategy: matrix: From 5b62860167520b1503b3880d5a026809eb07c6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Flor=20Chac=C3=B3n?= <14323496+florelis@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:28:46 -0700 Subject: [PATCH 03/13] Remove package-lock.json for log viewer tool (#6524) The log viewer is not used and is not built in any pipeline, so it doesn't seem worth the overhead to keep the dependencies pinned to an up to date version. --- tools/WinGetLogViewer/package-lock.json | 3862 ----------------------- 1 file changed, 3862 deletions(-) delete mode 100644 tools/WinGetLogViewer/package-lock.json diff --git a/tools/WinGetLogViewer/package-lock.json b/tools/WinGetLogViewer/package-lock.json deleted file mode 100644 index 102ceb0326..0000000000 --- a/tools/WinGetLogViewer/package-lock.json +++ /dev/null @@ -1,3862 +0,0 @@ -{ - "name": "winget-log-viewer", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "winget-log-viewer", - "version": "0.1.0", - "license": "MIT", - "devDependencies": { - "@types/node": "20.x", - "@types/vscode": "^1.85.0", - "@vscode/vsce": "^3.9.2", - "typescript": "^5.3.0" - }, - "engines": { - "vscode": "^1.85.0" - } - }, - "node_modules/@azu/format-text": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", - "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@azu/style-format": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", - "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", - "dev": true, - "license": "WTFPL", - "dependencies": { - "@azu/format-text": "^1.0.1" - } - }, - "node_modules/@azure/abort-controller": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", - "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-auth": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", - "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-util": "^1.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-client": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", - "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-rest-pipeline": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", - "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-tracing": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", - "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-util": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", - "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/identity": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", - "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.2", - "@azure/core-rest-pipeline": "^1.17.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^5.5.0", - "@azure/msal-node": "^5.1.0", - "open": "^10.1.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/logger": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", - "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/msal-browser": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.6.3.tgz", - "integrity": "sha512-sTjMtUm+bJpENU/1WlRzHEsgEHppZDZ1EtNyaOODg/sQBtMxxJzGB+MOCM+T2Q5Qe1fKBrdxUmjyRxm0r7Ez9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/msal-common": "16.4.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-common": { - "version": "16.4.1", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.4.1.tgz", - "integrity": "sha512-Bl8f+w37xkXsYh7QRkAKCFGYtWMYuOVO7Lv+BxILrvGz3HbIEF22Pt0ugyj0QPOl6NLrHcnNUQ9yeew98P/5iw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-node": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.2.tgz", - "integrity": "sha512-toS+2AePxqyzb0YOKttDOOiSl3jrkK9aiqIvpurpis0O34QcIS5gToqrgT39p04Dpxw3YoUU0lxJKTpSFFfA6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/msal-common": "16.6.2", - "jsonwebtoken": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { - "version": "16.6.2", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.2.tgz", - "integrity": "sha512-hQjjsekAjB00cM1EmatWJlzhEoK2Qhz7Rj5gvM6tYf8iL7RM3tkxlpU9fG0+ofkulzg9AEEA6dIEnSmDr5ZqUA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@secretlint/config-creator": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", - "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/types": "^10.2.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/config-loader": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", - "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/profiler": "^10.2.2", - "@secretlint/resolver": "^10.2.2", - "@secretlint/types": "^10.2.2", - "ajv": "^8.17.1", - "debug": "^4.4.1", - "rc-config-loader": "^4.1.3" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/core": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", - "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/profiler": "^10.2.2", - "@secretlint/types": "^10.2.2", - "debug": "^4.4.1", - "structured-source": "^4.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/formatter": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", - "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/resolver": "^10.2.2", - "@secretlint/types": "^10.2.2", - "@textlint/linter-formatter": "^15.2.0", - "@textlint/module-interop": "^15.2.0", - "@textlint/types": "^15.2.0", - "chalk": "^5.4.1", - "debug": "^4.4.1", - "pluralize": "^8.0.0", - "strip-ansi": "^7.1.0", - "table": "^6.9.0", - "terminal-link": "^4.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/formatter/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@secretlint/node": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", - "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/config-loader": "^10.2.2", - "@secretlint/core": "^10.2.2", - "@secretlint/formatter": "^10.2.2", - "@secretlint/profiler": "^10.2.2", - "@secretlint/source-creator": "^10.2.2", - "@secretlint/types": "^10.2.2", - "debug": "^4.4.1", - "p-map": "^7.0.3" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/profiler": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", - "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", - "dev": true, - "license": "MIT" - }, - "node_modules/@secretlint/resolver": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", - "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@secretlint/secretlint-formatter-sarif": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", - "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "node-sarif-builder": "^3.2.0" - } - }, - "node_modules/@secretlint/secretlint-rule-no-dotenv": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", - "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/types": "^10.2.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/secretlint-rule-preset-recommend": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", - "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/source-creator": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", - "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/types": "^10.2.2", - "istextorbinary": "^9.5.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@secretlint/types": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", - "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@textlint/ast-node-types": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.5.4.tgz", - "integrity": "sha512-bVtB6VEy9U9DpW8cTt25k5T+lz86zV5w6ImePZqY1AXzSuPhqQNT77lkMPxonXzUducEIlSvUu3o7sKw3y9+Sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/linter-formatter": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.5.4.tgz", - "integrity": "sha512-D9qJedKBLmAo+kiudop4UKgSxXMi4O8U86KrCidVXZ9RsK0NSVIw6+r2rlMUOExq79iEY81FRENyzmNVRxDBsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azu/format-text": "^1.0.2", - "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "15.5.4", - "@textlint/resolver": "15.5.4", - "@textlint/types": "15.5.4", - "chalk": "^4.1.2", - "debug": "^4.4.3", - "js-yaml": "^4.1.1", - "lodash": "^4.18.1", - "pluralize": "^2.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "table": "^6.9.0", - "text-table": "^0.2.0" - } - }, - "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@textlint/linter-formatter/node_modules/pluralize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", - "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@textlint/module-interop": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.5.4.tgz", - "integrity": "sha512-JyAUd26ll3IFF87LP0uGoa8Tzw5ZKiYvGs6v8jLlzyND1lUYCI4+2oIAslrODLkf0qwoCaJrBQWM3wsw+asVGQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/resolver": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.5.4.tgz", - "integrity": "sha512-5GUagtpQuYcmhlOzBGdmVBvDu5lKgVTjwbxtdfoidN4OIqblIxThJHHjazU+ic+/bCIIzI2JcOjHGSaRmE8Gcg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/types": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.5.4.tgz", - "integrity": "sha512-mY28j2U7nrWmZbxyKnRvB8vJxJab4AxqOobLfb6iozrLelJbqxcOTvBQednadWPfAk9XWaZVMqUr9Nird3mutg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@textlint/ast-node-types": "15.5.4" - } - }, - "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/sarif": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", - "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/vscode": { - "version": "1.115.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.115.0.tgz", - "integrity": "sha512-/M8cdznOlqtMqduHKKlIF00v4eum4ZWKgn8YoPRKcN6PDdvoWeeqDaQSnw63ipDbq1Uzz78Wndk/d0uSPwORfA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", - "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@vscode/vsce": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", - "integrity": "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/identity": "^4.1.0", - "@secretlint/node": "^10.1.2", - "@secretlint/secretlint-formatter-sarif": "^10.1.2", - "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", - "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", - "@vscode/vsce-sign": "^2.0.0", - "azure-devops-node-api": "^12.5.0", - "chalk": "^4.1.2", - "cheerio": "^1.0.0-rc.9", - "cockatiel": "^3.1.2", - "commander": "^12.1.0", - "form-data": "^4.0.0", - "glob": "^13.0.6", - "hosted-git-info": "^4.0.2", - "jsonc-parser": "^3.2.0", - "leven": "^3.1.0", - "markdown-it": "^14.1.0", - "mime": "^1.3.4", - "minimatch": "^10.2.2", - "parse-semver": "^1.1.1", - "read": "^1.0.7", - "secretlint": "^10.1.2", - "semver": "^7.5.2", - "tmp": "^0.2.3", - "typed-rest-client": "^1.8.4", - "url-join": "^4.0.1", - "xml2js": "^0.5.0", - "yauzl": "^3.2.1", - "yazl": "^2.2.2" - }, - "bin": { - "vsce": "vsce" - }, - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "keytar": "^7.7.0" - } - }, - "node_modules/@vscode/vsce-sign": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", - "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", - "dev": true, - "hasInstallScript": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optionalDependencies": { - "@vscode/vsce-sign-alpine-arm64": "2.0.6", - "@vscode/vsce-sign-alpine-x64": "2.0.6", - "@vscode/vsce-sign-darwin-arm64": "2.0.6", - "@vscode/vsce-sign-darwin-x64": "2.0.6", - "@vscode/vsce-sign-linux-arm": "2.0.6", - "@vscode/vsce-sign-linux-arm64": "2.0.6", - "@vscode/vsce-sign-linux-x64": "2.0.6", - "@vscode/vsce-sign-win32-arm64": "2.0.6", - "@vscode/vsce-sign-win32-x64": "2.0.6" - } - }, - "node_modules/@vscode/vsce-sign-alpine-arm64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", - "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "alpine" - ] - }, - "node_modules/@vscode/vsce-sign-alpine-x64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", - "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "alpine" - ] - }, - "node_modules/@vscode/vsce-sign-darwin-arm64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", - "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@vscode/vsce-sign-darwin-x64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", - "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@vscode/vsce-sign-linux-arm": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", - "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@vscode/vsce-sign-linux-arm64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", - "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@vscode/vsce-sign-linux-x64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", - "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@vscode/vsce-sign-win32-arm64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", - "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@vscode/vsce-sign-win32-x64": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", - "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "SEE LICENSE IN LICENSE.txt", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/azure-devops-node-api": { - "version": "12.5.0", - "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", - "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", - "dev": true, - "license": "MIT", - "dependencies": { - "tunnel": "0.0.6", - "typed-rest-client": "^1.8.4" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/binaryextensions": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", - "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", - "dev": true, - "license": "Artistic-2.0", - "dependencies": { - "editions": "^6.21.0" - }, - "engines": { - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/boundary": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", - "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cheerio": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", - "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "encoding-sniffer": "^0.2.1", - "htmlparser2": "^10.1.0", - "parse5": "^7.3.0", - "parse5-htmlparser2-tree-adapter": "^7.1.0", - "parse5-parser-stream": "^7.1.2", - "undici": "^7.19.0", - "whatwg-mimetype": "^4.0.0" - }, - "engines": { - "node": ">=20.18.1" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" - } - }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/cockatiel": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", - "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "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", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/editions": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", - "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", - "dev": true, - "license": "Artistic-2.0", - "dependencies": { - "version-range": "^4.15.0" - }, - "engines": { - "ecmascript": ">= es5", - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/encoding-sniffer": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", - "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "^0.6.3", - "whatwg-encoding": "^3.1.1" - }, - "funding": { - "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, - "license": "(MIT OR WTFPL)", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globby": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", - "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.3", - "path-type": "^6.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/index-to-position": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/istextorbinary": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", - "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", - "dev": true, - "license": "Artistic-2.0", - "dependencies": { - "binaryextensions": "^6.11.0", - "editions": "^6.21.0", - "textextensions": "^6.11.0" - }, - "engines": { - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "dev": true, - "license": "MIT", - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/keytar": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", - "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^4.3.0", - "prebuild-install": "^7.0.1" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/linkify-it": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", - "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.1", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "optional": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true, - "license": "ISC" - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/node-abi": { - "version": "3.89.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", - "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", - "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/node-sarif-builder": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", - "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/sarif": "^2.1.7", - "fs-extra": "^11.1.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^7.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/normalize-package-data/node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/normalize-package-data/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-semver": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", - "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^5.1.0" - } - }, - "node_modules/parse-semver/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "domhandler": "^5.0.3", - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-parser-stream": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", - "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/path-type": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", - "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "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": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc-config-loader": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", - "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "js-yaml": "^4.1.1", - "json5": "^2.2.3", - "require-from-string": "^2.0.2" - } - }, - "node_modules/read": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", - "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "mute-stream": "~0.0.4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/read-pkg": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", - "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.3", - "normalize-package-data": "^6.0.0", - "parse-json": "^8.0.0", - "type-fest": "^4.6.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/secretlint": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", - "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/config-creator": "^10.2.2", - "@secretlint/formatter": "^10.2.2", - "@secretlint/node": "^10.2.2", - "@secretlint/profiler": "^10.2.2", - "debug": "^4.4.1", - "globby": "^14.1.0", - "read-pkg": "^9.0.1" - }, - "bin": { - "secretlint": "bin/secretlint.js" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/structured-source": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", - "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boundary": "^2.0.0" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-hyperlinks": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", - "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">=14.18" - }, - "funding": { - "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" - } - }, - "node_modules/table": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", - "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "ajv": "^8.0.1", - "lodash.truncate": "^4.4.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/table/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/table/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/terminal-link": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", - "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "supports-hyperlinks": "^3.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/textextensions": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", - "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", - "dev": true, - "license": "Artistic-2.0", - "dependencies": { - "editions": "^6.21.0" - }, - "engines": { - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" - } - }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.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" - }, - "node_modules/tunnel": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", - "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.11 <=0.7.0 || >=0.7.3" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typed-rest-client": { - "version": "1.8.11", - "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", - "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "qs": "^6.9.1", - "tunnel": "0.0.6", - "underscore": "^1.12.1" - } - }, - "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/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "dev": true, - "license": "MIT" - }, - "node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/url-join": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", - "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", - "dev": true, - "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/version-range": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", - "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", - "dev": true, - "license": "Artistic-2.0", - "engines": { - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" - } - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xml2js": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", - "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/yauzl": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.0.tgz", - "integrity": "sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "pend": "~1.2.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yazl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", - "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3" - } - } - } -} From 3ee73c76eaa637805e2a4956f4a51cc7242a455d Mon Sep 17 00:00:00 2001 From: JohnMcPMS Date: Mon, 21 Sep 2026 09:00:52 -0700 Subject: [PATCH 04/13] Fix dangling view (#6532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📖 Description Fonts added a dynamic extension without changing the return from view to string. --- .../Workflows/DownloadFlow.cpp | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/AppInstallerCLICore/Workflows/DownloadFlow.cpp b/src/AppInstallerCLICore/Workflows/DownloadFlow.cpp index cd24892c44..c4ef50c2ba 100644 --- a/src/AppInstallerCLICore/Workflows/DownloadFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/DownloadFlow.cpp @@ -19,6 +19,7 @@ namespace AppInstaller::CLI::Workflow using namespace AppInstaller::Repository; using namespace AppInstaller::Utility; using namespace AppInstaller::Settings; + using namespace std::string_literals; using namespace std::string_view_literals; namespace @@ -44,7 +45,7 @@ namespace AppInstaller::CLI::Workflow } // Get the file extension to be used for the installer file. - std::wstring_view GetInstallerFileExtension(Execution::Context& context) + std::wstring GetInstallerFileExtension(Execution::Context& context) { const auto& installer = context.Get(); switch (installer->BaseInstallerType) @@ -54,21 +55,21 @@ namespace AppInstaller::CLI::Workflow case InstallerTypeEnum::Inno: case InstallerTypeEnum::Nullsoft: case InstallerTypeEnum::Portable: - return L".exe"sv; + return L".exe"s; case InstallerTypeEnum::Msi: case InstallerTypeEnum::Wix: - return L".msi"sv; + return L".msi"s; case InstallerTypeEnum::Msix: // Note: We may need to distinguish between .msix and .msixbundle in the future. - return L".msix"sv; + return L".msix"s; case InstallerTypeEnum::Zip: - return L".zip"sv; + return L".zip"s; case InstallerTypeEnum::Font: { - const auto& fileName = GetFileNameFromURI(installer->Url); + std::filesystem::path fileName = GetFileNameFromURI(installer->Url); if (fileName.has_extension()) { - return fileName.extension().c_str(); + return fileName.extension().wstring(); } else { @@ -91,7 +92,7 @@ namespace AppInstaller::CLI::Workflow { // Get file name from download URI std::filesystem::path filename = GetFileNameFromURI(context.Get()->Url); - std::wstring_view installerExtension = GetInstallerFileExtension(context); + std::wstring installerExtension = GetInstallerFileExtension(context); // Assuming that we find a safe stem value in the URI, use it. // This should be extremely common, but just in case fall back to the older name style. From a04a4287db013b2497160fa4700b164efea05bf6 Mon Sep 17 00:00:00 2001 From: JohnMcPMS Date: Mon, 21 Sep 2026 13:15:09 -0700 Subject: [PATCH 05/13] Add a debug command to check Store cert pinning (#6519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📖 Description Adds a debug command (`validate-store-pinning`) to check the current Store cert pinning against a target URL. --- .../Commands/DebugCommand.cpp | 166 ++++++++++++++++++ .../Commands/DebugCommand.h | 14 ++ src/AppInstallerCLICore/pch.h | 9 + .../Public/winget/RepositorySource.h | 4 + .../RepositorySource.cpp | 5 + src/AppInstallerSharedLib/Certificates.cpp | 28 +++ .../Public/winget/Certificates.h | 6 + 7 files changed, 232 insertions(+) diff --git a/src/AppInstallerCLICore/Commands/DebugCommand.cpp b/src/AppInstallerCLICore/Commands/DebugCommand.cpp index af596c91c5..7af30965db 100644 --- a/src/AppInstallerCLICore/Commands/DebugCommand.cpp +++ b/src/AppInstallerCLICore/Commands/DebugCommand.cpp @@ -9,8 +9,11 @@ #include #include #include "AppInstallerDownloader.h" +#include #include "Sixel.h" #include +#include +#include using namespace AppInstaller::CLI::Execution; @@ -68,6 +71,7 @@ namespace AppInstaller::CLI std::make_unique(FullName()), std::make_unique(FullName()), std::make_unique(FullName()), + std::make_unique(FullName()), }); } @@ -708,6 +712,168 @@ namespace AppInstaller::CLI context.Reporter.Info() << std::endl; } } + +// ── ValidateStorePinningCommand ────────────────────────────────────────────── + + namespace + { + std::string PercentageToString(double value) + { + std::ostringstream stream; + stream << std::fixed << std::setprecision(1) << (value * 100.0) << '%'; + return std::move(stream).str(); + } + + // The state shared with the certificate validation callback, which is invoked on another thread. + struct StorePinningValidationState + { + std::mutex Lock; + bool CertificateSeen = false; + std::string ChainDescription; + bool PinningAccepted = false; + HRESULT Error = S_OK; + }; + } + + std::vector ValidateStorePinningCommand::GetArguments() const + { + return { + Argument{ "url", 'u', Args::Type::SourceArg, Resource::String::SourceListUpdatedNever, ArgumentType::Positional }, + }; + } + + Resource::LocString ValidateStorePinningCommand::ShortDescription() const + { + return Utility::LocIndString("Validate the Store source certificate pinning"sv); + } + + Resource::LocString ValidateStorePinningCommand::LongDescription() const + { + return Utility::LocIndString( + "Connects to the given URL (defaulting to the Microsoft Store source URL) and validates the " + "certificate that it presents against the current static pinning configuration for the Store source. " + "Use this to determine if a rotated certificate will be accepted before it is deployed."sv); + } + + void ValidateStorePinningCommand::ExecuteInternal(Execution::Context& context) const + { + Repository::SourceDetails storeDetails = Repository::GetWellKnownSourceDetails(Repository::WellKnownSource::MicrosoftStore); + + std::string url{ storeDetails.Arg }; + if (context.Args.Contains(Args::Type::SourceArg)) + { + url = context.Args.GetArg(Args::Type::SourceArg); + } + + if (url.find("://") == std::string::npos) + { + url = "https://" + url; + } + + context.Reporter.Info() << "Validating the Microsoft Store source pinning configuration against: " << url << std::endl; + + if (storeDetails.CertificatePinningConfiguration.IsEmpty()) + { + context.Reporter.Warn() << + "The Store source has no pinning configuration; it has likely been disabled by the " + "BypassCertificatePinningForMicrosoftStore admin setting. Every certificate will be accepted." << std::endl; + } + else + { + context.Reporter.Info() << "Current pinning configuration:" << std::endl << + storeDetails.CertificatePinningConfiguration.GetDescription() << std::endl; + context.Reporter.Info() << "Pinned certificate remaining lifetime: " << + PercentageToString(storeDetails.CertificatePinningConfiguration.GetRemainingLifetimePercentage()) << std::endl; + } + + auto state = std::make_shared(); + + // Accept every certificate so that the pinning result can be reported independently of the connection result. + Certificates::PinningConfiguration captureConfiguration{ "Store Pinning Validation" }; + captureConfiguration.AddChain(std::make_shared( + [state, pinningConfiguration = storeDetails.CertificatePinningConfiguration](PCCERT_CONTEXT certContext) + { + std::lock_guard lock{ state->Lock }; + state->CertificateSeen = true; + + try + { + wil::unique_cert_chain_context chainContext; + + try + { + chainContext = Certificates::PinningConfiguration::BuildCertificateChain(certContext); + } + catch (...) + { + // Revocation information may not be reachable for all endpoints; try again without it. + LOG_CAUGHT_EXCEPTION(); + chainContext = Certificates::PinningConfiguration::BuildCertificateChain(certContext, nullptr, nullptr, 0); + } + + state->ChainDescription = Certificates::GetCertificateChainDescription(chainContext.get()); + state->PinningAccepted = pinningConfiguration.Validate(certContext, chainContext.get()); + } + catch (...) + { + state->Error = LOG_CAUGHT_EXCEPTION(); + } + + return true; + })); + + Http::HttpClientHelper client; + client.SetPinningConfiguration(captureConfiguration, context.GetSharedThreadGlobals()); + + HRESULT connectionResult = S_OK; + std::optional statusCode; + + try + { + statusCode = client.Get(Utility::ConvertToUTF16(url)).get().status_code(); + } + catch (...) + { + connectionResult = LOG_CAUGHT_EXCEPTION(); + } + + if (state->CertificateSeen) + { + context.Reporter.Info() << "Server certificate chain (root first):" << std::endl << state->ChainDescription << std::endl; + } + + if (statusCode) + { + context.Reporter.Info() << "Connection completed with HTTP status: " << statusCode.value() << std::endl; + } + else + { + context.Reporter.Warn() << "Connection failed with: 0x" << Logging::SetHRFormat << connectionResult << std::endl; + } + + if (!state->CertificateSeen) + { + context.Reporter.Error() << "No server certificate was received; the endpoint could not be reached." << std::endl; + AICLI_TERMINATE_CONTEXT(FAILED(connectionResult) ? connectionResult : E_UNEXPECTED); + } + + if (FAILED(state->Error)) + { + context.Reporter.Error() << "Failed to evaluate the certificate against the pinning configuration: 0x" << + Logging::SetHRFormat << state->Error << std::endl; + AICLI_TERMINATE_CONTEXT(state->Error); + } + + if (state->PinningAccepted) + { + context.Reporter.Info() << "Pinning validation: PASSED" << std::endl; + } + else + { + context.Reporter.Error() << "Pinning validation: FAILED" << std::endl; + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_PINNED_CERTIFICATE_MISMATCH); + } + } } #endif diff --git a/src/AppInstallerCLICore/Commands/DebugCommand.h b/src/AppInstallerCLICore/Commands/DebugCommand.h index 907f874a13..e6f80c6757 100644 --- a/src/AppInstallerCLICore/Commands/DebugCommand.h +++ b/src/AppInstallerCLICore/Commands/DebugCommand.h @@ -128,6 +128,20 @@ namespace AppInstaller::CLI protected: void ExecuteInternal(Execution::Context& context) const override; }; + + // Validates the Microsoft Store source certificate pinning configuration against an endpoint. + struct ValidateStorePinningCommand final : public Command + { + ValidateStorePinningCommand(std::string_view parent) : Command("validate-store-pinning", {}, parent) {} + + std::vector GetArguments() const override; + + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; + + protected: + void ExecuteInternal(Execution::Context& context) const override; + }; } #endif diff --git a/src/AppInstallerCLICore/pch.h b/src/AppInstallerCLICore/pch.h index 4c7dcadc31..ee262698ec 100644 --- a/src/AppInstallerCLICore/pch.h +++ b/src/AppInstallerCLICore/pch.h @@ -58,6 +58,15 @@ #include #pragma warning( pop ) +#ifndef WINGET_DISABLE_FOR_FUZZING +#pragma warning( push ) +#pragma warning ( disable : 26495 26439 ) +#include +#include +#include +#pragma warning( pop ) +#endif + #include #include #include diff --git a/src/AppInstallerRepositoryCore/Public/winget/RepositorySource.h b/src/AppInstallerRepositoryCore/Public/winget/RepositorySource.h index f772a8f8a1..a91a1fdb9b 100644 --- a/src/AppInstallerRepositoryCore/Public/winget/RepositorySource.h +++ b/src/AppInstallerRepositoryCore/Public/winget/RepositorySource.h @@ -165,6 +165,10 @@ namespace AppInstaller::Repository // Check if a source matches a well known source std::optional CheckForWellKnownSource(const SourceDetails& sourceDetails); + // Gets the details for a well known source, including its certificate pinning configuration. + // The details are not populated with any locally stored metadata. + SourceDetails GetWellKnownSourceDetails(WellKnownSource source); + // Individual source agreement entry. Label will be highlighted in the display as the key of the agreement entry. struct SourceAgreement { diff --git a/src/AppInstallerRepositoryCore/RepositorySource.cpp b/src/AppInstallerRepositoryCore/RepositorySource.cpp index ac273fc0c8..32dfc5d81f 100644 --- a/src/AppInstallerRepositoryCore/RepositorySource.cpp +++ b/src/AppInstallerRepositoryCore/RepositorySource.cpp @@ -432,6 +432,11 @@ namespace AppInstaller::Repository return CheckForWellKnownSourceMatch(sourceDetails.Name, sourceDetails.Arg, sourceDetails.Type); } + SourceDetails GetWellKnownSourceDetails(WellKnownSource source) + { + return GetWellKnownSourceDetailsInternal(source); + } + Source::Source() {} Source::Source(std::string_view name) diff --git a/src/AppInstallerSharedLib/Certificates.cpp b/src/AppInstallerSharedLib/Certificates.cpp index eca9a866a9..17b4c281af 100644 --- a/src/AppInstallerSharedLib/Certificates.cpp +++ b/src/AppInstallerSharedLib/Certificates.cpp @@ -841,6 +841,34 @@ namespace AppInstaller::Certificates return result; } + std::string PinningConfiguration::GetDescription() const + { + std::ostringstream stream; + stream << "Pinning configuration [" << m_identifier << "]"; + + if (m_configuration.empty()) + { + stream << ": "; + } + else + { + size_t index = 0; + + for (const auto& chain : m_configuration) + { + stream << std::endl << "Chain #" << ++index << ':' << std::endl << chain->GetDescription(); + } + } + + return std::move(stream).str(); + } + + std::string GetCertificateChainDescription(PCCERT_CHAIN_CONTEXT chainContext) + { + THROW_HR_IF(E_INVALIDARG, !chainContext || chainContext->cChain == 0); + return GetDescriptionOfCertChain(chainContext); + } + std::string GetAuthenticodeSubject(const std::filesystem::path& filePath) { const std::wstring& pathStr = filePath.wstring(); diff --git a/src/AppInstallerSharedLib/Public/winget/Certificates.h b/src/AppInstallerSharedLib/Public/winget/Certificates.h index 868098b3fd..a35d5d84bb 100644 --- a/src/AppInstallerSharedLib/Public/winget/Certificates.h +++ b/src/AppInstallerSharedLib/Public/winget/Certificates.h @@ -22,6 +22,9 @@ namespace AppInstaller::Certificates // Returns an empty string if the file is unsigned, untrusted, or on any error. std::string GetAuthenticodeSubject(const std::filesystem::path& filePath); + // Gets a human readable, indented description of the certificates in the given chain, from root to leaf. + std::string GetCertificateChainDescription(PCCERT_CHAIN_CONTEXT chainContext); + // Defines the types of certificate pinning to perform. enum class PinningVerificationType : uint32_t { @@ -254,6 +257,9 @@ namespace AppInstaller::Certificates // Loads the pinning configuration from the given JSON. [[nodiscard]] bool LoadFrom(const Json::Value& configuration); + // Gets a description of the configuration, including all of its chains. + std::string GetDescription() const; + // Determines how far the configuration is through its lifespan (the maximum of all of its chains). double GetRemainingLifetimePercentage() const; From b042b3b681e1ba4b70309c2ada7dc18b6b6b8bcf Mon Sep 17 00:00:00 2001 From: Kaleb Luedtke Date: Mon, 21 Sep 2026 15:44:04 -0500 Subject: [PATCH 06/13] Use hardlinks for portable aliases without renaming originals (#6308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📖 Description This PR updates portable alias handling to preserve original executable filenames and use hardlink aliases instead of renaming files. ### What changed **New `PortableFileType::Hardlink` support** (`src/AppInstallerCommonCore/Public/winget/PortableFileEntry.h`): - Added `Hardlink` enum value to `PortableFileType` - Added `PortableFileEntry::CreateHardlinkEntry(...)` factory method **New `CreateHardlink` filesystem helper** (`src/AppInstallerSharedLib/Public/winget/Filesystem.h`, `src/AppInstallerSharedLib/Filesystem.cpp`): - `CreateHardlink(target, link)` wraps `std::filesystem::create_hard_link` with error handling; returns `false` (instead of throwing) when hardlinks are unsupported **Updated portable flow** (`src/AppInstallerCLICore/Workflows/PortableFlow.cpp`): - Original executable filenames are no longer changed; the file is placed under its original name in the install directory - Alias names (`--rename`, `Commands`, `PortableCommandAlias`) are satisfied by creating a hardlink in the install directory next to the original executable - Symlinks in the Links directory continue to point to the original executable path - For archive portables, hardlinks for nested executables are placed alongside the original extracted file; a `fileHashes` map avoids re-reading files from disk to obtain the SHA256 **Updated portable installer** (`src/AppInstallerCLICore/PortableInstaller.cpp`): - Verify, install, and remove logic now handles `PortableFileType::Hardlink` entries (fallback to file copy if hardlinks are not supported by the volume) - `RemoveFromPathVariable` gains an `onlyIfEmpty` parameter to correctly handle partial-uninstall scenarios where the Links directory still contains symlinks from other packages - Symlink-creation logic correctly transitions the `InstallDirectoryAddedToPath` ARP state: adds install dir to PATH on failure, removes it again when a subsequent symlink succeeds on upgrade - ARP state reconstruction (`InitializeExpectedEntries`) now detects and tracks hardlink aliases so they are removed during uninstall even when state is rebuilt from ARP values **New E2E test manifests** (`src/AppInstallerCLIE2ETests/TestData/Manifests/`): - `TestZipInstaller_Portable_PathFallbackToSymlink` (v1, v2) — package that switches from PATH-based install to symlink on upgrade - `TestZipInstaller_Portable_SymlinkToPathFallback` (v1, v2) — package that switches from symlink to PATH-based install on upgrade **Updated E2E tests**: - `InstallCommand.cs`: 3 new tests verifying hardlink creation for `--rename`, `Commands`, and archive `PortableCommandAlias` - `UninstallCommand.cs`: 3 new tests verifying hardlink cleanup, correct retention/removal of the Links PATH entry when multiple packages share it, and PATH cleanup for archive portables with `ArchiveBinariesDependentOnPath` - `UpgradeCommand.cs`: 2 new tests verifying PATH/symlink state is correctly re-evaluated when a package switches between symlink and PATH-based aliasing across versions - `TestCommon.cs`: minor helper additions - Removed unused `testResult` variable in existing uninstall test **Updated release notes** (`doc/ReleaseNotes.md`) ### User-visible behavior - Portable originals are no longer renamed; the original executable exists alongside its alias in the install directory. - Alias names are provided as hardlinks, so commands work in non-symlinked (PATH-based) scenarios. - When symlink creation fails, the install directory is added to PATH as a fallback; if a subsequent upgrade can create a symlink, the install directory is removed from PATH automatically. - The Links directory is only removed from PATH when it becomes empty (i.e., no other symlinked packages remain). --- .github/actions/spelling/allow.txt | 2 + doc/ReleaseNotes.md | 9 ++ src/AppInstallerCLICore/PortableInstaller.cpp | 92 ++++++++++++-- src/AppInstallerCLICore/PortableInstaller.h | 2 +- .../Workflows/PortableFlow.cpp | 55 +++++++-- .../Helpers/TestCommon.cs | 16 +++ src/AppInstallerCLIE2ETests/InstallCommand.cs | 112 ++++++++++++++++++ ...ortable_PathFallbackToSymlink.2.0.0.0.yaml | 20 ++++ ...taller_Portable_PathFallbackToSymlink.yaml | 21 ++++ ...ortable_SymlinkToPathFallback.2.0.0.0.yaml | 21 ++++ ...taller_Portable_SymlinkToPathFallback.yaml | 20 ++++ .../UninstallCommand.cs | 110 ++++++++++++++++- src/AppInstallerCLIE2ETests/UpgradeCommand.cs | 56 +++++++++ .../Public/winget/PortableFileEntry.h | 14 ++- src/AppInstallerSharedLib/Filesystem.cpp | 25 ++++ .../Public/winget/Filesystem.h | 4 + 16 files changed, 552 insertions(+), 27 deletions(-) create mode 100644 src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_PathFallbackToSymlink.2.0.0.0.yaml create mode 100644 src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_PathFallbackToSymlink.yaml create mode 100644 src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_SymlinkToPathFallback.2.0.0.0.yaml create mode 100644 src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_SymlinkToPathFallback.yaml diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 200d4b29f7..7fe36d24d7 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -123,6 +123,7 @@ hcertstore HCRYPTMSG HGlobal HGLOBAL +hardlinks HIDECANCEL hinternet HKCU @@ -232,6 +233,7 @@ Params params parentidx pathpart +pathing Pathto PBYTE pch diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index 24e427b854..c6cb18fc5c 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -14,6 +14,15 @@ Added a new `--ignore-unavailable` flag to the `install` command. When installin ## Bug Fixes +### Portable installer alias handling + +Portable installs now preserve the original executable filename instead of renaming it when an alias is needed. +For aliases requested through `--rename`, `Commands`, or `PortableCommandAlias`, WinGet creates a hardlink alias and keeps the original file as the source executable. + +This change resolves alias failures in non-symlinked scenarios, including cases where WinGet adds the install directory to `PATH` instead of creating links. +Because the alias is now created as an executable hardlink in the install location, command aliases remain available and consistent even when symlink creation is skipped. + +### Minor Bug Fixes * Fixed an issue where `winget search --id ` could fail to return a Microsoft Store package unless `--exact` was also provided. * Updated NUnit to v4 * Fixed a crash (`0x8000ffff`) when using `--disable-interactivity` with the Resume experimental feature enabled during install operations. diff --git a/src/AppInstallerCLICore/PortableInstaller.cpp b/src/AppInstallerCLICore/PortableInstaller.cpp index a5d42b6410..b596c61ec9 100644 --- a/src/AppInstallerCLICore/PortableInstaller.cpp +++ b/src/AppInstallerCLICore/PortableInstaller.cpp @@ -72,6 +72,22 @@ namespace AppInstaller::CLI::Portable } } } + else if (fileType == PortableFileType::Hardlink) + { + if (std::filesystem::exists(filePath)) + { + // Only verify hash if one was stored + if (!entry.SHA256.empty()) + { + SHA256::HashBuffer fileHash = SHA256::ComputeHashFromFile(filePath); + if (!SHA256::AreEqual(fileHash, SHA256::ConvertToBytes(entry.SHA256))) + { + AICLI_LOG(CLI, Warning, << "Hardlink hash does not match ARP Entry. Expected: " << entry.SHA256 << " Actual: " << SHA256::ConvertToString(fileHash)); + return false; + } + } + } + } else if (fileType == PortableFileType::Symlink) { std::filesystem::path symlinkTargetPath{ AppInstaller::Utility::ConvertToUTF16(entry.SymlinkTarget) }; @@ -122,20 +138,49 @@ namespace AppInstaller::CLI::Portable std::filesystem::copy(entry.CurrentPath, filePath, std::filesystem::copy_options::overwrite_existing | std::filesystem::copy_options::recursive); } } + else if (fileType == PortableFileType::Hardlink) + { + if (std::filesystem::exists(filePath)) + { + AICLI_LOG(Core, Info, << "Removing existing portable hardlink at: " << filePath); + std::filesystem::remove(filePath); + } + + AICLI_LOG(Core, Info, << "Creating hardlink at: " << filePath << " pointing to: " << entry.CurrentPath); + + // Try to create hardlink + if (Filesystem::CreateHardlink(entry.CurrentPath, filePath)) + { + AICLI_LOG(Core, Info, << "Hardlink created successfully at: " << filePath); + } + else + { + // Fallback: copy the file if hardlinks not supported + AICLI_LOG(Core, Info, << "Hardlink creation failed, falling back to copy: " << filePath); + std::filesystem::copy_file(entry.CurrentPath, filePath, std::filesystem::copy_options::overwrite_existing); + } + + if (!RecordToIndex) + { + CommitToARPEntry(PortableValueName::SHA256, entry.SHA256); + } + } else if (entry.FileType == PortableFileType::Symlink) { std::filesystem::path symlinkTargetPath{ Utility::ConvertToUTF16(entry.SymlinkTarget) }; - if (BinariesDependOnPath && !InstallDirectoryAddedToPath) + if (BinariesDependOnPath) { // Scenario indicated by 'ArchiveBinariesDependOnPath' manifest entry. // Skip symlink creation for portables dependent on binaries that require the install directory to be added to PATH. std::filesystem::path installDirectory = symlinkTargetPath.parent_path(); AddToPathVariable(installDirectory); + // Only remove the links directory if there are no links in it + RemoveFromPathVariable(GetPortableLinksLocation(GetScope()), true); AICLI_LOG(Core, Info, << "Install directory added to PATH: " << installDirectory); CommitToARPEntry(PortableValueName::InstallDirectoryAddedToPath, InstallDirectoryAddedToPath = true); } - else if (!InstallDirectoryAddedToPath) + else { std::filesystem::file_status status = std::filesystem::status(filePath); if (std::filesystem::is_directory(status)) @@ -157,6 +202,12 @@ namespace AppInstaller::CLI::Portable if (Filesystem::CreateSymlink(symlinkTargetPath, filePath)) { + if (InstallDirectoryAddedToPath) + { + // If the install directory was previously added to PATH, remove it now that the symlink has been created. + RemoveFromPathVariable(symlinkTargetPath.parent_path(), false); + } + CommitToARPEntry(PortableValueName::InstallDirectoryAddedToPath, InstallDirectoryAddedToPath = false); AICLI_LOG(Core, Info, << "Symlink created at: " << filePath << " with target path: " << symlinkTargetPath); } else @@ -164,6 +215,8 @@ namespace AppInstaller::CLI::Portable // If symlink creation fails, resort to adding the package directory to PATH. AICLI_LOG(Core, Info, << "Failed to create symlink at: " << filePath); AddToPathVariable(symlinkTargetPath.parent_path()); + // Only remove the links directory if there are no links in it + RemoveFromPathVariable(GetPortableLinksLocation(GetScope()), true); CommitToARPEntry(PortableValueName::InstallDirectoryAddedToPath, InstallDirectoryAddedToPath = true); } } @@ -176,9 +229,9 @@ namespace AppInstaller::CLI::Portable const auto& filePath = entry.GetFilePath(); PortableFileType fileType = entry.FileType; - if (fileType == PortableFileType::File && std::filesystem::exists(filePath)) + if ((fileType == PortableFileType::File || fileType == PortableFileType::Hardlink) && std::filesystem::exists(filePath)) { - AICLI_LOG(CLI, Info, << "Deleting portable exe at: " << filePath); + AICLI_LOG(CLI, Info, << "Deleting portable file at: " << filePath); std::filesystem::remove(filePath); } else if (fileType == PortableFileType::Symlink) @@ -191,7 +244,7 @@ namespace AppInstaller::CLI::Portable else if (InstallDirectoryAddedToPath) { // If symlink doesn't exist, check if install directory was added to PATH directly and remove. - RemoveFromPathVariable(std::filesystem::path(Utility::ConvertToUTF16(entry.SymlinkTarget)).parent_path()); + RemoveFromPathVariable(std::filesystem::path(Utility::ConvertToUTF16(entry.SymlinkTarget)).parent_path(), false); } } else if (fileType == PortableFileType::Symlink && Filesystem::SymlinkExists(filePath)) @@ -347,7 +400,12 @@ namespace AppInstaller::CLI::Portable if (!InstallDirectoryAddedToPath) { - RemoveFromPathVariable(GetPortableLinksLocation(GetScope())); + // Only remove the links directory if there are no links in it + RemoveFromPathVariable(GetPortableLinksLocation(GetScope()), true); + } + else + { + RemoveFromPathVariable(InstallLocation, false); } m_portableARPEntry.Delete(); @@ -407,15 +465,15 @@ namespace AppInstaller::CLI::Portable } } - void PortableInstaller::RemoveFromPathVariable(std::filesystem::path value) + void PortableInstaller::RemoveFromPathVariable(std::filesystem::path value, bool onlyIfEmpty) { - if (std::filesystem::exists(value) && !std::filesystem::is_empty(value)) + if (onlyIfEmpty && std::filesystem::exists(value) && !std::filesystem::is_empty(value)) { - AICLI_LOG(Core, Info, << "Install directory is not empty: " << value); + AICLI_LOG(Core, Info, << "Directory is not empty: " << value); } else { - // Attempt to remove both the original and the preferred format to ensure removal + // Attempt to remove both the original and the preferred format to ensure removal // Necessary for handling old path values associated with winget-cli#5033 if (PathVariable(GetScope()).Remove(value) || PathVariable(GetScope()).Remove(value.make_preferred())) { @@ -489,12 +547,22 @@ namespace AppInstaller::CLI::Portable // This is to ensure that the directory is fully uninstalled before attempting to remove from PATH registry. if (!targetFullPath.empty()) { - m_expectedEntries.emplace_back(std::move(PortableFileEntry::CreateFileEntry({}, targetFullPath, SHA256))); + m_expectedEntries.emplace_back(PortableFileEntry::CreateFileEntry({}, targetFullPath, SHA256)); } if (!symlinkFullPath.empty()) { - m_expectedEntries.emplace_back(std::move(PortableFileEntry::CreateSymlinkEntry(symlinkFullPath, targetFullPath))); + // If alias differs from original filename, a hardlink exists in the install directory. + // Track it so uninstall removes it even when state is reconstructed from ARP values. + if (!targetFullPath.empty() && targetFullPath.filename() != symlinkFullPath.filename()) + { + std::filesystem::path hardlinkPath = InstallLocation / symlinkFullPath.filename(); + if (hardlinkPath != targetFullPath && std::filesystem::exists(hardlinkPath)) + { + m_expectedEntries.emplace_back(PortableFileEntry::CreateHardlinkEntry(hardlinkPath, targetFullPath, SHA256)); + } + } + m_expectedEntries.emplace_back(PortableFileEntry::CreateSymlinkEntry(symlinkFullPath, targetFullPath)); } } } diff --git a/src/AppInstallerCLICore/PortableInstaller.h b/src/AppInstallerCLICore/PortableInstaller.h index 9f282efcd0..812a1724ed 100644 --- a/src/AppInstallerCLICore/PortableInstaller.h +++ b/src/AppInstallerCLICore/PortableInstaller.h @@ -114,6 +114,6 @@ namespace AppInstaller::CLI::Portable void RemoveInstallDirectory(); void AddToPathVariable(std::filesystem::path value); - void RemoveFromPathVariable(std::filesystem::path value); + void RemoveFromPathVariable(std::filesystem::path value, bool onlyIfEmpty); }; } diff --git a/src/AppInstallerCLICore/Workflows/PortableFlow.cpp b/src/AppInstallerCLICore/Workflows/PortableFlow.cpp index c14cada640..8d0a0ddfd1 100644 --- a/src/AppInstallerCLICore/Workflows/PortableFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PortableFlow.cpp @@ -4,6 +4,7 @@ #include "PortableFlow.h" #include "PortableInstaller.h" #include "WorkflowBase.h" +#include #include #include #include @@ -196,20 +197,24 @@ namespace AppInstaller::CLI::Workflow { portableInstaller.RecordToIndex = true; + // Build a map of installed target path → SHA256 as file entries are created, + // so nested installer files that need hardlinks can reuse the hash without re-reading from disk. + std::map fileHashes; + for (const auto& entry : std::filesystem::directory_iterator(installerPath)) { std::filesystem::path entryPath = entry.path(); - PortableFileEntry portableFile; std::filesystem::path relativePath = std::filesystem::relative(entryPath, entryPath.parent_path()); std::filesystem::path targetPath = targetInstallDirectory / relativePath; if (std::filesystem::is_directory(entryPath)) { - entries.emplace_back(std::move(PortableFileEntry::CreateDirectoryEntry(entryPath, targetPath))); + entries.emplace_back(PortableFileEntry::CreateDirectoryEntry(entryPath, targetPath)); } else { - entries.emplace_back(std::move(PortableFileEntry::CreateFileEntry(entryPath, targetPath, {}))); + const PortableFileEntry& fileEntry = entries.emplace_back(PortableFileEntry::CreateFileEntry(entryPath, targetPath, {})); + fileHashes[std::filesystem::weakly_canonical(targetPath)] = fileEntry.SHA256; } } @@ -223,12 +228,14 @@ namespace AppInstaller::CLI::Workflow EnsurePathIsRelative(context, nestedInstallerFile.PortableCommandAlias, "PortableCommandAlias", ManifestError::PortableCommandAliasEscapesDirectory); AICLI_RETURN_VALUE_IF_TERMINATED(context, {}); - const std::filesystem::path& targetPath = targetInstallDirectory / ConvertToUTF16(nestedInstallerFile.RelativeFilePath); + const std::filesystem::path& relativeFilePath = ConvertToUTF16(nestedInstallerFile.RelativeFilePath); + const std::filesystem::path& targetPath = targetInstallDirectory / relativeFilePath; + std::filesystem::path originalFilename = targetPath.filename(); std::filesystem::path commandAlias; if (nestedInstallerFile.PortableCommandAlias.empty()) { - commandAlias = targetPath.filename(); + commandAlias = originalFilename; } else { @@ -236,15 +243,29 @@ namespace AppInstaller::CLI::Workflow } Filesystem::AppendExtension(commandAlias, ".exe"); - entries.emplace_back(std::move(PortableFileEntry::CreateSymlinkEntry(symlinkDirectory / commandAlias, targetPath))); + + // If alias differs from original filename, create hardlink + // Hardlink will be placed in the same directory as the original file to avoid pathing issues and same-volume restrictions + if (commandAlias != originalFilename) + { + std::filesystem::path hardlinkPath = targetPath.parent_path() / commandAlias; + auto it = fileHashes.find(std::filesystem::weakly_canonical(targetPath)); + THROW_HR_IF_MSG(APPINSTALLER_CLI_ERROR_PORTABLE_INSTALL_FAILED, it == fileHashes.end(), "Hash not found for hardlink target: %ls", targetPath.c_str()); + entries.emplace_back(PortableFileEntry::CreateHardlinkEntry(hardlinkPath, targetPath, it->second)); + } + entries.emplace_back(PortableFileEntry::CreateSymlinkEntry(symlinkDirectory / commandAlias, targetPath)); } } else { + // Non-archive portable case: single executable file std::string_view renameArg = context.Args.GetArg(Execution::Args::Type::Rename); const std::vector& commands = context.Get()->Commands; - std::filesystem::path commandAlias = installerPath.filename(); + std::filesystem::path originalFilename = installerPath.filename(); + std::filesystem::path commandAlias = originalFilename; + + // Determine the command alias from rename arg, commands, or use original filename if (!commands.empty()) { EnsurePathIsRelative(context, commands[0], "CommandAlias", ManifestError::PortableCommandAliasEscapesDirectory); @@ -257,11 +278,23 @@ namespace AppInstaller::CLI::Workflow { commandAlias = ConvertToUTF16(renameArg); } - AppInstaller::Filesystem::AppendExtension(commandAlias, ".exe"); - const std::filesystem::path& targetFullPath = targetInstallDirectory / commandAlias; - entries.emplace_back(std::move(PortableFileEntry::CreateFileEntry(installerPath, targetFullPath, {}))); - entries.emplace_back(std::move(PortableFileEntry::CreateSymlinkEntry(symlinkDirectory / commandAlias, targetFullPath))); + Filesystem::AppendExtension(commandAlias, ".exe"); + + // Target path for the original file (keeps its original name) + const std::filesystem::path& targetFullPath = targetInstallDirectory / originalFilename; + + // Create file entry for original (with original name) - this computes SHA256 + std::string fileSha256 = Utility::SHA256::ConvertToString(Utility::SHA256::ComputeHashFromFile(installerPath)); + entries.emplace_back(PortableFileEntry::CreateFileEntry(installerPath, targetFullPath, fileSha256)); + + // If alias differs from original filename, create hardlink + if (commandAlias != originalFilename) + { + std::filesystem::path hardlinkPath = targetInstallDirectory / commandAlias; + entries.emplace_back(PortableFileEntry::CreateHardlinkEntry(hardlinkPath, targetFullPath, fileSha256)); + } + entries.emplace_back(PortableFileEntry::CreateSymlinkEntry(symlinkDirectory / commandAlias, targetFullPath)); } return entries; diff --git a/src/AppInstallerCLIE2ETests/Helpers/TestCommon.cs b/src/AppInstallerCLIE2ETests/Helpers/TestCommon.cs index 71eb6d1985..e6e9d4c78b 100644 --- a/src/AppInstallerCLIE2ETests/Helpers/TestCommon.cs +++ b/src/AppInstallerCLIE2ETests/Helpers/TestCommon.cs @@ -522,6 +522,22 @@ public static void VerifyPortablePackage( Assert.That(isAddedToPath, Is.EqualTo(shouldExist), $"Expected path variable: {(installDirectoryAddedToPath ? installDir : symlinkDirectory)}{pathDiagnostics}"); } + /// + /// Check if a path value exists in PATH. + /// + /// Path value. + /// Scope. + /// True if PATH contains the value. + public static bool PathContainsValue(string value, Scope scope = Scope.User) + { + RegistryKey baseKey = scope == Scope.User ? Registry.CurrentUser : Registry.LocalMachine; + string pathSubKey = scope == Scope.User ? Constants.PathSubKey_User : Constants.PathSubKey_Machine; + using RegistryKey environmentRegistryKey = baseKey.OpenSubKey(pathSubKey, true); + string currentPathValue = (string)environmentRegistryKey.GetValue("Path") ?? string.Empty; + string expectedValue = value.TrimEnd('\\') + ';'; + return currentPathValue.Contains(expectedValue, StringComparison.OrdinalIgnoreCase); + } + /// /// Copies log files to the path %TEMP%\E2ETestLogs. /// diff --git a/src/AppInstallerCLIE2ETests/InstallCommand.cs b/src/AppInstallerCLIE2ETests/InstallCommand.cs index 38140aba3a..8da9265ca7 100644 --- a/src/AppInstallerCLIE2ETests/InstallCommand.cs +++ b/src/AppInstallerCLIE2ETests/InstallCommand.cs @@ -35,6 +35,21 @@ public void Setup() TestCommon.RunAICLICommand("uninstall", "AppInstallerTest.TestExeInstaller"); } + /// + /// Tear down. + /// + [TearDown] + public void TearDown() + { + // Portable packages installed by tests in this class. Force-uninstalling them after every + // test guards against leftover state (e.g. stale Links directory PATH entries) affecting + // subsequent tests, even if a test fails before it can clean up after itself. + TestCommon.RunAICLICommand("uninstall", Constants.PortableExePackageId); + TestCommon.RunAICLICommand("uninstall", Constants.PortableExeWithCommandPackageId); + TestCommon.RunAICLICommand("uninstall", "AppInstallerTest.TestZipInstallerWithPortable"); + TestCommon.RunAICLICommand("uninstall", "AppInstallerTest.ArchivePortableWithBinariesDependentOnPath"); + } + /// /// Test package doesn't exist. /// @@ -544,6 +559,103 @@ public void InstallZip_ArchivePortableWithBinariesDependentOnPath() TestCommon.VerifyPortablePackage(Path.Combine(installDir, packageDirName), commandAlias, fileName, productCode, true, TestCommon.Scope.User, true); } + /// + /// Test install portable with rename creates hardlink instead of renaming original. + /// + [Test] + public void InstallPortableWithRename_VerifyHardlink() + { + string installDir = TestCommon.GetPortablePackagesDirectory(); + string packageId, packageDirName, productCode; + packageId = "AppInstallerTest.TestPortableExeWithCommand"; + packageDirName = productCode = packageId + "_" + Constants.TestSourceIdentifier; + string renameArgValue = "customAlias.exe"; + + var result = TestCommon.RunAICLICommand("install", $"{packageId} --rename {renameArgValue}"); + Assert.That(result.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + Assert.That(result.StdOut, Does.Contain("Successfully installed")); + + string installPath = Path.Combine(installDir, packageDirName); + string originalFile = Path.Combine(installPath, "AppInstallerTestExeInstaller.exe"); + string hardlinkFile = Path.Combine(installPath, renameArgValue); + + // Verify original file exists with original name (not renamed) + Assert.That(originalFile, Does.Exist, $"Original file should exist at: {originalFile}"); + + // Verify hardlink exists + Assert.That(hardlinkFile, Does.Exist, $"Hardlink should exist at: {hardlinkFile}"); + + // Verify hardlink and original point to same content (equivalence) + Assert.That(File.ReadAllBytes(hardlinkFile), Is.EqualTo(File.ReadAllBytes(originalFile)), "Hardlink should be equivalent to original file"); + + // Verify uninstall removes both original and hardlink + var uninstallResult = TestCommon.RunAICLICommand("uninstall", $"{packageId}"); + Assert.That(uninstallResult.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + Assert.That(originalFile, Does.Not.Exist, $"Original file should be removed after uninstall"); + Assert.That(hardlinkFile, Does.Not.Exist, $"Hardlink should be removed after uninstall"); + } + + /// + /// Test install portable with Commands field creates hardlinks for all command aliases. + /// + [Test] + public void InstallPortableWithCommands_VerifyHardlinks() + { + string installDir = TestCommon.GetPortablePackagesDirectory(); + string packageId, packageDirName, productCode; + packageId = "AppInstallerTest.TestPortableExeWithCommand"; + packageDirName = productCode = packageId + "_" + Constants.TestSourceIdentifier; + string commandAlias = "testCommand.exe"; + + var result = TestCommon.RunAICLICommand("install", $"{packageId}"); + Assert.That(result.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + Assert.That(result.StdOut, Does.Contain("Successfully installed")); + + string installPath = Path.Combine(installDir, packageDirName); + string originalFile = Path.Combine(installPath, "AppInstallerTestExeInstaller.exe"); + string hardlinkFile = Path.Combine(installPath, commandAlias); + + // Verify original file exists with original name + Assert.That(originalFile, Does.Exist, $"Original file should exist at: {originalFile}"); + + // Verify command alias hardlink exists + Assert.That(hardlinkFile, Does.Exist, $"Command alias hardlink should exist at: {hardlinkFile}"); + + // Verify hardlink is equivalent to original + Assert.That(File.ReadAllBytes(hardlinkFile), Is.EqualTo(File.ReadAllBytes(originalFile)), "Command alias hardlink should be equivalent to original file"); + } + + /// + /// Test install zip portable with PortableCommandAlias creates hardlinks for nested files. + /// + [Test] + public void InstallZip_PortableWithCommandAlias_VerifyHardlinks() + { + string installDir = TestCommon.GetPortablePackagesDirectory(); + string packageId, packageDirName, productCode; + packageId = "AppInstallerTest.TestZipInstallerWithPortable"; + packageDirName = productCode = packageId + "_" + Constants.TestSourceIdentifier; + string originalFileName = "AppInstallerTestExeInstaller.exe"; + string commandAlias = "TestPortable.exe"; + + var result = TestCommon.RunAICLICommand("install", $"{packageId}"); + Assert.That(result.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + Assert.That(result.StdOut, Does.Contain("Successfully installed")); + + string installPath = Path.Combine(installDir, packageDirName); + string originalFile = Path.Combine(installPath, originalFileName); + string hardlinkFile = Path.Combine(installPath, commandAlias); + + // Verify original extracted file exists with original name + Assert.That(originalFile, Does.Exist, $"Original extracted file should exist at: {originalFile}"); + + // Verify hardlink for command alias exists + Assert.That(hardlinkFile, Does.Exist, $"Command alias hardlink should exist at: {hardlinkFile}"); + + // Verify hardlink is equivalent to original + Assert.That(File.ReadAllBytes(hardlinkFile), Is.EqualTo(File.ReadAllBytes(originalFile)), "Archive portable hardlink should be equivalent to original file"); + } + /// /// Test install zip with invalid relative file path. /// diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_PathFallbackToSymlink.2.0.0.0.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_PathFallbackToSymlink.2.0.0.0.yaml new file mode 100644 index 0000000000..080c43c599 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_PathFallbackToSymlink.2.0.0.0.yaml @@ -0,0 +1,20 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.9.0.schema.json + +PackageIdentifier: AppInstallerTest.ZipPortable_PathSwitchToSymlink +PackageVersion: 2.0.0.0 +PackageName: ZipPortablePathSwitchToSymlink +PackageLocale: en-US +Publisher: AppInstallerTest +License: Test +ShortDescription: E2E test for upgrading zip portable from PATH fallback mode to symlink mode. +Installers: + - Architecture: x64 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestZipInstaller/AppInstallerTestZipInstaller.zip + InstallerType: zip + InstallerSha256: + NestedInstallerType: portable + NestedInstallerFiles: + - RelativeFilePath: AppInstallerTestExeInstaller.exe + PortableCommandAlias: TestPortableTransitionPathToSymlink +ManifestType: singleton +ManifestVersion: 1.9.0 diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_PathFallbackToSymlink.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_PathFallbackToSymlink.yaml new file mode 100644 index 0000000000..a9920ae6a7 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_PathFallbackToSymlink.yaml @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.9.0.schema.json + +PackageIdentifier: AppInstallerTest.ZipPortable_PathSwitchToSymlink +PackageVersion: 1.0.0.0 +PackageName: ZipPortablePathSwitchToSymlink +PackageLocale: en-US +Publisher: AppInstallerTest +License: Test +ShortDescription: E2E test for upgrading zip portable from PATH fallback mode to symlink mode. +Installers: + - Architecture: x64 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestZipInstaller/AppInstallerTestZipInstaller.zip + InstallerType: zip + InstallerSha256: + NestedInstallerType: portable + NestedInstallerFiles: + - RelativeFilePath: AppInstallerTestExeInstaller.exe + PortableCommandAlias: TestPortableTransitionPathToSymlink + ArchiveBinariesDependOnPath: true +ManifestType: singleton +ManifestVersion: 1.9.0 diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_SymlinkToPathFallback.2.0.0.0.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_SymlinkToPathFallback.2.0.0.0.yaml new file mode 100644 index 0000000000..cfa87987e2 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_SymlinkToPathFallback.2.0.0.0.yaml @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.9.0.schema.json + +PackageIdentifier: AppInstallerTest.ZipPortable_SymlinkSwitchToPath +PackageVersion: 2.0.0.0 +PackageName: ZipPortableSymlinkSwitchToPath +PackageLocale: en-US +Publisher: AppInstallerTest +License: Test +ShortDescription: E2E test for upgrading zip portable from symlink mode to PATH fallback mode. +Installers: + - Architecture: x64 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestZipInstaller/AppInstallerTestZipInstaller.zip + InstallerType: zip + InstallerSha256: + NestedInstallerType: portable + NestedInstallerFiles: + - RelativeFilePath: AppInstallerTestExeInstaller.exe + PortableCommandAlias: TestPortableTransitionSymlinkToPath + ArchiveBinariesDependOnPath: true +ManifestType: singleton +ManifestVersion: 1.9.0 diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_SymlinkToPathFallback.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_SymlinkToPathFallback.yaml new file mode 100644 index 0000000000..3dec3974ee --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestZipInstaller_Portable_SymlinkToPathFallback.yaml @@ -0,0 +1,20 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.9.0.schema.json + +PackageIdentifier: AppInstallerTest.ZipPortable_SymlinkSwitchToPath +PackageVersion: 1.0.0.0 +PackageName: ZipPortableSymlinkSwitchToPath +PackageLocale: en-US +Publisher: AppInstallerTest +License: Test +ShortDescription: E2E test for upgrading zip portable from symlink mode to PATH fallback mode. +Installers: + - Architecture: x64 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestZipInstaller/AppInstallerTestZipInstaller.zip + InstallerType: zip + InstallerSha256: + NestedInstallerType: portable + NestedInstallerFiles: + - RelativeFilePath: AppInstallerTestExeInstaller.exe + PortableCommandAlias: TestPortableTransitionSymlinkToPath +ManifestType: singleton +ManifestVersion: 1.9.0 diff --git a/src/AppInstallerCLIE2ETests/UninstallCommand.cs b/src/AppInstallerCLIE2ETests/UninstallCommand.cs index 8c6b23ab02..ec6eae5f7a 100644 --- a/src/AppInstallerCLIE2ETests/UninstallCommand.cs +++ b/src/AppInstallerCLIE2ETests/UninstallCommand.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------------- +// ----------------------------------------------------------------------------- // // Copyright (c) Microsoft Corporation. Licensed under the MIT License. // @@ -27,6 +27,21 @@ public class UninstallCommand : BaseCommand // Package name of the test MSIX package private const string UninstallTestMsixName = "6c6338fe-41b7-46ca-8ba6-b5ad5312bb0e"; + /// + /// Tear down. + /// + [TearDown] + public void TearDown() + { + // Portable packages installed by tests in this class. Force-uninstalling them after every + // test guards against leftover state (e.g. stale Links directory PATH entries) affecting + // subsequent tests, even if a test fails before it can clean up after itself. + TestCommon.RunAICLICommand("uninstall", Constants.PortableExePackageId); + TestCommon.RunAICLICommand("uninstall", Constants.PortableExeWithCommandPackageId); + TestCommon.RunAICLICommand("uninstall", "AppInstallerTest.TestZipInstallerWithPortable"); + TestCommon.RunAICLICommand("uninstall", "AppInstallerTest.ArchivePortableWithBinariesDependentOnPath"); + } + /// /// Test uninstall exe. /// @@ -181,13 +196,104 @@ public void UninstallZip_Portable() commandAlias = "TestPortable.exe"; fileName = "AppInstallerTestExeInstaller.exe"; - var testResult = TestCommon.RunAICLICommand("install", $"{packageId}"); + TestCommon.RunAICLICommand("install", $"{packageId}"); var result = TestCommon.RunAICLICommand("uninstall", $"{packageId}"); Assert.That(result.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); Assert.That(result.StdOut, Does.Contain("Successfully uninstalled")); TestCommon.VerifyPortablePackage(Path.Combine(installDir, packageDirName), commandAlias, fileName, productCode, false); } + /// + /// Test uninstalling one of two symlinked packages keeps Links path, then uninstalling the second removes it. + /// + [Test] + public void UninstallZip_Portable_KeepsThenRemovesLinksPath() + { + string linksDirectory = TestCommon.GetPortableSymlinkDirectory(TestCommon.Scope.User); + + string packageIdToUninstall = "AppInstallerTest.TestZipInstallerWithPortable"; + string remainingPackageId = "AppInstallerTest.TestPortableExe"; + string remainingSymlinkPath = Path.Combine(linksDirectory, "AppInstallerTestExeInstaller.exe"); + + var installResult1 = TestCommon.RunAICLICommand("install", packageIdToUninstall); + Assert.That(installResult1.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + + var installResult2 = TestCommon.RunAICLICommand("install", remainingPackageId); + Assert.That(installResult2.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + + Assert.That(TestCommon.PathContainsValue(linksDirectory), "Links directory should be on PATH after installing symlinked packages."); + + var uninstallResult = TestCommon.RunAICLICommand("uninstall", packageIdToUninstall); + Assert.That(uninstallResult.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + Assert.That(uninstallResult.StdOut, Does.Contain("Successfully uninstalled")); + + Assert.That(remainingSymlinkPath, Does.Exist, "Remaining package symlink should still exist."); + Assert.That(TestCommon.PathContainsValue(linksDirectory), "Links directory should remain on PATH while another symlinked package exists."); + + var uninstallRemainingResult = TestCommon.RunAICLICommand("uninstall", remainingPackageId); + Assert.That(uninstallRemainingResult.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + Assert.That(uninstallRemainingResult.StdOut, Does.Contain("Successfully uninstalled")); + + Assert.That(remainingSymlinkPath, Does.Not.Exist, "Remaining package symlink should be removed after uninstall."); + Assert.That(!Directory.Exists(linksDirectory) || Directory.GetFileSystemEntries(linksDirectory).Length == 0, Is.True, "Links directory should be empty after uninstalling both symlinked packages."); + Assert.That(TestCommon.PathContainsValue(linksDirectory), Is.False, "Links directory should be removed from PATH when no symlinked packages remain."); + } + + /// + /// Test uninstall zip portable package with binaries dependent on PATH cleans install directory PATH entry. + /// + [Test] + public void UninstallZip_ArchivePortableWithBinariesDependentOnPath() + { + string installDir = TestCommon.GetPortablePackagesDirectory(); + string packageId = "AppInstallerTest.ArchivePortableWithBinariesDependentOnPath"; + string packageDir = Path.Combine(installDir, packageId + "_" + Constants.TestSourceIdentifier); + + var installResult = TestCommon.RunAICLICommand("install", $"{packageId} -v 1.0.0.0"); + Assert.That(installResult.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + Assert.That(TestCommon.PathContainsValue(packageDir)); + + var uninstallResult = TestCommon.RunAICLICommand("uninstall", packageId); + Assert.That(uninstallResult.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + Assert.That(uninstallResult.StdOut, Does.Contain("Successfully uninstalled")); + Assert.That(TestCommon.PathContainsValue(packageDir), Is.False); + } + + /// + /// Test uninstall portable package removes hardlinks. + /// + [Test] + public void UninstallPortableWithCommands_RemovesHardlinks() + { + string installDir = TestCommon.GetPortablePackagesDirectory(); + string packageId, packageDirName, productCode; + packageId = "AppInstallerTest.TestPortableExeWithCommand"; + packageDirName = productCode = packageId + "_" + Constants.TestSourceIdentifier; + + // Install + var installResult = TestCommon.RunAICLICommand("install", $"{packageId}"); + Assert.That(installResult.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + Assert.That(installResult.StdOut, Does.Contain("Successfully installed")); + + string installPath = Path.Combine(installDir, packageDirName); + string originalFile = Path.Combine(installPath, "AppInstallerTestExeInstaller.exe"); + string hardlinkFile = Path.Combine(installPath, "testCommand.exe"); + + // Verify files exist after install + Assert.That(originalFile, Does.Exist, "Original file should exist after install"); + Assert.That(hardlinkFile, Does.Exist, "Hardlink should exist after install"); + + // Uninstall + var uninstallResult = TestCommon.RunAICLICommand("uninstall", $"{packageId}"); + Assert.That(uninstallResult.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + Assert.That(uninstallResult.StdOut, Does.Contain("Successfully uninstalled")); + + // Verify both original and hardlink are removed + Assert.That(originalFile, Does.Not.Exist, "Original file should be removed after uninstall"); + Assert.That(hardlinkFile, Does.Not.Exist, "Hardlink should be removed after uninstall"); + Assert.That(Directory.Exists(installPath), Is.False, "Installation directory should be removed after uninstall"); + } + /// /// Test uninstall not indexed. /// diff --git a/src/AppInstallerCLIE2ETests/UpgradeCommand.cs b/src/AppInstallerCLIE2ETests/UpgradeCommand.cs index 3b2d1d4f53..bc0af8f07d 100644 --- a/src/AppInstallerCLIE2ETests/UpgradeCommand.cs +++ b/src/AppInstallerCLIE2ETests/UpgradeCommand.cs @@ -25,6 +25,14 @@ public void TearDown() { // Due to its properties, this being present is problematic. TestCommon.RunAICLICommand("uninstall", DenyUpgradePackage); + + // Portable packages installed by tests in this class. Force-uninstalling them after every + // test guards against leftover state (e.g. stale Links directory PATH entries) affecting + // subsequent tests, even if a test fails before it can clean up after itself. + TestCommon.RunAICLICommand("uninstall", Constants.PortableExePackageId); + TestCommon.RunAICLICommand("uninstall", "AppInstallerTest.TestZipInstallerWithPortable"); + TestCommon.RunAICLICommand("uninstall", "AppInstallerTest.ZipPortable_PathSwitchToSymlink"); + TestCommon.RunAICLICommand("uninstall", "AppInstallerTest.ZipPortable_SymlinkSwitchToPath"); } /// @@ -210,6 +218,54 @@ public void UpgradeZip_Portable() TestCommon.VerifyPortablePackage(Path.Combine(installDir, packageDirName), commandAlias, fileName, productCode, true, TestCommon.Scope.User); } + /// + /// Test upgrade zip portable package re-evaluates stale InstallDirectoryAddedToPath state. + /// + [Test] + public void UpgradeZip_Portable_ReevaluatesInstallDirectoryAddedToPath() + { + string installDir = TestCommon.GetPortablePackagesDirectory(); + string linksDirectory = TestCommon.GetPortableSymlinkDirectory(TestCommon.Scope.User); + string packageId = "AppInstallerTest.ZipPortable_PathSwitchToSymlink"; + string packageDir = Path.Combine(installDir, packageId + "_" + Constants.TestSourceIdentifier); + string symlinkPath = Path.Combine(linksDirectory, "TestPortableTransitionPathToSymlink.exe"); + + var installResult = TestCommon.RunAICLICommand("install", $"{packageId} -v 1.0.0.0"); + Assert.That(installResult.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + + var upgradeResult = TestCommon.RunAICLICommand("upgrade", $"{packageId} -v 2.0.0.0"); + Assert.That(upgradeResult.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + Assert.That(symlinkPath, Does.Exist); + Assert.That(TestCommon.PathContainsValue(linksDirectory)); + Assert.That(TestCommon.PathContainsValue(packageDir), Is.False); + } + + /// + /// Test upgrade zip portable package with binaries dependent on PATH cleans stale Links PATH entry. + /// + [Test] + public void UpgradeZip_ArchivePortableWithBinariesDependentOnPath_CleansLinksPath() + { + string installDir = TestCommon.GetPortablePackagesDirectory(); + string packageId = "AppInstallerTest.ZipPortable_SymlinkSwitchToPath"; + string packageDir = Path.Combine(installDir, packageId + "_" + Constants.TestSourceIdentifier); + string linksDir = TestCommon.GetPortableSymlinkDirectory(TestCommon.Scope.User); + + var installResult = TestCommon.RunAICLICommand("install", $"{packageId} -v 1.0.0.0"); + Assert.That(installResult.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + + Assert.That(TestCommon.PathContainsValue(linksDir)); + + var upgradeResult = TestCommon.RunAICLICommand("upgrade", $"{packageId} -v 2.0.0.0"); + Assert.That(upgradeResult.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK)); + Assert.That(TestCommon.PathContainsValue(packageDir)); + + if (!Directory.Exists(linksDir) || Directory.GetFileSystemEntries(linksDir).Length == 0) + { + Assert.That(TestCommon.PathContainsValue(linksDir), Is.False); + } + } + /// /// Test upgrade when a new dependency is added that is not installed. /// diff --git a/src/AppInstallerCommonCore/Public/winget/PortableFileEntry.h b/src/AppInstallerCommonCore/Public/winget/PortableFileEntry.h index 26c834ff7c..b3b780c915 100644 --- a/src/AppInstallerCommonCore/Public/winget/PortableFileEntry.h +++ b/src/AppInstallerCommonCore/Public/winget/PortableFileEntry.h @@ -13,7 +13,8 @@ namespace AppInstaller::Portable Unknown, File, Directory, - Symlink + Symlink, + Hardlink, }; // Metadata representation of a portable file placed down during installation @@ -66,6 +67,17 @@ namespace AppInstaller::Portable return symlinkEntry; } + static PortableFileEntry CreateHardlinkEntry(const std::filesystem::path& hardlinkPath, const std::filesystem::path& targetPath, const std::string& sha256 = {}) + { + PortableFileEntry hardlinkEntry; + hardlinkEntry.FileType = PortableFileType::Hardlink; + hardlinkEntry.CurrentPath = targetPath; + hardlinkEntry.SetFilePath(hardlinkPath); + // Use provided SHA256 or empty (will be computed from target after install) + hardlinkEntry.SHA256 = sha256; + return hardlinkEntry; + } + static PortableFileEntry CreateDirectoryEntry(const std::filesystem::path& currentPath, const std::filesystem::path& directoryPath) { PortableFileEntry directoryEntry; diff --git a/src/AppInstallerSharedLib/Filesystem.cpp b/src/AppInstallerSharedLib/Filesystem.cpp index 90394c6824..4a84cab4a5 100644 --- a/src/AppInstallerSharedLib/Filesystem.cpp +++ b/src/AppInstallerSharedLib/Filesystem.cpp @@ -455,6 +455,31 @@ namespace AppInstaller::Filesystem std::filesystem::copy_file(from, to, std::filesystem::copy_options::overwrite_existing); } + bool CreateHardlink(const std::filesystem::path& target, const std::filesystem::path& link) + { + try + { + std::filesystem::create_hard_link(target, link); + AICLI_LOG(Core, Info, << "Hardlink created successfully from " << target << " to " << link); + return true; + } + catch (const std::filesystem::filesystem_error& e) + { + AICLI_LOG(Core, Info, << "Hardlink not supported or creation failed: " << e.what()); + return false; + } + catch (const std::exception& e) + { + AICLI_LOG(Core, Info, << "Hardlink creation failed: " << e.what()); + return false; + } + catch (...) + { + AICLI_LOG(Core, Info, << "Hardlink creation failed with unknown error"); + return false; + } + } + #ifndef AICLI_DISABLE_TEST_HOOKS static bool* s_CreateSymlinkResult_TestHook_Override = nullptr; diff --git a/src/AppInstallerSharedLib/Public/winget/Filesystem.h b/src/AppInstallerSharedLib/Public/winget/Filesystem.h index f450444234..865881938a 100644 --- a/src/AppInstallerSharedLib/Public/winget/Filesystem.h +++ b/src/AppInstallerSharedLib/Public/winget/Filesystem.h @@ -27,6 +27,10 @@ namespace AppInstaller::Filesystem // Renames the file to a new path. void RenameFile(const std::filesystem::path& from, const std::filesystem::path& to); + // Creates a hardlink at linkPath pointing to targetPath. + // Returns true if successful, false if hardlinks are not supported or creation failed. + bool CreateHardlink(const std::filesystem::path& target, const std::filesystem::path& link); + // Creates a symlink that points to the target path. bool CreateSymlink(const std::filesystem::path& target, const std::filesystem::path& link); From 4ecfcbe9bdc39f1a69478ada36cb357fcb685654 Mon Sep 17 00:00:00 2001 From: Kaleb Luedtke Date: Tue, 22 Sep 2026 10:16:39 -0500 Subject: [PATCH 07/13] Strengthen contributor guidance to support Help-Wanted contributions (#6402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📖 Description Strengthens contributor guidance to better support `Help-Wanted` contributions. Addresses the four sub-issues tracked under #6393: - Adds an explicit testing policy to `CONTRIBUTING.md` specifying when unit and E2E tests are expected, when they may be omitted, and how to run them. - Adds `doc/Standards.md`, a new coding conventions reference covering file formatting, brace style, naming (C++ and C#), error handling (`WIL`/`AICLI_*` macros), casts, `std::move()` usage, and resource string requirements. - Adds an AI-assisted contributions policy to `CONTRIBUTING.md` clarifying that contributors are fully accountable for AI-generated code and must disclose material AI use in their PR. - Updates `.github/PULL_REQUEST_TEMPLATE.md` to add an evidence prompt in the Validation section, a tests checkbox, and an AI Assistance disclosure section. - Updates `doc/Developing.md` to cross-reference `doc/Standards.md` and add a dedicated "Running End-to-End Tests" section. - Updates `.github/copilot-instructions.md` to document PR expectations for AI-assisted work and normalize trailing whitespace. cc @denelon ## 🔗 References - Relates to #6393 - Resolves #6394 - Resolves #6395 - Resolves #6396 - Resolves #6397 ## 🔍 Validation Documentation-only changes. All cross-references and Markdown anchor links verified. No functional code changed. ## ✅ Checklist - [x] Signed the [Contributor License Agreement](https://cla.opensource.microsoft.com) - [x] Linked to an issue - [ ] Updated [Release Notes](../doc/ReleaseNotes.md) (if applicable) - [x] Updated documentation (if applicable) - [x] Added or updated tests (or noted why not applicable — documentation-only change) - [x] Updated [Copilot instructions](.github/copilot-instructions.md) (if build, architecture, or conventions changed) ## 🤖 AI Assistance - [x] AI assistance was used and has been disclosed in this PR GitHub Copilot assisted in authoring all documentation additions in this PR: the new `doc/Standards.md` file, the AI-assisted contributions policy, the expanded testing guidance in `CONTRIBUTING.md`, the "Running End-to-End Tests" section in `doc/Developing.md`, and the PR template updates. I have reviewed and revised the submitted content. ## 📋 Issue Type - [ ] Bug fix - [ ] Feature - [x] Task ###### Microsoft Reviewers: [Open in CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/microsoft/winget-cli/pull/6402) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/PULL_REQUEST_TEMPLATE.md | 8 +- .github/actions/spelling/expect.txt | 3 + .github/copilot-instructions.md | 404 ++++++++++++++-------------- CONTRIBUTING.md | 26 +- doc/Developing.md | 18 ++ doc/Standards.md | 261 ++++++++++++++++++ 6 files changed, 517 insertions(+), 203 deletions(-) create mode 100644 doc/Standards.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 98635394ce..64e6193f27 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,7 +5,7 @@ ## 🔍 Validation - + ## ✅ Checklist @@ -14,8 +14,14 @@ - [ ] Linked to an issue - [ ] Updated [Release Notes](../doc/ReleaseNotes.md) (if applicable) - [ ] Updated documentation (if applicable) +- [ ] Added or updated tests (or noted why not applicable) - [ ] Updated [Copilot instructions](.github/copilot-instructions.md) (if build, architecture, or conventions changed) +## 🤖 AI Assistance + +- [ ] AI assistance was used and has been disclosed in this PR +- [ ] No AI assistance was used + ## 📋 Issue Type - [ ] Bug fix diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 41ffa13e88..3153320bb7 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -13,6 +13,7 @@ admx AFAIK aicli AICLIC +Allman allusers alreadyinstalled AMap @@ -148,6 +149,7 @@ DMC dnld Dns Dobbeleer +Downcasting DONOT dsc dupenv @@ -400,6 +402,7 @@ NOUPDATE nowarn npmjs nsis +NRVO NTFS objbase objidl diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8d0039bbc4..f19f1a7bdc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,201 +1,203 @@ -# WinGet CLI Development Guide - -## Project Overview - -This is the Windows Package Manager (WinGet) CLI client - a native Windows application for discovering and installing packages. The codebase consists of: - -- **C++/WinRT client** (`src/AppInstallerCLI*`) - The main CLI and core logic -- **COM API** (`src/Microsoft.Management.Deployment`) - Public Windows Runtime API for programmatic access -- **PowerShell modules** (`src/PowerShell`) - Microsoft.WinGet.Client and Microsoft.WinGet.Configuration cmdlets -- **Configuration system** - DSC-based system configuration using WinGet - -## Building, Testing, and Running - -### Initial Setup - -Use a configuration file in `.config` as in `winget configure .config/configuration.winget` (alternatives provided for other VS SKUs). - -Manual steps: - -1. Install Visual Studio 2022 with required workloads (see `.vsconfig`) -2. Install Windows SDK 10.0.26100: `winget install Microsoft.WindowsSDK.10.0.26100` -3. Enable developer mode in Windows -4. Run `vcpkg integrate install` from Developer Command Prompt - -### Building - -Open `src\AppInstallerCLI.sln` in Visual Studio and build the solution (Ctrl+Shift+B) or use msbuild.exe to build from the command line. - -The solution uses: -- MSBuild -- vcpkg for C++ dependencies -- NuGet for C++ and .NET dependencies - -### Running/Debugging - -1. Deploy solution: Build > Deploy Solution -2. Run from command line: `wingetdev` -3. For debugging: - - Right-click `AppInstallerCLIPackage` > Properties > Debug tab - - Set Debugger type to "Native Only" for both Application and Background task processes - - Select "Do not launch, but debug my code when it starts" - - Press F5 and run `wingetdev` in a separate terminal - -Entry point: `src/AppInstallerCLI/main.cpp` - -### Testing - -#### C++ Unit Tests (Catch2) -Located in `AppInstallerCLITests` project. After building: - -```powershell -# Run all tests -src\\\AppInstallerCLITests\AppInstallerCLITests.exe - -# Run specific test -src\\\AppInstallerCLITests\AppInstallerCLITests.exe TestName - -# Available options -AppInstallerCLITests.exe --help -``` - -#### .NET Tests -- `Microsoft.WinGet.UnitTests` - PowerShell module tests -- `Microsoft.Management.Configuration.UnitTests` - Configuration system tests -- `WinGetUtilInterop.UnitTests` - Interop layer tests - -#### E2E Tests -`AppInstallerCLIE2ETests` project contains end-to-end integration tests. - -## Architecture - -### Core Components - -**AppInstallerCLICore** - Core CLI logic organized around: -- **ExecutionContext**: State container that flows through workflows. Contains arguments, reporter, flags, and data (ExecutionContextData.h) -- **Workflows**: Composable functions that take ExecutionContext and perform operations (e.g., InstallFlow, UpdateFlow, SearchFlow) -- **Commands**: Parse arguments and orchestrate workflows -- **Reporter**: Handles all user output (ExecutionReporter.h) - -**AppInstallerRepositoryCore** - Package source abstraction: -- Interfaces for different source types (REST, SQLite index, Microsoft Store, composite) -- Search, match, and correlation logic -- Package version selection and dependencies - -**AppInstallerCommonCore** - Shared utilities: -- Manifest parsing (YAML/JSON) -- Settings and group policy -- Telemetry and logging -- HTTP client, downloader, archive handling - -**Microsoft.Management.Deployment** - COM API surface: -- IDL definitions in `PackageManager.idl` -- WinRT projections for external consumption -- Used by PowerShell modules and third-party integrations - -**AppInstallerCLIPackage** - Dev MSIX package definition: -- Models the release package definition as closely as possible. -- Contains localized string resources at src\AppInstallerCLIPackage\Shared\Strings\en-us\winget.resw - -### Key Patterns - -**Workflow Pattern**: Functions that operate on ExecutionContext: -```cpp -void WorkflowTask(Execution::Context& context) -{ - // Check if already terminated - AICLI_RETURN_IF_TERMINATED(context); - - // Access data - auto& data = context.Get(); - - // Report to user - context.Reporter.Info() << "Doing something"; - - // Store data for next workflow - context.Add(result); - - // Terminate on error - if (failed) - { - AICLI_TERMINATE_CONTEXT(HRESULT); - } -} -``` - -**Source Composition**: Multiple package sources can be composed: -- CompositeSource combines multiple sources with conflict resolution -- Installed source tracks locally installed packages -- Available sources provide packages to install - -**Manifest Schema**: Package manifests use versioned YAML schemas: -- Schema definitions in `schemas/JSON/manifests/` -- Parsing in `AppInstallerCommonCore/Manifest/` -- Multi-file manifests: installer, locale, version, defaultLocale - -## Naming Conventions - -- **Namespace structure**: `AppInstaller::[::]` - - `AppInstaller::CLI::Execution` - CLI execution context - - `AppInstaller::CLI::Workflow` - Workflow functions - - `AppInstaller::Repository` - Repository/source logic - - `AppInstaller::Manifest` - Manifest types - - `AppInstaller::Settings` - User/admin settings - -- **Macros**: Prefixed with `AICLI_` for CLI, `WINGET_` for general -- **Data keys**: ExecutionContextData uses enum keys to type-safely store/retrieve data - -## Windows-Specific Considerations - -- Use Windows-style paths with backslashes (`\`) -- Leverage WinRT APIs via C++/WinRT projections -- COM threading models matter - client uses multi-threaded apartment (MTA) -- Package deployment uses Windows App SDK / MSIX infrastructure -- Requires Windows 10 1809+ (build 17763) - -## Contributing - -- Review `CONTRIBUTING.md` for workflow -- File/discuss issues before starting work -- Specs required for features (stored in `doc/specs/`); see `.github/instructions/specs.instructions.md` for detailed guidance -- Follow existing code style (see `stylecop.json`) -- CI runs on Azure Pipelines (`azure-pipelines.yml`) - -## Issues and Pull Requests - -- Before filing an issue, search existing open and closed issues for duplicates. -- Use the GitHub issue forms in `.github/ISSUE_TEMPLATE/`; do not file a blank issue unless a maintainer explicitly asks for one. -- Bug reports should include the form fields for relevant area, command if applicable, brief description, steps to reproduce, expected behavior, actual behavior, and environment. -- Feature requests should include the form fields for relevant area, feature or enhancement description, and proposed technical implementation details when known. -- Keep issue bodies concise and evidence-based. Do not paste large speculative patches into issue bodies; open a pull request or link a branch when code is available. -- Before opening a pull request, review `CONTRIBUTING.md`, follow the PR template, keep the change focused, and summarize validation performed. - -## Useful Commands - -```powershell -# Get WinGet client info -wingetdev --info - -# Show experimental features -wingetdev features - -# Check sources -wingetdev source list -``` - -## Localization - -### Source of truth - -The English resource file `src\AppInstallerCLIPackage\Shared\Strings\en-us\winget.resw` is the only file contributors should edit for string changes. It feeds the Microsoft localization pipeline. - -The files under `Localization\Resources\\` are **automatically synced from Microsoft's internal localization system and must not be edited**. Any manual edits will be overwritten on the next sync. - -Every string that could be misunderstood without context should have a ``. - -### Triggering retranslation - -- **Changing a string's ``** automatically queues it for retranslation on the next localization sync. -- **Changing only a ``** does NOT trigger retranslation. Comments improve future translations but do not fix existing ones. - -To fix an existing bad translation, a bug has to be filed internally with the localization team. +# WinGet CLI Development Guide + +## Project Overview + +This is the Windows Package Manager (WinGet) CLI client - a native Windows application for discovering and installing packages. The codebase consists of: + +- **C++/WinRT client** (`src/AppInstallerCLI*`) - The main CLI and core logic +- **COM API** (`src/Microsoft.Management.Deployment`) - Public Windows Runtime API for programmatic access +- **PowerShell modules** (`src/PowerShell`) - Microsoft.WinGet.Client and Microsoft.WinGet.Configuration cmdlets +- **Configuration system** - DSC-based system configuration using WinGet + +## Building, Testing, and Running + +### Initial Setup + +Use a configuration file in `.config` as in `winget configure .config/configuration.winget` (alternatives provided for other VS SKUs). + +Manual steps: + +1. Install Visual Studio 2022 with required workloads (see `.vsconfig`) +2. Install Windows SDK 10.0.26100: `winget install Microsoft.WindowsSDK.10.0.26100` +3. Enable developer mode in Windows +4. Run `vcpkg integrate install` from Developer Command Prompt + +### Building + +Open `src\AppInstallerCLI.sln` in Visual Studio and build the solution (Ctrl+Shift+B) or use msbuild.exe to build from the command line. + +The solution uses: +- MSBuild +- vcpkg for C++ dependencies +- NuGet for C++ and .NET dependencies + +### Running/Debugging + +1. Deploy solution: Build > Deploy Solution +2. Run from command line: `wingetdev` +3. For debugging: + - Right-click `AppInstallerCLIPackage` > Properties > Debug tab + - Set Debugger type to "Native Only" for both Application and Background task processes + - Select "Do not launch, but debug my code when it starts" + - Press F5 and run `wingetdev` in a separate terminal + +Entry point: `src/AppInstallerCLI/main.cpp` + +### Testing + +#### C++ Unit Tests (Catch2) +Located in `AppInstallerCLITests` project. After building: + +```powershell +# Run all tests +src\\\AppInstallerCLITests\AppInstallerCLITests.exe + +# Run specific test +src\\\AppInstallerCLITests\AppInstallerCLITests.exe TestName + +# Available options +AppInstallerCLITests.exe --help +``` + +#### .NET Tests +- `Microsoft.WinGet.UnitTests` - PowerShell module tests +- `Microsoft.Management.Configuration.UnitTests` - Configuration system tests +- `WinGetUtilInterop.UnitTests` - Interop layer tests + +#### E2E Tests +`AppInstallerCLIE2ETests` project contains end-to-end integration tests. + +## Architecture + +### Core Components + +**AppInstallerCLICore** - Core CLI logic organized around: +- **ExecutionContext**: State container that flows through workflows. Contains arguments, reporter, flags, and data (ExecutionContextData.h) +- **Workflows**: Composable functions that take ExecutionContext and perform operations (e.g., InstallFlow, UpdateFlow, SearchFlow) +- **Commands**: Parse arguments and orchestrate workflows +- **Reporter**: Handles all user output (ExecutionReporter.h) + +**AppInstallerRepositoryCore** - Package source abstraction: +- Interfaces for different source types (REST, SQLite index, Microsoft Store, composite) +- Search, match, and correlation logic +- Package version selection and dependencies + +**AppInstallerCommonCore** - Shared utilities: +- Manifest parsing (YAML/JSON) +- Settings and group policy +- Telemetry and logging +- HTTP client, downloader, archive handling + +**Microsoft.Management.Deployment** - COM API surface: +- IDL definitions in `PackageManager.idl` +- WinRT projections for external consumption +- Used by PowerShell modules and third-party integrations + +**AppInstallerCLIPackage** - Dev MSIX package definition: +- Models the release package definition as closely as possible. +- Contains localized string resources at src\AppInstallerCLIPackage\Shared\Strings\en-us\winget.resw + +### Key Patterns + +**Workflow Pattern**: Functions that operate on ExecutionContext: +```cpp +void WorkflowTask(Execution::Context& context) +{ + // Check if already terminated + AICLI_RETURN_IF_TERMINATED(context); + + // Access data + auto& data = context.Get(); + + // Report to user + context.Reporter.Info() << "Doing something"; + + // Store data for next workflow + context.Add(result); + + // Terminate on error + if (failed) + { + AICLI_TERMINATE_CONTEXT(HRESULT); + } +} +``` + +**Source Composition**: Multiple package sources can be composed: +- CompositeSource combines multiple sources with conflict resolution +- Installed source tracks locally installed packages +- Available sources provide packages to install + +**Manifest Schema**: Package manifests use versioned YAML schemas: +- Schema definitions in `schemas/JSON/manifests/` +- Parsing in `AppInstallerCommonCore/Manifest/` +- Multi-file manifests: installer, locale, version, defaultLocale + +## Naming Conventions + +- **Namespace structure**: `AppInstaller::[::]` + - `AppInstaller::CLI::Execution` - CLI execution context + - `AppInstaller::CLI::Workflow` - Workflow functions + - `AppInstaller::Repository` - Repository/source logic + - `AppInstaller::Manifest` - Manifest types + - `AppInstaller::Settings` - User/admin settings + +- **Macros**: Prefixed with `AICLI_` for CLI, `WINGET_` for general +- **Data keys**: ExecutionContextData uses enum keys to type-safely store/retrieve data + +## Windows-Specific Considerations + +- Use Windows-style paths with backslashes (`\`) +- Leverage WinRT APIs via C++/WinRT projections +- COM threading models matter - client uses multi-threaded apartment (MTA) +- Package deployment uses Windows App SDK / MSIX infrastructure +- Requires Windows 10 1809+ (build 17763) + +## Contributing + +- Review `CONTRIBUTING.md` for workflow +- File/discuss issues before starting work +- Specs required for features (stored in `doc/specs/`); see `.github/instructions/specs.instructions.md` for detailed guidance +- Follow existing code style (see `stylecop.json`) +- CI runs on Azure Pipelines (`azure-pipelines.yml`) + +## Issues and Pull Requests + +- Before filing an issue, search existing open and closed issues for duplicates. +- Use the GitHub issue forms in `.github/ISSUE_TEMPLATE/`; do not file a blank issue unless a maintainer explicitly asks for one. +- Bug reports should include the form fields for relevant area, command if applicable, brief description, steps to reproduce, expected behavior, actual behavior, and environment. +- Feature requests should include the form fields for relevant area, feature or enhancement description, and proposed technical implementation details when known. +- Keep issue bodies concise and evidence-based. Do not paste large speculative patches into issue bodies; open a pull request or link a branch when code is available. +- Before opening a pull request, review `CONTRIBUTING.md`, follow the PR template, keep the change focused, and summarize validation performed. +- AI assistance is allowed, but contributors are fully accountable for AI-assisted output as if they wrote it themselves. + - Unless explicitly directed otherwise, confirm with the user that they have reviewed the submission. + +## Useful Commands + +```powershell +# Get WinGet client info +wingetdev --info + +# Show experimental features +wingetdev features + +# Check sources +wingetdev source list +``` + +## Localization + +### Source of truth + +The English resource file `src\AppInstallerCLIPackage\Shared\Strings\en-us\winget.resw` is the only file contributors should edit for string changes. It feeds the Microsoft localization pipeline. + +The files under `Localization\Resources\\` are **automatically synced from Microsoft's internal localization system and must not be edited**. Any manual edits will be overwritten on the next sync. + +Every string that could be misunderstood without context should have a ``. + +### Triggering retranslation + +- **Changing a string's ``** automatically queues it for retranslation on the next localization sync. +- **Changing only a ``** does NOT trigger retranslation. Comments improve future translations but do not fix existing ones. + +To fix an existing bad translation, a bug has to be filed internally with the localization team. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index adf8758a74..09477feca7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,14 @@ Therefore, if you do file issues, or create PRs, please keep an eye on your GitH **Please do not report security vulnerabilities through public GitHub issues.** Instead, please report them to the Microsoft Security Response Center (MSRC). See [SECURITY.md](./SECURITY.md) for more information. +## AI-assisted contributions + +AI assistance is welcome, but contributors remain fully responsible for the submitted work. If AI contributes to a change, you are accountable for that content exactly as if you wrote it yourself: you must understand it, ensure it follows project conventions, and provide the same testing and validation evidence expected for any other contribution. + +When opening or updating a PR, disclose material AI-generated assistance and briefly describe which parts were assisted. + +Opening a PR requires real engineering review time. Please keep this in mind and submit contributions that are complete, reviewed, and ready for meaningful feedback. Low-effort, unreviewed, or unverifiable AI-generated submissions may be closed at maintainer discretion. + ## Before you start, file an issue Please follow this simple rule to help us eliminate any unnecessary wasted effort & frustration, and ensure an efficient and effective use of everyone's time - yours, ours, and other community members': @@ -116,9 +124,25 @@ Once you've discussed your proposed feature/fix/etc. with a team member, and you 1. Work on your changes. 1. Build and see if it works. +### Coding Standards + +Before writing code, review [doc/Standards.md](./doc/Standards.md) for the conventions used in this codebase — naming, formatting, error handling, casts, `std::move()` usage, and resource strings. PRs that diverge from these conventions will be asked to bring their changes into line before review completes. + ### Testing -Testing is a key component in the development workflow. +Testing is a key part of getting a change ready for review. + +If your contribution changes behavior or implementation, please plan to add or update automated tests alongside the code. + +As a quick rule of thumb: unit tests in `AppInstallerCLITests` focus on the code and logic, while end-to-end tests in `AppInstallerCLIE2ETests` focus on actual `winget` command behavior and the resulting system outcomes. + +For non-trivial code changes, the bar is high. Where practical, include both unit tests and end-to-end coverage. If you believe that tests aren't needed for your change, please call that out in your PR and explain why. + +Documentation-only updates and non-functional metadata changes generally do not require new tests. + +For build and test execution details, see [Running Unit Tests](./doc/Developing.md#running-unit-tests) and [Running End-to-End Tests](./doc/Developing.md#running-end-to-end-tests) in [doc/Developing.md](./doc/Developing.md). + +PRs without appropriate coverage may be asked to add tests before review completes. ### Code Review diff --git a/doc/Developing.md b/doc/Developing.md index d35e1e6878..1584ba052d 100644 --- a/doc/Developing.md +++ b/doc/Developing.md @@ -1,5 +1,9 @@ # Developer guidance +## Coding Standards + +For naming, formatting, error handling, casts, `std::move()` usage, and resource string requirements, see [doc/Standards.md](./Standards.md). + ## Prerequisites * Windows 10 1809 (17763) or later @@ -47,6 +51,20 @@ The unit tests are located inside the `AppInstallerCLITests` project. When the s > [!TIP] > If you just want to run a particular test, you can specify the test name as an argument to the executable. For example, `AppInstallerCLITests.exe EnsureSortedErrorList`. +> [!TIP] +> For local debugging, you can increase test output detail and include timing information by running `AppInstallerCLITests.exe -d yes -v high`. + +## Running End-to-End Tests + +The end-to-end tests are located in the `AppInstallerCLIE2ETests` project and are executed with NUnit. + +For setup details (including `Test.runsettings`, local test source setup, and localhost web server usage), see [`src/AppInstallerCLIE2ETests/README.md`](../src/AppInstallerCLIE2ETests/README.md). + +A typical local workflow is: +1. Build the solution. +2. Configure `src/AppInstallerCLIE2ETests/Test.runsettings` for your environment. +3. Run `AppInstallerCLIE2ETests` from Test Explorer, or run `dotnet test src\AppInstallerCLIE2ETests\AppInstallerCLIE2ETests.csproj --settings src\AppInstallerCLIE2ETests\Test.runsettings`. + ## Localization The English resource strings are the source of truth and live in: diff --git a/doc/Standards.md b/doc/Standards.md new file mode 100644 index 0000000000..8d86e79d0c --- /dev/null +++ b/doc/Standards.md @@ -0,0 +1,261 @@ +# Coding Standards + +This document describes the coding conventions used in the WinGet CLI codebase. It is a companion to: + +- [`CONTRIBUTING.md`](../CONTRIBUTING.md) — workflow and process guidance +- [`doc/Developing.md`](./Developing.md) — build, test, and localization instructions + +The codebase is primarily C++/WinRT. Brief notes for .NET (C#) components appear at the end. + +--- + +## File Formatting + +All source files must conform to the rules in [`.editorconfig`](../.editorconfig): + +- **Line endings**: CRLF +- **Encoding**: UTF-8 +- **Final newline**: required +- **Trailing whitespace**: must be trimmed +- **YAML files** (`.yml`/`.yaml`): 2-space indentation +- **Markdown files** (`.md`): tab indentation + +Configure your editor to apply these settings on save, or run a check before committing. + +--- + + +## Brace Style + +The codebase uses **Allman style** (also known as BSD style): the opening brace of every block goes on its **own new line**, at the same indentation level as the statement that introduced it. + +```cpp +namespace AppInstaller::CLI::Workflow +{ + void SomeWorkflowTask(Execution::Context& context) + { + if (condition) + { + DoSomething(); + } + else if (otherCondition) + { + DoSomethingElse(); + } + else + { + Fallback(); + } + + for (const auto& item : items) + { + Process(item); + } + + switch (value) + { + case SomeEnum::First: + HandleFirst(); + break; + case SomeEnum::Second: + HandleSecond(); + break; + default: + THROW_HR(E_UNEXPECTED); + } + } +} +``` + +Key points: + +- `else` and `else if` go on their own line after the closing `}` — never on the same line as `}`. +- `case` labels inside a `switch` are **not** indented relative to the `switch` keyword; they sit at the same level. +- **Trivial single-expression bodies** (simple getters, forwarding constructors, one-liner lambdas) may be written inline: + ```cpp + bool IsTerminated() const { return m_isTerminated; } + SomeClass(std::string name) : m_name(std::move(name)) {} + ``` + Use judgment: if the body is anything more than a single expression, use the full Allman form. + +--- + +## Naming Conventions + +### C++ + +| Element | Convention | Example | +|---------|-----------|---------| +| Types (classes, structs, enums, type aliases) | `PascalCase` | `ExecutionContext`, `ContextFlag` | +| Functions and methods | `PascalCase` | `GetErrorCode()`, `IsTerminated()` | +| Local variables and parameters | `camelCase` | `installerType`, `packageId` | +| Non-static member variables | `m_` prefix + `camelCase` | `m_name`, `m_flags` | +| Static member variables | `s_` prefix + `camelCase` | `s_disabledReason` | +| Namespaces | `PascalCase` | `AppInstaller::CLI::Workflow` | +| Macros | `AICLI_` or `WINGET_` prefix, `ALL_CAPS` | `AICLI_TERMINATE_CONTEXT`, `WINGET_CATCH_STORE` | +| Enum members | `PascalCase` | `ContextFlag::InstallerTrusted` | + +### C\# + +Follow standard .NET naming conventions (PascalCase for public members, camelCase for local variables and private fields). StyleCop is configured in `src/stylecop.json`. + +--- + +## Error Handling + +### C++: WIL macros (preferred) + +The codebase uses the [Windows Implementation Library (WIL)](https://github.com/microsoft/wil) for HRESULT-based error handling. Prefer WIL macros over manual `if (FAILED(hr))` checks: + +```cpp +// Throw on failure — use in code where exceptions are acceptable +THROW_IF_FAILED(SomeWin32OrComApi()); +THROW_HR(E_UNEXPECTED); +THROW_HR_IF(E_POINTER, ptr == nullptr); +THROW_HR_MSG(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), "Stage %d is not valid here", stage); + +// Return the HRESULT on failure — use in COM methods or HRESULT-returning functions +RETURN_IF_FAILED(SomeWin32OrComApi()); + +// Log without propagating — use for non-fatal side effects +LOG_IF_FAILED(CleanupTempFiles()); +``` + +Prefer the most specific macro. For example, `THROW_HR_IF` is cleaner than `if (condition) { THROW_HR(...); }`. + +### C++: Workflow context termination + +Inside workflow functions (functions that take `Execution::Context& context`), use the `AICLI_*` family of macros rather than throwing: + +```cpp +void WorkflowTask(Execution::Context& context) +{ + // Guard against an already-terminated context at the top of every task + AICLI_RETURN_IF_TERMINATED(context); + + // Terminate the context and return from the current function + if (failed) + { + AICLI_TERMINATE_CONTEXT(HRESULT_VALUE); + } + + // Terminate but return a specific value (useful in non-void helpers) + // AICLI_TERMINATE_CONTEXT_RETURN(HRESULT_VALUE, returnValue); +} +``` + +`AICLI_TERMINATE_CONTEXT` records the file and line of the failure, sets the context's termination HRESULT, and returns from the current function. Do not throw exceptions out of workflow functions — use context termination instead. + +### C++: Unexpected cases must not be swallowed + +Every `switch` statement over an enum or integer must have an explicit `default` branch. That branch must either: + +1. **Terminate with `E_UNEXPECTED`** if reaching it indicates a programming error (an enum value was added but the switch was not updated, or the caller passed an invalid value): + ```cpp + switch (installerType) + { + case InstallerTypeEnum::Exe: /* ... */ break; + case InstallerTypeEnum::Msi: /* ... */ break; + default: + THROW_HR(E_UNEXPECTED); + } + ``` + +2. **Have a clearly intentional and documented fallback** when a default behavior is genuinely correct: + ```cpp + switch (result) + { + case ConfigurationTestResult::Positive: return Resource::StringId::ConfigPositive; + case ConfigurationTestResult::Negative: return Resource::StringId::ConfigNegative; + default: return Resource::StringId::Empty(); // Unknown/not yet tested + } + ``` + +The same principle applies outside of switches: use `THROW_HR_IF(E_UNEXPECTED, condition)` to assert runtime invariants that should never be violated: + +```cpp +THROW_HR_IF(E_UNEXPECTED, entries.size() != expectedCount); +``` + +**Do not leave an empty `default:` or an empty `else` branch** that silently discards an unexpected case. Silent failures are harder to debug than explicit ones. + +--- + +## Casts + +**Never use C-style casts.** C-style casts (e.g., `(int)value`) bypass the type system silently. Use the named C++ cast operators instead: + +| Situation | Use | +|-----------|-----| +| Safe numeric or enum conversions | `static_cast(value)` | +| Reinterpreting pointer/integer bytes | `reinterpret_cast(value)` | +| Removing `const` (rare; justify in a comment) | `const_cast(value)` | +| Downcasting via virtual dispatch | `dynamic_cast(value)` | +| WinRT interface conversion | `.as()` from C++/WinRT | +| Converting `ToIntegral` for enums | Use the project helper `ToIntegral(enumValue)` where available | + +--- + +## `std::move()` + +`std::move()` casts a value to an rvalue reference so its resources can be transferred rather than copied. Use it only where ownership transfer is clearly intended. + +### When to use + +- **Passing to a constructor or function that takes by value or `&&`** when you no longer need the source: + ```cpp + context.Add(std::move(manifest)); + m_items.push_back(std::move(item)); + ``` +- **Initializing members from constructor parameters**: + ```cpp + MyClass(std::string name) : m_name(std::move(name)) {} + ``` + +### When NOT to use + +- **On `return` statements.** Named Return Value Optimization (NRVO) can eliminate the copy/move entirely, but only if you return the variable directly. `std::move()` on a return statement suppresses NRVO and can result in an extra move that would not otherwise occur: + + ```cpp + // ✗ Suppresses NRVO + std::string BuildResult() { return std::move(result); } + + // ✓ Allows NRVO + std::string BuildResult() { return result; } + ``` + +- **On trivially copyable types** (`int`, `HRESULT`, raw pointers, enums, etc.). Moving these is no cheaper than copying, and `std::move()` just adds noise. + +- **On `const` objects.** The move constructor cannot be selected for a `const` object; the call silently falls back to a copy. + +- **On an object you still need after the call.** After a move, the source is in a valid but unspecified state. Accessing it without re-assignment is undefined behavior. + +--- + +## Resource Strings + +All user-visible strings must be added to the English resource file: + +``` +src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +``` + +**Do not edit** any file under `Localization/Resources//`; those files are owned by Microsoft's localization pipeline and will be overwritten automatically. + +Every new or modified string entry must include a `` element that gives translators enough context. This is especially important for: + +- Short or single-word values (column headers, status labels) where the word has multiple meanings +- Technical jargon that may have a different colloquial meaning in other languages +- Strings with placeholders — document what each `{0}`, `{1}`, etc. represents + +See [Localization in `doc/Developing.md`](./Developing.md#localization) for an example. + +--- + +## .NET (C#) Components + +The PowerShell modules (`src/PowerShell`) and configuration tests are written in C#. In addition to the file header and naming guidance above: + +- StyleCop Analyzers are configured in `src/stylecop.json`. Build warnings from StyleCop must not be suppressed without justification. +- Follow standard .NET exception handling — avoid swallowing exceptions silently. +- Match the namespace structure of the surrounding project (e.g., `Microsoft.WinGet.Client.Engine.Commands`). From fdc9e287d0bdb803e7f627d6fe59cdafa58ac0b7 Mon Sep 17 00:00:00 2001 From: Kaleb Luedtke Date: Tue, 22 Sep 2026 10:19:55 -0500 Subject: [PATCH 08/13] Exclude generated *.lock.yml workflow files from spelling checks (#6548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📖 Description Excludes generated `*.lock.yml` workflow files under `.github/workflows/` from the Check Spelling action. These files are compiled artifacts (e.g. from GitHub Agentic Workflows) rather than authored source content, and they introduce dozens of unrelated third-party/tooling terms (`openai`, `claude`, `gpt`, `zizmor`, certificate-authority names, etc.) into spell-check results for unrelated pull requests. Adds a new pattern `^\.github/workflows/.*\.lock\.yml$` to `.github/actions/spelling/excludes.txt`, alongside the existing `spelling*.yml` exclusion. ## 🔗 References Resolves #6547 ## 🔍 Validation Manually reviewed `.github/actions/spelling/excludes.txt` for correct regex syntax and placement consistent with existing patterns. The pattern matches `issue-closure-recommendation.lock.yml` and `duplicate-surfacing.lock.yml`, the two `*.lock.yml` files currently present under `.github/workflows/`. No automated spelling-check run was performed locally since the Check Spelling action only runs in CI; the change will be validated by the workflow on this PR. ## ✅ Checklist - [x] Signed the [Contributor License Agreement](https://cla.opensource.microsoft.com) - [x] Linked to an issue - [ ] Updated [Release Notes](../doc/ReleaseNotes.md) (if applicable) — not applicable; this is a repository CI/tooling change with no product-facing behavior - [ ] Updated documentation (if applicable) - [ ] Updated [Copilot instructions](.github/copilot-instructions.md) (if build, architecture, or conventions changed) ## 📋 Issue Type - [x] Task --- This PR was drafted with assistance from GitHub Copilot. ###### Microsoft Reviewers: [Open in CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/microsoft/winget-cli/pull/6548) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/actions/spelling/excludes.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index 5ad5826bde..2bc651502c 100644 --- a/.github/actions/spelling/excludes.txt +++ b/.github/actions/spelling/excludes.txt @@ -96,6 +96,7 @@ \.zip$ ^\.github/actions/spelling/ ^\.github/policies/ +^\.github/workflows/.*\.lock\.yml$ ^\.github/workflows/spelling\d*\.yml$ ^cgmanifest\.json$ ^Localization/ From 1b2686c36c5f7e350e3fc8d6ae3e60a3a5b929ee Mon Sep 17 00:00:00 2001 From: JohnMcPMS Date: Tue, 22 Sep 2026 08:59:35 -0700 Subject: [PATCH 09/13] Add index schema 2.1 with delta support (#6523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📖 Description Adds a new schema version with support for generating and consuming delta databases. When using the new schema, the general flow is: 1. Update the index as normal; when appropriate, mark a baseline. 2. When making future updates, provide the baseline and delta output locations before packaging prep. 3. This will produce the normal package ready index and a delta from the baseline. This allows the existing flow to be maintained while also producing deltas. A future change will introduce the public surface to do those interactions. The spec (#6500) has more details, but the basic concept is that we record the difference between the baseline and the new index, keeping package rows fixed so that we can merge them easily at runtime. The delta uses new table names and creates views that merge the tables from the delta and baseline. This means that the code to read the index doesn't need to change. --- .github/actions/spelling/allow.txt | 1 + .../AppInstallerCLITests.vcxproj | 3 + .../AppInstallerCLITests.vcxproj.filters | 12 + src/AppInstallerCLITests/SQLiteIndex.cpp | 244 +- src/AppInstallerCLITests/SQLiteIndexDelta.cpp | 2346 +++++++++++++++++ .../SQLiteIndexTestCommon.cpp | 140 + .../SQLiteIndexTestCommon.h | 172 ++ src/AppInstallerCLITests/SQLiteWrapper.cpp | 571 ++++ src/AppInstallerCLITests/main.cpp | 24 + .../AppInstallerRepositoryCore.vcxproj | 8 + ...AppInstallerRepositoryCore.vcxproj.filters | 27 + .../Microsoft/SQLiteIndex.cpp | 810 +++--- .../Microsoft/SQLiteIndex.h | 15 + .../Microsoft/Schema/2_0/Interface.h | 14 + .../Microsoft/Schema/2_0/Interface_2_0.cpp | 43 +- .../Schema/2_0/OneToManyTableWithMap.cpp | 12 + .../Schema/2_0/PackageUpdateTrackingTable.cpp | 509 +++- .../Schema/2_0/PackageUpdateTrackingTable.h | 49 +- .../Microsoft/Schema/2_0/PackagesTable.cpp | 14 +- .../Microsoft/Schema/2_0/PackagesTable.h | 2 +- .../Microsoft/Schema/2_1/DeltaGeneration.cpp | 433 +++ .../Microsoft/Schema/2_1/DeltaGeneration.h | 38 + .../Microsoft/Schema/2_1/DeltaTables.cpp | 179 ++ .../Microsoft/Schema/2_1/DeltaTables.h | 41 + .../Microsoft/Schema/2_1/DeltaViews.cpp | 230 ++ .../Microsoft/Schema/2_1/DeltaViews.h | 16 + .../Microsoft/Schema/2_1/Interface.h | 46 + .../Microsoft/Schema/2_1/Interface_2_1.cpp | 138 + .../Microsoft/Schema/ISQLiteIndex.cpp | 16 +- .../Microsoft/Schema/ISQLiteIndex.h | 12 + .../Microsoft/Schema/SQLiteIndexContextData.h | 16 + .../Public/winget/SQLiteMetadataTable.h | 14 +- .../Public/winget/SQLiteStatementBuilder.h | 45 + .../Public/winget/SQLiteStorageBase.h | 12 +- .../Public/winget/SQLiteVersion.h | 5 +- .../Public/winget/SQLiteWrapper.h | 47 +- .../SQLiteMetadataTable.cpp | 27 +- .../SQLiteStatementBuilder.cpp | 80 +- .../SQLiteStorageBase.cpp | 67 +- src/AppInstallerSharedLib/SQLiteVersion.cpp | 6 +- src/AppInstallerSharedLib/SQLiteWrapper.cpp | 112 +- 41 files changed, 5832 insertions(+), 764 deletions(-) create mode 100644 src/AppInstallerCLITests/SQLiteIndexDelta.cpp create mode 100644 src/AppInstallerCLITests/SQLiteIndexTestCommon.cpp create mode 100644 src/AppInstallerCLITests/SQLiteIndexTestCommon.h create mode 100644 src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaGeneration.cpp create mode 100644 src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaGeneration.h create mode 100644 src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaTables.cpp create mode 100644 src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaTables.h create mode 100644 src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaViews.cpp create mode 100644 src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaViews.h create mode 100644 src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/Interface.h create mode 100644 src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/Interface_2_1.cpp diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 7fe36d24d7..3959424ff3 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -213,6 +213,7 @@ normalizedpackagenameandpublisher notcontains NTAPI NTSTATUS +nullability nullsoft nunit nupkg diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj index 8f624eb24d..7680564e42 100644 --- a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj +++ b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj @@ -212,6 +212,7 @@ + @@ -329,6 +330,8 @@ + + diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters index 5fc2e79a90..d187f219e1 100644 --- a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters +++ b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters @@ -99,6 +99,9 @@ Header Files + + Header Files + @@ -419,6 +422,15 @@ Source Files\Repository + + Source Files + + + Source Files\Repository + + + Source Files\Repository + diff --git a/src/AppInstallerCLITests/SQLiteIndex.cpp b/src/AppInstallerCLITests/SQLiteIndex.cpp index 99b6dd8786..713a993018 100644 --- a/src/AppInstallerCLITests/SQLiteIndex.cpp +++ b/src/AppInstallerCLITests/SQLiteIndex.cpp @@ -2,13 +2,14 @@ // Licensed under the MIT License. #include "pch.h" #include "TestCommon.h" +#include "SQLiteIndexTestCommon.h" #include #include #include #include #include #include -#include +#include #include #include @@ -35,22 +36,6 @@ using namespace AppInstaller::SQLite; using namespace AppInstaller::Utility; using UtilityVersion = AppInstaller::Utility::Version; -using SQLiteVersion = AppInstaller::SQLite::Version; - -SQLiteIndex CreateTestIndex(const std::string& filePath, std::optional version = {}) -{ - // If no specific version requested, then use generator to run against the last 3 versions. - if (!version) - { - SQLiteVersion latestVersion{ 2, 0 }; - SQLiteVersion versionMinus1 = SQLiteVersion{ 1, 7 }; - SQLiteVersion versionMinus2 = SQLiteVersion{ 1, 6 }; - - version = GENERATE_COPY(SQLiteVersion{ versionMinus2 }, SQLiteVersion{ versionMinus1 }, SQLiteVersion{ latestVersion }); - } - - return SQLiteIndex::CreateNew(filePath, version.value()); -} SQLiteVersion TestPrepareForRead(SQLiteIndex& index) { @@ -82,26 +67,6 @@ SQLiteVersion TestPrepareForRead(SQLiteIndex& index) return index.GetVersion(); } -std::string GetPathFromManifest(Manifest& manifest) -{ - auto publisher = manifest.Id; - AppInstaller::Utility::FindAndReplace(publisher, ".", "/"); - - return AppInstaller::Utility::ToLower(publisher).append("/").append(manifest.Version); -} - -void CreateFakeManifest(Manifest& manifest, string_t publisher, string_t version = "1.0.0") -{ - manifest.Installers.push_back({}); - manifest.Id = publisher.append(".").append("Id"); - manifest.DefaultLocalization.Add(publisher.append(" Name")); - manifest.Moniker = "testmoniker"; - manifest.Version = version; - manifest.Channel = "test"; - manifest.DefaultLocalization.Add({ "t1", "t2" }); - manifest.Installers[0].Commands = { "test1", "test2" }; -} - SQLiteIndex SimpleTestSetup(const std::string& filePath, Manifest& manifest, std::optional version = {}) { SQLiteIndex index = CreateTestIndex(filePath, version); @@ -116,176 +81,6 @@ SQLiteIndex SimpleTestSetup(const std::string& filePath, Manifest& manifest, std return index; } -struct IndexFields -{ - IndexFields( - std::string id, - std::string name, - std::string moniker, - std::string version, - std::string channel, - std::vector tags, - std::vector commands, - std::string path - ) : - Id(std::move(id)), - Name(std::move(name)), - Moniker(std::move(moniker)), - Version(std::move(version)), - Channel(std::move(channel)), - Tags(std::move(tags)), - Commands(std::move(commands)), - Path(std::move(path)) - {} - - IndexFields( - std::string id, - std::string name, - std::string moniker, - std::string version, - std::string channel, - std::vector tags, - std::vector commands, - std::string path, - std::vector packageFamilyNames, - std::vector productCodes - ) : - Id(std::move(id)), - Name(std::move(name)), - Moniker(std::move(moniker)), - Version(std::move(version)), - Channel(std::move(channel)), - Tags(std::move(tags)), - Commands(std::move(commands)), - Path(std::move(path)), - PackageFamilyNames(std::move(packageFamilyNames)), - ProductCodes(std::move(productCodes)) - {} - - IndexFields( - std::string id, - std::string name, - std::string publisher, - std::string moniker, - std::string version, - std::string channel, - std::vector tags, - std::vector commands, - std::string path, - std::vector packageFamilyNames, - std::vector productCodes - ) : - Id(std::move(id)), - Name(std::move(name)), - Publisher(std::move(publisher)), - Moniker(std::move(moniker)), - Version(std::move(version)), - Channel(std::move(channel)), - Tags(std::move(tags)), - Commands(std::move(commands)), - Path(std::move(path)), - PackageFamilyNames(std::move(packageFamilyNames)), - ProductCodes(std::move(productCodes)) - {} - - IndexFields( - std::string id, - std::string name, - std::string publisher, - std::string moniker, - std::string version, - std::string channel, - std::vector tags, - std::vector commands, - std::string path, - std::vector packageFamilyNames, - std::vector productCodes, - std::string arpName, - std::string arpPublisher - ) : - Id(std::move(id)), - Name(std::move(name)), - Publisher(std::move(publisher)), - Moniker(std::move(moniker)), - Version(std::move(version)), - Channel(std::move(channel)), - Tags(std::move(tags)), - Commands(std::move(commands)), - Path(std::move(path)), - PackageFamilyNames(std::move(packageFamilyNames)), - ProductCodes(std::move(productCodes)), - ArpName(std::move(arpName)), - ArpPublisher(std::move(arpPublisher)) - {} - - std::string Id; - std::string Name; - std::string Publisher; - std::string Moniker; - std::string Version; - std::string Channel; - std::vector Tags; - std::vector Commands; - std::string Path; - std::vector PackageFamilyNames; - std::vector ProductCodes; - std::string ArpName; - std::string ArpPublisher; -}; - -SQLiteIndex SearchTestSetup(const std::string& filePath, std::initializer_list data = {}, std::optional version = {}) -{ - SQLiteIndex index = CreateTestIndex(filePath, version); - - Manifest manifest; - - auto addFunc = [&](const IndexFields& d) - { - manifest.Id = d.Id; - manifest.DefaultLocalization.Add(d.Name); - manifest.DefaultLocalization.Add(d.Publisher); - manifest.Moniker = d.Moniker; - manifest.Version = d.Version; - manifest.DefaultLocalization.Add(d.Tags); - - manifest.Installers.resize(std::max(d.PackageFamilyNames.size(), d.ProductCodes.size())); - - if (manifest.Installers.size() == 0) - { - manifest.Installers.push_back({}); - } - - manifest.Channel = d.Channel; - manifest.Installers[0].Commands = d.Commands; - - for (size_t i = 0; i < d.PackageFamilyNames.size(); ++i) - { - manifest.Installers[i].PackageFamilyName = d.PackageFamilyNames[i]; - } - - for (size_t i = 0; i < d.ProductCodes.size(); ++i) - { - manifest.Installers[i].ProductCode = d.ProductCodes[i]; - } - - if (!d.ArpName.empty() || !d.ArpPublisher.empty()) - { - manifest.Installers[0].AppsAndFeaturesEntries.push_back({}); - manifest.Installers[0].AppsAndFeaturesEntries[0].DisplayName = d.ArpName; - manifest.Installers[0].AppsAndFeaturesEntries[0].Publisher = d.ArpPublisher; - } - - index.AddManifest(manifest, d.Path); - }; - - for (const auto& d : data) - { - addFunc(d); - } - - return index; -} - bool ArePackageFamilyNameAndProductCodeSupported(const SQLiteIndex& index, const SQLiteVersion& testVersion) { UNSCOPED_INFO("Index " << index.GetVersion() << " | Test " << testVersion); @@ -3582,7 +3377,7 @@ TEST_CASE("SQLiteIndex_MigrateTo_Data", "[sqliteindex][V2_0]") REQUIRE(index.GetVersion() == SQLiteVersion{ 2, 0 }); Connection connection = Connection::Create(tempFile, Connection::OpenDisposition::ReadWrite); - auto updateData = Schema::V2_0::PackageUpdateTrackingTable::GetUpdatesSince(connection, 0); + auto updateData = Schema::V2_0::PackageUpdateTrackingTable::GetUpdatesSince(connection, 0, Schema::V2_0::PackageUpdateTrackingTable::RemovalBehavior::Delete); REQUIRE(updateData.size() == 3); REQUIRE(std::count_if(updateData.begin(), updateData.end(), [&](const auto& x) { return x.PackageIdentifier == packageId1; }) == 1); @@ -3607,38 +3402,6 @@ TEST_CASE("SQLiteIndex_Property_IntermediateFilePath", "[sqliteindex]") REQUIRE(contextData.Get() == intermediateFilePath); } -struct ManifestAndPath -{ - Manifest Manifest; - std::string Path; -}; - -void CreateFakeManifestAndPath( - ManifestAndPath& manifestAndPath, - const string_t& publisher, - std::string_view version = "1.0.0", - std::optional arpMinVersion = {}, - std::optional arpMaxVersion = {}) -{ - CreateFakeManifest(manifestAndPath.Manifest, publisher, version); - manifestAndPath.Path = ConvertToUTF8(CreateNewGuidNameWString()); - manifestAndPath.Manifest.StreamSha256 = SHA256::ComputeHash(manifestAndPath.Path); - - if (arpMinVersion) - { - manifestAndPath.Manifest.Installers[0].BaseInstallerType = InstallerTypeEnum::Exe; - manifestAndPath.Manifest.Installers[0].AppsAndFeaturesEntries.push_back({}); - manifestAndPath.Manifest.Installers[0].AppsAndFeaturesEntries.back().DisplayVersion = arpMinVersion.value(); - } - - if (arpMaxVersion) - { - manifestAndPath.Manifest.Installers[0].BaseInstallerType = InstallerTypeEnum::Exe; - manifestAndPath.Manifest.Installers[0].AppsAndFeaturesEntries.push_back({}); - manifestAndPath.Manifest.Installers[0].AppsAndFeaturesEntries.back().DisplayVersion = arpMaxVersion.value(); - } -} - std::filesystem::path GetOnlyChild(const std::filesystem::path& parent) { auto parentDirectoryIterator = std::filesystem::directory_iterator{ parent }; @@ -3963,3 +3726,4 @@ TEST_CASE("SQLiteIndex_VersionStringPreserved", "[sqliteindex]") REQUIRE(extractedVersion == version); } + diff --git a/src/AppInstallerCLITests/SQLiteIndexDelta.cpp b/src/AppInstallerCLITests/SQLiteIndexDelta.cpp new file mode 100644 index 0000000000..02b835ffad --- /dev/null +++ b/src/AppInstallerCLITests/SQLiteIndexDelta.cpp @@ -0,0 +1,2346 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "TestCommon.h" +#include "SQLiteIndexTestCommon.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace std::string_literals; +using namespace std::string_view_literals; +using namespace TestCommon; +using namespace AppInstaller::Manifest; +using namespace AppInstaller::Repository; +using namespace AppInstaller::Repository::Microsoft; +using namespace AppInstaller::SQLite; +using namespace AppInstaller::Utility; + +using Tracking = Schema::V2_0::PackageUpdateTrackingTable; +namespace Delta = AppInstaller::Repository::Microsoft::Schema::V2_1::Delta; + +namespace +{ + // The first version that can produce or consume a delta. + const SQLiteVersion s_DeltaVersion{ 2, 1 }; + + // Runs a query that produces a single integer. + int64_t GetScalar(const Connection& connection, const std::string& sql) + { + Statement statement = Statement::Create(connection, sql); + REQUIRE(statement.Step()); + return statement.GetColumn(0); + } + + // Counts the rows in a table. + int64_t GetRowCount(const Connection& connection, std::string_view tableName) + { + return GetScalar(connection, "SELECT COUNT(*) FROM [" + std::string{ tableName } + "]"); + } + + // Reads every value in a single column of a query as a set. + std::set GetStrings(const Connection& connection, const std::string& sql) + { + std::set result; + Statement statement = Statement::Create(connection, sql); + + while (statement.Step()) + { + result.insert(statement.GetColumn(0)); + } + + return result; + } + + std::set ToStringSet(const std::vector& values) + { + return std::set(values.begin(), values.end()); + } + + std::set ToFoldedStringSet(const std::vector& values) + { + std::set result; + for (const auto& value : values) + { + result.insert(FoldCase(value)); + } + return result; + } + + // Files sitting beside the delta output whose names extend it. Generation builds the delta at a + // temporary sibling, so a leftover here means a failure did not clean up after itself. + std::vector GetDeltaOutputSiblings(const std::filesystem::path& deltaPath) + { + std::vector result; + std::string prefix = deltaPath.filename().u8string(); + + for (const auto& entry : std::filesystem::directory_iterator{ deltaPath.parent_path() }) + { + std::string name = entry.path().filename().u8string(); + + if (name.size() > prefix.size() && name.compare(0, prefix.size(), prefix) == 0) + { + result.push_back(entry.path()); + } + } + + return result; + } + + // Reads the rowid that a prepared index gave a package identifier. + std::optional GetPreparedPackageRowId(const std::filesystem::path& indexPath, std::string_view packageIdentifier) + { + Connection connection = Connection::Create(indexPath.u8string(), Connection::OpenDisposition::ReadOnly); + Statement statement = Statement::Create(connection, "SELECT rowid FROM packages WHERE id = ?"); + statement.Bind(1, std::string{ packageIdentifier }); + + if (statement.Step()) + { + return statement.GetColumn(0); + } + + return {}; + } + + // Reads a package's associated 1:N values through the given connection. + std::set GetOneToManyValues( + const Connection& connection, + std::string_view tableName, + std::string_view valueName, + std::string_view packageId) + { + std::string sql = + "SELECT [v].[" + std::string{ valueName } + "] FROM [" + std::string{ tableName } + "] AS [v] " + "JOIN [" + std::string{ tableName } + "_map] AS [m] ON [m].[" + std::string{ valueName } + "] = [v].[rowid] " + "JOIN [packages] AS [p] ON [p].[rowid] = [m].[package] " + "WHERE [p].[id] = '" + std::string{ packageId } + "'"; + + return GetStrings(connection, sql); + } + + // Reads a package's system reference values, which are stored directly against the package. + std::set GetSystemReferenceValues( + const Connection& connection, + std::string_view tableName, + std::string_view valueName, + std::string_view packageId) + { + std::string sql = + "SELECT [s].[" + std::string{ valueName } + "] FROM [" + std::string{ tableName } + "] AS [s] " + "JOIN [packages] AS [p] ON [p].[rowid] = [s].[package] " + "WHERE [p].[id] = '" + std::string{ packageId } + "'"; + + return GetStrings(connection, sql); + } + + struct DeltaTestContext + { + TempFile WorkingFile{ "delta_working"s, ".db"s }; + TempFile BaselineFile{ "delta_baseline"s, ".db"s }; + TempFile DeltaFile{ "delta_output"s, ".db"s }; + + DeltaTestContext() = default; + + // Creates the working index and fills it with the data the baseline will hold. + DeltaTestContext(std::initializer_list baselineData) + { + CreateWorking(baselineData); + CaptureBaseline(); + } + + void CreateWorking(std::initializer_list baselineData) + { + SQLiteIndex index = SQLiteIndex::CreateNew(WorkingFile, s_DeltaVersion); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + + for (const auto& fields : baselineData) + { + Manifest manifest = CreateManifest(fields); + index.AddManifest(manifest, fields.Path); + } + } + + // Copies the working index out, prepares it, and designates it as a baseline. + // Everything the working index does afterwards is what the delta will describe. + void CaptureBaseline(bool markAsBaseline = true) + { + std::filesystem::copy_file(WorkingFile.GetPath(), BaselineFile.GetPath(), std::filesystem::copy_options::overwrite_existing); + + SQLiteIndex prepared = SQLiteIndex::Open(BaselineFile.GetPath().u8string(), SQLiteStorageBase::OpenDisposition::ReadWrite); + prepared.PrepareForPackaging(); + + if (markAsBaseline) + { + prepared.MarkAsBaseline(); + } + + m_baselineCaptured = true; + } + + // Opens the working index for the changes that the delta will carry. + SQLiteIndex OpenWorkingForChanges(bool resetBaseTimeIfNeeded = true) + { + SQLiteIndex index = SQLiteIndex::Open(WorkingFile, SQLiteStorageBase::OpenDisposition::ReadWrite); + + if (resetBaseTimeIfNeeded && !m_baseTimeReset) + { + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, ""); + m_baseTimeReset = true; + } + + return index; + } + + void Add(const IndexFields& fields, bool resetBaseTimeIfNeeded = true) + { + SQLiteIndex index = OpenWorkingForChanges(resetBaseTimeIfNeeded); + Manifest manifest = CreateManifest(fields); + index.AddManifest(manifest, fields.Path); + } + + void Update(const IndexFields& fields) + { + SQLiteIndex index = OpenWorkingForChanges(); + Manifest manifest = CreateManifest(fields); + REQUIRE(index.UpdateManifest(manifest, fields.Path)); + } + + void Remove(const IndexFields& fields) + { + SQLiteIndex index = OpenWorkingForChanges(); + Manifest manifest = CreateManifest(fields); + index.RemoveManifest(manifest, fields.Path); + } + + // Prepares the working index, producing the delta as a side effect. + void GenerateDelta() + { + REQUIRE(m_baselineCaptured); + + SQLiteIndex index = SQLiteIndex::Open(WorkingFile, SQLiteStorageBase::OpenDisposition::ReadWrite); + index.SetProperty(SQLiteIndex::Property::DeltaBaselineIndexPath, BaselineFile.GetPath().u8string()); + index.SetProperty(SQLiteIndex::Property::DeltaOutputPath, DeltaFile.GetPath().u8string()); + index.PrepareForPackaging(); + + m_deltaGenerated = true; + } + + // Opens the delta on its own, to inspect what generation actually wrote. + Connection OpenDeltaConnection() + { + REQUIRE(std::filesystem::exists(DeltaFile.GetPath())); + return Connection::Create(DeltaFile.GetPath().u8string(), Connection::OpenDisposition::ReadOnly); + } + + // Opens the delta with its baseline attached and the merged views in place, without going + // through SQLiteIndex, so that a test can read the merged tables directly. + Connection OpenMergedConnection() + { + Connection connection = OpenDeltaConnection(); + Delta::SetupReadMode(connection, DatabaseSpecifier{ BaselineFile.GetPath().u8string(), DatabaseDisposition::Read }); + return connection; + } + + SQLiteIndex OpenCombined(SQLiteStorageBase::OpenDisposition disposition = SQLiteStorageBase::OpenDisposition::Read) + { + REQUIRE(m_deltaGenerated); + return SQLiteIndex::OpenWithBaseline(DeltaFile.GetPath().u8string(), BaselineFile.GetPath().u8string(), disposition); + } + + // The working index, once prepared, is an ordinary full index built from the same data that + // the delta describes. That makes it the reference for equivalence. + SQLiteIndex OpenFullIndex() + { + REQUIRE(m_deltaGenerated); + return SQLiteIndex::Open(WorkingFile.GetPath().u8string(), SQLiteStorageBase::OpenDisposition::Read); + } + + std::string DeltaTable(std::string_view baseTableName) const + { + return Delta::GetTableName(baseTableName); + } + + std::string DeltaMapTable(std::string_view baseTableName) const + { + return Delta::GetMapTableName(baseTableName); + } + + private: + bool m_baselineCaptured = false; + bool m_baseTimeReset = false; + bool m_deltaGenerated = false; + }; + + IndexFields MakePackage( + std::string id, + std::string name, + std::vector tags = { "t1", "t2" }, + std::vector commands = { "c1" }, + std::vector packageFamilyNames = {}, + std::vector productCodes = {}, + std::string version = "1.0"s) + { + std::string path = id + "/" + version; + + return IndexFields{ + id, + std::move(name), + "Publisher"s, + "moniker"s, + std::move(version), + ""s, + std::move(tags), + std::move(commands), + std::move(path), + std::move(packageFamilyNames), + std::move(productCodes) }; + } + + // Collects the identifiers that a search returns. + std::set GetSearchedIds(const SQLiteIndex& index, const SearchRequest& request = {}) + { + std::set result; + + for (const auto& match : index.Search(request).Matches) + { + auto id = index.GetPropertyByPrimaryId(match.first, PackageVersionProperty::Id); + REQUIRE(id.has_value()); + result.insert(id.value()); + } + + return result; + } +} + +// --------------------------------------------------------------------------------------------- +// Group B - package rowid identity +// +// The merged package's view suppresses a baseline row when the delta names the same rowid, and +// every association refers to a package by that rowid. These cases cover the ways that identity +// can be broken. +// --------------------------------------------------------------------------------------------- + +// B1. A package removed and re-added lands on a new rowid, so the delta has to both introduce it +// at the new rowid and vacate the old one. Recording only the add leaves the baseline row in +// place, and the package appears twice. +TEST_CASE("SQLiteIndex_Delta_PackageRemovedThenReAdded", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + auto p3 = MakePackage("Publisher3.Id", "Package 3"); + + DeltaTestContext context{ { p1, p2, p3 } }; + + rowid_t originalRowId = GetPreparedPackageRowId(context.BaselineFile.GetPath(), p2.Id).value(); + + // Removing the middle package frees its rowid, but the next insert takes one above the highest + // in use, so the re-add cannot land back on it. + context.Remove(p2); + context.Add(p2); + + context.GenerateDelta(); + + rowid_t newRowId = GetPreparedPackageRowId(context.WorkingFile.GetPath(), p2.Id).value(); + REQUIRE(newRowId != originalRowId); + + { + Connection delta = context.OpenDeltaConnection(); + + // The old rowid must be vacated, or nothing suppresses the baseline row. + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_packages] WHERE [rowid] = " + std::to_string(originalRowId) + " AND [is_removed] = 1") == 1); + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_packages] WHERE [rowid] = " + std::to_string(newRowId) + " AND [is_removed] = 0") == 1); + } + + SQLiteIndex combined = context.OpenCombined(); + + SearchRequest request; + request.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::Id, MatchType::Exact, p2.Id)); + + // Exactly one, and from the delta rather than the stale baseline row. + auto results = combined.Search(request); + REQUIRE(results.Matches.size() == 1); + + REQUIRE(GetSearchedIds(combined) == std::set{ p1.Id, p2.Id, p3.Id }); +} + +// B2. Removing the package that holds the highest rowid frees it for the next package added, so +// a removal and a change can name the same rowid. Writing both violates the delta's primary key. +TEST_CASE("SQLiteIndex_Delta_RemovedPackageRowIdReused", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + auto p3 = MakePackage("Publisher3.Id", "Package 3"); + + DeltaTestContext context{ { p1, p2, p3 } }; + + rowid_t reusedRowId = GetPreparedPackageRowId(context.BaselineFile.GetPath(), p3.Id).value(); + + auto p4 = MakePackage("Publisher4.Id", "Package 4"); + + context.Remove(p3); + context.Add(p4); + + REQUIRE_NOTHROW(context.GenerateDelta()); + + // The new package took the rowid that the removed one gave up. + REQUIRE(GetPreparedPackageRowId(context.WorkingFile.GetPath(), p4.Id).value() == reusedRowId); + + { + Connection delta = context.OpenDeltaConnection(); + + // Only the change is recorded. The removal would be redundant, since a delta row at that + // rowid already displaces the baseline row, and it cannot be written in any case. + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_packages] WHERE [rowid] = " + std::to_string(reusedRowId)) == 1); + REQUIRE(GetStrings(delta, "SELECT [id] FROM [delta_packages] WHERE [rowid] = " + std::to_string(reusedRowId)) == + std::set{ p4.Id }); + } + + SQLiteIndex combined = context.OpenCombined(); + REQUIRE(GetSearchedIds(combined) == std::set{ p1.Id, p2.Id, p4.Id }); +} + +// B2 continued. The displaced package's associations must go with it. They are compared against the +// baseline at the shared rowid, so the values belonging to the old occupant are removed. +TEST_CASE("SQLiteIndex_Delta_ReusedRowIdReplacesAssociations", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1", { "keep" }); + auto p2 = MakePackage("Publisher2.Id", "Package 2", { "old1", "old2" }, { "old_cmd" }, {}, { "OLD_PC" }); + + DeltaTestContext context{ { p1, p2 } }; + + auto p3 = MakePackage("Publisher3.Id", "Package 3", { "new1" }, { "new_cmd" }, {}, { "NEW_PC" }); + + context.Remove(p2); + context.Add(p3); + context.GenerateDelta(); + + Connection merged = context.OpenMergedConnection(); + + // Nothing of the old occupant survives at the shared rowid. + REQUIRE(GetOneToManyValues(merged, "tags2", "tag", p3.Id) == ToStringSet(p3.Tags)); + REQUIRE(GetOneToManyValues(merged, "commands2", "command", p3.Id) == ToStringSet(p3.Commands)); + REQUIRE(GetSystemReferenceValues(merged, "productcodes2", "productcode", p3.Id) == ToFoldedStringSet(p3.ProductCodes)); + + // The untouched package is unaffected. + REQUIRE(GetOneToManyValues(merged, "tags2", "tag", p1.Id) == ToStringSet(p1.Tags)); +} + +// B6. Remove, re-add, and remove again leaves a tombstone for each rowid the package vacated. Both +// are reported, but only one of them names a rowid the baseline holds, so only one removal is +// written — a delta that recorded the transient rowid too would claim to suppress a row that has +// never existed. +TEST_CASE("SQLiteIndex_Delta_RemoveAddRemove", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + auto p3 = MakePackage("Publisher3.Id", "Package 3"); + + DeltaTestContext context{ { p1, p2, p3 } }; + + rowid_t originalRowId = GetPreparedPackageRowId(context.BaselineFile.GetPath(), p2.Id).value(); + + context.Remove(p2); + context.Add(p2); + context.Remove(p2); + + { + Connection working = Connection::Create(context.WorkingFile.GetPath().u8string(), Connection::OpenDisposition::ReadOnly); + + // One tombstone per vacated rowid, and generation is told about both of them. + REQUIRE(GetScalar(working, "SELECT COUNT(*) FROM [update_tracking] WHERE [package] = '" + p2.Id + "' AND [is_removed] = 1") == 2); + + auto removals = Tracking::GetRemovalsSince(working, 0, Tracking::RemovalBehavior::Record); + REQUIRE(removals.size() == 2); + REQUIRE(removals.count(originalRowId) == 1); + } + + REQUIRE_NOTHROW(context.GenerateDelta()); + + { + Connection delta = context.OpenDeltaConnection(); + // Only the rowid the baseline actually holds is written; the one the re-add briefly + // occupied is above the baseline's range, so there is nothing there to suppress. + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_packages] WHERE [id] = '" + p2.Id + "'") == 1); + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_packages] WHERE [rowid] = " + std::to_string(originalRowId) + " AND [is_removed] = 1") == 1); + } + + SQLiteIndex combined = context.OpenCombined(); + REQUIRE(GetSearchedIds(combined) == std::set{ p1.Id, p3.Id }); +} + +// B14. The same sequence, but the re-add changes the casing of the identifier. The tracking table +// freezes the casing of each row at insert, so the two tombstones carry byte different names. With +// identity settled on the rowid this is uneventful, which is exactly what it is asserting: no part +// of the removal path compares the two spellings, so nothing can get the comparison wrong. +TEST_CASE("SQLiteIndex_Delta_RemoveAddRemove_CasingChanged", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + auto p3 = MakePackage("Publisher3.Id", "Package 3"); + auto p2_NewCase = MakePackage("publisher2.id", "Package 2"); + + DeltaTestContext context{ { p1, p2, p3 } }; + + rowid_t originalRowId = GetPreparedPackageRowId(context.BaselineFile.GetPath(), p2.Id).value(); + + // Publisher3 holds the highest rowid, so the re-add lands on a new one and leaves the first + // tombstone in place rather than reviving it. + context.Remove(p2); + context.Add(p2_NewCase); + context.Remove(p2_NewCase); + + { + Connection working = Connection::Create(context.WorkingFile.GetPath().u8string(), Connection::OpenDisposition::ReadOnly); + + // Two tombstones at two rowids. The differing casing does not participate in identity at + // all, which is the point: nothing here has to reconcile the two spellings. + auto removals = Tracking::GetRemovalsSince(working, 0, Tracking::RemovalBehavior::Record); + REQUIRE(removals.size() == 2); + REQUIRE(removals.count(originalRowId) == 1); + } + + REQUIRE_NOTHROW(context.GenerateDelta()); + + { + Connection delta = context.OpenDeltaConnection(); + + // Only the baseline rowid was written, and the identifier stored with it is the one the + // baseline holds rather than either spelling the tracking table recorded. + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_packages] WHERE [is_removed] = 1") == 1); + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_packages] WHERE [rowid] = " + std::to_string(originalRowId) + " AND [is_removed] = 1") == 1); + } + + SQLiteIndex combined = context.OpenCombined(); + REQUIRE(GetSearchedIds(combined) == std::set{ p1.Id, p3.Id }); +} + +// B7. When the re-add happens to land on the rowid the package just gave up, the tracking row is +// updated in place. Leaving a tombstone next to the live row would contradict it. +TEST_CASE("SQLiteIndex_Delta_ReAddOnSameRowIdUpdatesInPlace", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + + DeltaTestContext context{ { p1, p2 } }; + + rowid_t originalRowId = GetPreparedPackageRowId(context.BaselineFile.GetPath(), p2.Id).value(); + + // Publisher2 holds the highest rowid, so removing it frees the value that the re-add takes. + context.Remove(p2); + context.Add(p2); + + context.GenerateDelta(); + + REQUIRE(GetPreparedPackageRowId(context.WorkingFile.GetPath(), p2.Id).value() == originalRowId); + + Connection delta = context.OpenDeltaConnection(); + + // A single live row, and no tombstone left behind to contradict it. + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_packages] WHERE [id] = '" + p2.Id + "'") == 1); + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_packages] WHERE [id] = '" + p2.Id + "' AND [is_removed] = 0") == 1); + + SQLiteIndex combined = context.OpenCombined(); + REQUIRE(GetSearchedIds(combined) == std::set{ p1.Id, p2.Id }); +} + +// B8. Two identifiers legitimately share a rowid in the tracking table when one vacates it and the +// other takes it. A unique index on the rowid alone would reject exactly the case that the +// tombstone exists to record; only the live rows are constrained. +TEST_CASE("SQLiteIndex_Delta_TrackingAllowsSharedRowIdAcrossPackages", "[sqliteindex][V2_1][update_tracking]") +{ + TempFile indexFile{ "update_tracking"s, ".db"s }; + + ManifestAndPath m1; + CreateFakeManifestAndPath(m1, "Publisher1", "1.0"); + ManifestAndPath m2; + CreateFakeManifestAndPath(m2, "Publisher2", "1.0"); + + { + SQLiteIndex index = SQLiteIndex::CreateNew(indexFile, s_DeltaVersion); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + index.AddManifest(m1.Manifest, m1.Path); + index.RemoveManifest(m1.Manifest, m1.Path); + + // Publisher1 gave up the only rowid in use, so Publisher2 takes it. + REQUIRE_NOTHROW(index.AddManifest(m2.Manifest, m2.Path)); + REQUIRE(index.CheckConsistency(true)); + } + + Connection connection = Connection::Create(indexFile, Connection::OpenDisposition::ReadOnly); + + int64_t sharedRowId = GetScalar(connection, "SELECT [package_rowid] FROM [update_tracking] WHERE [package] = '" + m2.Manifest.Id + "'"); + REQUIRE(GetScalar(connection, "SELECT [package_rowid] FROM [update_tracking] WHERE [package] = '" + m1.Manifest.Id + "'") == sharedRowId); + + REQUIRE(GetScalar(connection, "SELECT COUNT(*) FROM [update_tracking] WHERE [package_rowid] = " + std::to_string(sharedRowId)) == 2); + REQUIRE(GetScalar(connection, "SELECT COUNT(*) FROM [update_tracking] WHERE [package_rowid] = " + std::to_string(sharedRowId) + " AND [is_removed] = 0") == 1); +} + +// B9. Identifiers that differ only by case are the same package. The index decides that with the +// ICU LIKE implementation, which folds beyond ASCII, so a NOCASE index would disagree here. +TEST_CASE("SQLiteIndex_Delta_TrackingIdentityIsIcuCaseInsensitive", "[sqliteindex][V2_1][update_tracking]") +{ + TempFile indexFile{ "update_tracking"s, ".db"s }; + + // Cyrillic, where the case mapping is well defined but outside the ASCII range that the + // built in NOCASE collation folds. + ManifestAndPath lower; + CreateFakeManifestAndPath(lower, "\xd0\xbf\xd1\x80\xd0\xb8\xd0\xbc\xd0\xb5\xd1\x80", "1.0"); + ManifestAndPath upper; + CreateFakeManifestAndPath(upper, "\xd0\x9f\xd0\xa0\xd0\x98\xd0\x9c\xd0\x95\xd0\xa0", "2.0"); + + { + SQLiteIndex index = SQLiteIndex::CreateNew(indexFile, s_DeltaVersion); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + index.AddManifest(lower.Manifest, lower.Path); + index.AddManifest(upper.Manifest, upper.Path); + REQUIRE(index.CheckConsistency(true)); + } + + Connection connection = Connection::Create(indexFile, Connection::OpenDisposition::ReadOnly); + + // One package, so one live tracking row. Two would mean the two spellings were treated as + // different packages, and the live row constraint failed to see them as one. + REQUIRE(GetScalar(connection, "SELECT COUNT(*) FROM [update_tracking] WHERE [is_removed] = 0") == 1); + REQUIRE(GetScalar(connection, "SELECT COUNT(DISTINCT [package_rowid]) FROM [update_tracking]") == 1); +} + +// B10. The rowid the tracking table stores has to be the one the prepared index assigns, since +// that is what generation and the merged views agree on. This also fails if pinning regresses. +TEST_CASE("SQLiteIndex_Delta_TrackingRowIdMatchesPreparedIndex", "[sqliteindex][V2_1][update_tracking]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + auto p3 = MakePackage("Publisher3.Id", "Package 3"); + + DeltaTestContext context{ { p1, p2, p3 } }; + + std::vector> tracked; + + { + Connection working = Connection::Create(context.WorkingFile.GetPath().u8string(), Connection::OpenDisposition::ReadOnly); + Statement statement = Statement::Create(working, "SELECT [package], [package_rowid] FROM [update_tracking] WHERE [is_removed] = 0"); + + while (statement.Step()) + { + tracked.emplace_back(statement.GetColumn(0), statement.GetColumn(1)); + } + } + + REQUIRE(tracked.size() == 3); + + for (const auto& [packageId, trackedRowId] : tracked) + { + INFO(packageId); + auto preparedRowId = GetPreparedPackageRowId(context.BaselineFile.GetPath(), packageId); + REQUIRE(preparedRowId.has_value()); + REQUIRE(preparedRowId.value() == trackedRowId); + } +} + +// B12. Package identity is case-insensitive everywhere in the index: the ids table collapses +// LIKE equal identifiers onto one rowid and overwrites the stored string with the most recent +// casing, while the tracking table freezes the casing it first saw. Generation therefore has to +// resolve a package across a casing difference between the two. +// +// On the changed path, byte equality fails to resolve the package and generation throws for the +// entire index. +TEST_CASE("SQLiteIndex_Delta_IdentifierCasingChange_Changed", "[sqliteindex][V2_1][delta]") +{ + auto original = MakePackage("Publisher1.Id", "Package 1"); + auto unchanged = MakePackage("Publisher2.Id", "Package 2"); + auto newCase = MakePackage("publisher1.id", "Package 1 V2", { "t1", "t2" }, { "c1" }, {}, {}, "2.0"s); + + DeltaTestContext context{ { original, unchanged } }; + + // Adding a version under a different casing rewrites the ids table entry, and with it the + // identifier that packaging will put in the packages table. The tracking row keeps the + // original casing. + context.Add(newCase); + + REQUIRE_NOTHROW(context.GenerateDelta()); + + SQLiteIndex combined = context.OpenCombined(); + + // Exactly one row for the package. Resolving to no rowid would have left the baseline row + // unsuppressed alongside the delta's, showing it twice. + REQUIRE(GetSearchedIds(combined) == std::set{ newCase.Id, unchanged.Id }); +} + +// B13. The silent half of the same defect. Here the casing changed before the baseline was taken, +// so the baseline holds the new casing while the tracking table still holds the old. A removal +// that cannot be resolved against the baseline is treated as "never existed there" and skipped, +// leaving the baseline row visible forever. +TEST_CASE("SQLiteIndex_Delta_IdentifierCasingChange_Removed", "[sqliteindex][V2_1][delta]") +{ + auto original = MakePackage("Publisher1.Id", "Package 1"); + auto newCase = MakePackage("publisher1.id", "Package 1 V2", { "t1", "t2" }, { "c1" }, {}, {}, "2.0"s); + auto unchanged = MakePackage("Publisher2.Id", "Package 2"); + + DeltaTestContext context; + context.CreateWorking({ original, unchanged }); + context.Add(newCase, false); + context.CaptureBaseline(); + + REQUIRE(GetPreparedPackageRowId(context.BaselineFile.GetPath(), newCase.Id).has_value()); + + context.Remove(original); + context.Remove(newCase); + + context.GenerateDelta(); + + SQLiteIndex combined = context.OpenCombined(); + + REQUIRE(GetSearchedIds(combined) == std::set{ unchanged.Id }); +} + +// B11. The migration adds the rowid column to a table whose rows predate it. A backfill that left +// nulls behind would break the first delta generated after an upgrade. +TEST_CASE("SQLiteIndex_Delta_TrackingMigrationBackfillsRowIds", "[sqliteindex][V2_1][update_tracking]") +{ + TempFile indexFile{ "update_tracking"s, ".db"s }; + + ManifestAndPath m1; + CreateFakeManifestAndPath(m1, "Publisher1", "1.0"); + ManifestAndPath m2; + CreateFakeManifestAndPath(m2, "Publisher2", "1.0"); + + { + SQLiteIndex index = SQLiteIndex::CreateNew(indexFile, SQLiteVersion{ 2, 0 }); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + index.AddManifest(m1.Manifest, m1.Path); + index.AddManifest(m2.Manifest, m2.Path); + } + + { + SQLiteIndex index = SQLiteIndex::Open(indexFile, SQLiteStorageBase::OpenDisposition::ReadWrite); + REQUIRE(index.MigrateTo(s_DeltaVersion)); + REQUIRE(index.CheckConsistency(true)); + } + + Connection connection = Connection::Create(indexFile, Connection::OpenDisposition::ReadOnly); + + REQUIRE(GetRowCount(connection, "update_tracking") == 2); + + // The rebuild has to carry the real rowid across, not leave the column at its initial value. + REQUIRE(GetScalar(connection, "SELECT COUNT(*) FROM [update_tracking] WHERE [package_rowid] = 0") == 0); + + // The backfilled value has to be the one the index itself uses, not just any non null. + for (const auto& packageId : { m1.Manifest.Id, m2.Manifest.Id }) + { + INFO(packageId); + int64_t tracked = GetScalar(connection, "SELECT [package_rowid] FROM [update_tracking] WHERE [package] = '" + std::string{ packageId } + "'"); + auto idRowId = Schema::V1_0::IdTable::SelectIdByValue(connection, std::string{ packageId }); + REQUIRE(idRowId.has_value()); + REQUIRE(idRowId.value() == tracked); + } +} + +// A 1.7 index migrating straight to 2.1 takes a different path than 2.0 -> 2.1: the 2.0 migration +// runs under the 2.1 interface, so it creates the tracking table already carrying the removal +// columns. Adding them again afterwards fails with a duplicate column, which made this migration +// impossible. The rowids it records also have to be the ones the index goes on to use, since a +// delta generated after the upgrade is keyed on them. +TEST_CASE("SQLiteIndex_Delta_TrackingMigrationFrom1_7", "[sqliteindex][V2_1][update_tracking]") +{ + TempFile indexFile{ "update_tracking"s, ".db"s }; + + ManifestAndPath m1; + CreateFakeManifestAndPath(m1, "Publisher1", "1.0"); + ManifestAndPath m2; + CreateFakeManifestAndPath(m2, "Publisher2", "1.0"); + + { + SQLiteIndex index = SQLiteIndex::CreateNew(indexFile, SQLiteVersion{ 1, 7 }); + index.AddManifest(m1.Manifest, m1.Path); + index.AddManifest(m2.Manifest, m2.Path); + } + + { + SQLiteIndex index = SQLiteIndex::Open(indexFile, SQLiteStorageBase::OpenDisposition::ReadWrite); + REQUIRE(index.MigrateTo(s_DeltaVersion)); + REQUIRE(index.GetVersion() == s_DeltaVersion); + REQUIRE(index.CheckConsistency(true)); + } + + { + Connection connection = Connection::Create(indexFile, Connection::OpenDisposition::ReadOnly); + + REQUIRE(GetRowCount(connection, "update_tracking") == 2); + REQUIRE(GetScalar(connection, "SELECT COUNT(*) FROM [update_tracking] WHERE [is_removed] != 0") == 0); + REQUIRE(GetScalar(connection, "SELECT COUNT(*) FROM [update_tracking] WHERE [package_rowid] = 0") == 0); + + // A 1.7 table has no sequences at all, so these are issued rather than backfilled. + REQUIRE(GetScalar(connection, "SELECT COUNT(DISTINCT [change_seq]) FROM [update_tracking]") == 2); + REQUIRE(GetScalar(connection, "SELECT MIN([change_seq]) FROM [update_tracking]") == 1); + + for (const auto& packageId : { m1.Manifest.Id, m2.Manifest.Id }) + { + INFO(packageId); + int64_t tracked = GetScalar(connection, "SELECT [package_rowid] FROM [update_tracking] WHERE [package] = '" + std::string{ packageId } + "'"); + auto idRowId = Schema::V1_0::IdTable::SelectIdByValue(connection, std::string{ packageId }); + REQUIRE(idRowId.has_value()); + REQUIRE(idRowId.value() == tracked); + } + } + + // The tracked rowid is only useful if it is the one that packaging pins, as that is the + // identity a delta built against this index would be expressed in. + TempFile preparedFile{ "update_tracking_prepared"s, ".db"s }; + std::filesystem::copy_file(indexFile.GetPath(), preparedFile.GetPath(), std::filesystem::copy_options::overwrite_existing); + + std::map trackedRowIds; + + { + Connection connection = Connection::Create(indexFile, Connection::OpenDisposition::ReadOnly); + Statement statement = Statement::Create(connection, "SELECT [package], [package_rowid] FROM [update_tracking]"); + + while (statement.Step()) + { + trackedRowIds.emplace(statement.GetColumn(0), statement.GetColumn(1)); + } + } + + { + SQLiteIndex prepared = SQLiteIndex::Open(preparedFile.GetPath().u8string(), SQLiteStorageBase::OpenDisposition::ReadWrite); + prepared.PrepareForPackaging(); + } + + REQUIRE(trackedRowIds.size() == 2); + + for (const auto& tracked : trackedRowIds) + { + INFO(tracked.first); + auto preparedRowId = GetPreparedPackageRowId(preparedFile.GetPath(), tracked.first); + REQUIRE(preparedRowId.has_value()); + REQUIRE(preparedRowId.value() == tracked.second); + } +} + +// The 2.0 table has to produce exactly the table it always +// Nullability on the data columns relaxes only in 2.1, where a tombstone has no manifest or +// hash to record. SQLite cannot drop a not null constraint in place, so the migration rebuilds the +// table — and a rebuild is a data move, which has to arrive intact. +TEST_CASE("SQLiteIndex_Delta_TrackingMigrationRelaxesDataNullability", "[sqliteindex][V2_1][update_tracking]") +{ + using RowData = std::pair; + + TempFile indexFile{ "update_tracking"s, ".db"s }; + + ManifestAndPath m1; + CreateFakeManifestAndPath(m1, "Publisher1", "1.0"); + ManifestAndPath m2; + CreateFakeManifestAndPath(m2, "Publisher2", "1.0"); + + { + SQLiteIndex index = SQLiteIndex::CreateNew(indexFile, SQLiteVersion{ 2, 0 }); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + index.AddManifest(m1.Manifest, m1.Path); + index.AddManifest(m2.Manifest, m2.Path); + } + + auto readRows = [](const Connection& connection) + { + std::map result; + Statement statement = Statement::Create(connection, "SELECT [package], HEX([manifest]), HEX([hash]) FROM [update_tracking]"); + + while (statement.Step()) + { + result.emplace(statement.GetColumn(0), RowData{ statement.GetColumn(1), statement.GetColumn(2) }); + } + + return result; + }; + + std::map before; + + { + Connection connection = Connection::Create(indexFile, Connection::OpenDisposition::ReadWrite); + + // A 2.0 table requires both, which is the constraint that cannot survive into 2.1. + REQUIRE_THROWS(Statement::Create(connection, "UPDATE [update_tracking] SET [manifest] = NULL").Execute()); + REQUIRE_THROWS(Statement::Create(connection, "UPDATE [update_tracking] SET [hash] = NULL").Execute()); + + before = readRows(connection); + } + + REQUIRE(before.size() == 2); + + { + SQLiteIndex index = SQLiteIndex::Open(indexFile, SQLiteStorageBase::OpenDisposition::ReadWrite); + REQUIRE(index.MigrateTo(s_DeltaVersion)); + REQUIRE(index.CheckConsistency(true)); + } + + { + Connection connection = Connection::Create(indexFile, Connection::OpenDisposition::ReadWrite); + + REQUIRE(readRows(connection) == before); + + // Only the columns a tombstone clears become nullable; the rest of the table does not. + REQUIRE_NOTHROW(Statement::Create(connection, "UPDATE [update_tracking] SET [manifest] = NULL, [hash] = NULL").Execute()); + REQUIRE_THROWS(Statement::Create(connection, "UPDATE [update_tracking] SET [package] = NULL").Execute()); + REQUIRE_THROWS(Statement::Create(connection, "UPDATE [update_tracking] SET [write_time] = NULL").Execute()); + REQUIRE_THROWS(Statement::Create(connection, "UPDATE [update_tracking] SET [is_removed] = NULL").Execute()); + REQUIRE_THROWS(Statement::Create(connection, "UPDATE [update_tracking] SET [package_rowid] = NULL").Execute()); + REQUIRE_THROWS(Statement::Create(connection, "UPDATE [update_tracking] SET [change_seq] = NULL").Execute()); + } +} + +// B3/B4. Rowids have to survive repeated preparation, not just one round. Two rounds cannot +// distinguish a stable assignment from one that happens to repeat. +TEST_CASE("SQLiteIndex_Delta_RowIdsAreStableAcrossPrepares", "[sqliteindex][V2_0]") +{ + TempFile workingFile{ "rowid_working"s, ".db"s }; + + std::vector manifests(5); + CreateFakeManifestAndPath(manifests[0], "Publisher1", "1.0"); + CreateFakeManifestAndPath(manifests[1], "Publisher2", "1.0"); + CreateFakeManifestAndPath(manifests[2], "Publisher3", "1.0"); + CreateFakeManifestAndPath(manifests[3], "Publisher4", "1.0"); + CreateFakeManifestAndPath(manifests[4], "Publisher5", "1.0"); + + { + SQLiteIndex index = SQLiteIndex::CreateNew(workingFile, SQLiteVersion{ 2, 0 }); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + index.AddManifest(manifests[0].Manifest, manifests[0].Path); + index.AddManifest(manifests[1].Manifest, manifests[1].Path); + index.AddManifest(manifests[2].Manifest, manifests[2].Path); + } + + auto prepareCopy = [&](const TempFile& target) + { + std::filesystem::copy_file(workingFile.GetPath(), target.GetPath(), std::filesystem::copy_options::overwrite_existing); + SQLiteIndex prepared = SQLiteIndex::Open(target.GetPath().u8string(), SQLiteStorageBase::OpenDisposition::ReadWrite); + prepared.PrepareForPackaging(); + }; + + TempFile first{ "rowid_first"s, ".db"s }; + prepareCopy(first); + + // Round two: drop one package and add another. + { + SQLiteIndex index = SQLiteIndex::Open(workingFile, SQLiteStorageBase::OpenDisposition::ReadWrite); + index.RemoveManifest(manifests[0].Manifest, manifests[0].Path); + index.AddManifest(manifests[3].Manifest, manifests[3].Path); + } + + TempFile second{ "rowid_second"s, ".db"s }; + prepareCopy(second); + + // Round three, to catch an assignment that only appears stable over a single step. + { + SQLiteIndex index = SQLiteIndex::Open(workingFile, SQLiteStorageBase::OpenDisposition::ReadWrite); + index.RemoveManifest(manifests[1].Manifest, manifests[1].Path); + index.AddManifest(manifests[4].Manifest, manifests[4].Path); + } + + TempFile third{ "rowid_third"s, ".db"s }; + prepareCopy(third); + + // Publisher3 is present throughout and must never move. + rowid_t p3First = GetPreparedPackageRowId(first.GetPath(), manifests[2].Manifest.Id).value(); + REQUIRE(GetPreparedPackageRowId(second.GetPath(), manifests[2].Manifest.Id).value() == p3First); + REQUIRE(GetPreparedPackageRowId(third.GetPath(), manifests[2].Manifest.Id).value() == p3First); + + // Publisher4 survives from round two to round three. + REQUIRE(GetPreparedPackageRowId(third.GetPath(), manifests[3].Manifest.Id).value() == GetPreparedPackageRowId(second.GetPath(), manifests[3].Manifest.Id).value()); + + // B5. A package added after the baseline must land above everything the baseline holds, or it + // would collide with an untouched package when the two are merged. + rowid_t maxInFirst = 0; + for (const auto& id : { manifests[0].Manifest.Id, manifests[1].Manifest.Id, manifests[2].Manifest.Id }) + { + maxInFirst = std::max(maxInFirst, GetPreparedPackageRowId(first.GetPath(), id).value()); + } + + REQUIRE(GetPreparedPackageRowId(second.GetPath(), manifests[3].Manifest.Id).value() > maxInFirst); +} + +// B6. Pinning the packages rowid changed which rowid a package lands on. It used to follow the +// alphabetical order that PrepareForPackaging iterates in, because rowids were handed out as the +// rows were inserted; it now follows the order the package was first added. Search orders only by +// match quality, so anything that ties is left to the order rows come out of the table. +TEST_CASE("SQLiteIndex_Delta_SearchRankingIsIndependentOfIngestionOrder", "[sqliteindex][V2_0]") +{ + std::vector packages{ + MakePackage("Publisher1.Id", "Alpha Package", { "shared" }), + MakePackage("Publisher2.Id", "Bravo Package", { "shared" }), + MakePackage("Publisher3.Id", "Charlie Package", { "shared" }), + MakePackage("Publisher4.Id", "Zulu Package", { "shared" }), + }; + + auto buildPrepared = [](const TempFile& file, const std::vector& order) + { + { + SQLiteIndex index = SQLiteIndex::CreateNew(file, SQLiteVersion{ 2, 0 }); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + + for (const auto& fields : order) + { + Manifest manifest = CreateManifest(fields); + index.AddManifest(manifest, fields.Path); + } + + index.PrepareForPackaging(); + } + + return SQLiteIndex::Open(file.GetPath().u8string(), SQLiteStorageBase::OpenDisposition::Read); + }; + + TempFile forwardFile{ "ranking_forward"s, ".db"s }; + SQLiteIndex forward = buildPrepared(forwardFile, packages); + + // The reversed ingestion is what makes the rowids differ; alphabetical order is unchanged. + std::vector reversedOrder{ packages.rbegin(), packages.rend() }; + TempFile reversedFile{ "ranking_reversed"s, ".db"s }; + SQLiteIndex reversed = buildPrepared(reversedFile, reversedOrder); + + // The premise of the test: the same package really does sit on a different rowid in each. + REQUIRE(GetPreparedPackageRowId(forwardFile.GetPath(), packages[0].Id).value() != + GetPreparedPackageRowId(reversedFile.GetPath(), packages[0].Id).value()); + + SearchRequest tied; + tied.Inclusions.emplace_back(PackageMatchField::Tag, MatchType::Exact, "shared"); + + SQLiteIndex::SearchResult forwardResults = forward.Search(tied); + SQLiteIndex::SearchResult reversedResults = reversed.Search(tied); + + REQUIRE(forwardResults.Matches.size() == packages.size()); + REQUIRE(reversedResults.Matches.size() == forwardResults.Matches.size()); + + // Every package is still found. + REQUIRE(GetSearchedIds(forward, tied) == GetSearchedIds(reversed, tied)); + + // And each position holds a match of the same quality, so the rowid a package happens to + // occupy cannot promote or demote it. The order within a tie is deliberately not asserted: + // the index does not define one, and ordering equally good matches is the caller's decision. + for (size_t i = 0; i < forwardResults.Matches.size(); ++i) + { + REQUIRE(forwardResults.Matches[i].second.Field == reversedResults.Matches[i].second.Field); + REQUIRE(forwardResults.Matches[i].second.Type == reversedResults.Matches[i].second.Type); + } + + // Where there is no tie there is an order, and it has to be the same one. + SearchRequest exact; + exact.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, packages[2].Id); + + SQLiteIndex::SearchResult forwardExact = forward.Search(exact); + SQLiteIndex::SearchResult reversedExact = reversed.Search(exact); + + REQUIRE(forwardExact.Matches.size() == 1); + REQUIRE(reversedExact.Matches.size() == 1); + REQUIRE(forward.GetPropertyByPrimaryId(forwardExact.Matches[0].first, PackageVersionProperty::Id).value() == packages[2].Id); + REQUIRE(reversed.GetPropertyByPrimaryId(reversedExact.Matches[0].first, PackageVersionProperty::Id).value() == packages[2].Id); +} + +// B7. Preparing resolves each package's identifier back to its ids rowid in order to pin it, and +// refuses to continue if that lookup comes up empty. The lookup is an exact match while the ids +// table collapses identifiers that differ only by case onto a single row, overwriting the stored +// string with the most recent casing. A package whose later version changes the casing of its +// identifier is therefore the case where the two could disagree. +TEST_CASE("SQLiteIndex_Delta_PrepareResolvesCaseChangedIdentifier", "[sqliteindex][V2_0]") +{ + auto original = MakePackage("Publisher1.Id", "Package 1"); + auto newCase = MakePackage("publisher1.id", "Package 1", { "t1", "t2" }, { "c1" }, {}, {}, "2.0"); + auto other = MakePackage("Publisher2.Id", "Package 2"); + + TempFile indexFile{ "case_changed_identifier"s, ".db"s }; + + { + SQLiteIndex index = SQLiteIndex::CreateNew(indexFile, SQLiteVersion{ 2, 0 }); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + + for (const auto& fields : { original, newCase, other }) + { + Manifest manifest = CreateManifest(fields); + index.AddManifest(manifest, fields.Path); + } + + REQUIRE_NOTHROW(index.PrepareForPackaging()); + } + + // The two versions collapsed onto one package, carrying the most recent casing. + { + Connection connection = Connection::Create(indexFile.GetPath().u8string(), Connection::OpenDisposition::ReadOnly); + REQUIRE(GetRowCount(connection, "packages") == 2); + } + + auto rowId = GetPreparedPackageRowId(indexFile.GetPath(), newCase.Id); + REQUIRE(rowId.has_value()); + + // Pinning still happened, which is the whole reason the lookup is there. + SQLiteIndex index = SQLiteIndex::Open(indexFile.GetPath().u8string(), SQLiteStorageBase::OpenDisposition::Read); + + SearchRequest request; + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, original.Id); + + SQLiteIndex::SearchResult result = index.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].first == rowId.value()); +} + +// --------------------------------------------------------------------------------------------- +// Group C - generation of the packages table +// --------------------------------------------------------------------------------------------- + +TEST_CASE("SQLiteIndex_Delta_AddedPackage", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + + DeltaTestContext context{ { p1 } }; + + context.Add(p2); + context.GenerateDelta(); + + Connection delta = context.OpenDeltaConnection(); + + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_packages] WHERE [is_removed] = 0") == 1); + REQUIRE(GetStrings(delta, "SELECT [id] FROM [delta_packages] WHERE [is_removed] = 0") == std::set{ p2.Id }); +} + +TEST_CASE("SQLiteIndex_Delta_RemovedPackage", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + + DeltaTestContext context{ { p1, p2 } }; + + context.Remove(p2); + context.GenerateDelta(); + + Connection delta = context.OpenDeltaConnection(); + + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_packages] WHERE [is_removed] = 1") == 1); + REQUIRE(GetStrings(delta, "SELECT [id] FROM [delta_packages] WHERE [is_removed] = 1") == std::set{ p2.Id }); + + // A removal carries no data beyond identity, so the rest of the row stays null. + REQUIRE(GetScalar(delta, + "SELECT COUNT(*) FROM [delta_packages] WHERE [is_removed] = 1 AND [name] IS NULL AND [latest_version] IS NULL AND [hash] IS NULL") == 1); +} + +// C4. Only the identifier and name have ever been asserted, so a column dropped from the copy +// would go unnoticed. +TEST_CASE("SQLiteIndex_Delta_ChangedPackageCopiesEveryColumn", "[sqliteindex][V2_1][delta]") +{ + DeltaTestContext context{ { MakePackage("Publisher1.Id", "Package 1") } }; + ManifestAndPath added; + CreateFakeManifestAndPath(added, "Publisher2", "3.4.5", "1.2"sv, "6.7"sv); + + { + SQLiteIndex index = context.OpenWorkingForChanges(); + index.AddManifest(added.Manifest, added.Path); + } + + context.GenerateDelta(); + + Connection delta = context.OpenDeltaConnection(); + Connection source = Connection::Create(context.WorkingFile.GetPath().u8string(), Connection::OpenDisposition::ReadOnly); + + for (std::string_view column : { "id"sv, "name"sv, "moniker"sv, "latest_version"sv, "arp_min_version"sv, "arp_max_version"sv }) + { + INFO(column); + + auto fromDelta = GetStrings(delta, "SELECT [" + std::string{ column } + "] FROM [delta_packages] WHERE [id] = '" + added.Manifest.Id + "'"); + auto fromSource = GetStrings(source, "SELECT [" + std::string{ column } + "] FROM [packages] WHERE [id] = '" + added.Manifest.Id + "'"); + + REQUIRE(fromDelta == fromSource); + REQUIRE(fromDelta.size() == 1); + REQUIRE(!fromDelta.begin()->empty()); + } + + // The hash is a blob, so compare it as one rather than through the string accessor. + Statement deltaHash = Statement::Create(delta, "SELECT [hash] FROM [delta_packages] WHERE [id] = '" + added.Manifest.Id + "'"); + REQUIRE(deltaHash.Step()); + Statement sourceHash = Statement::Create(source, "SELECT [hash] FROM [packages] WHERE [id] = '" + added.Manifest.Id + "'"); + REQUIRE(sourceHash.Step()); + + auto hashValue = deltaHash.GetColumn(0); + REQUIRE(!hashValue.empty()); + REQUIRE(hashValue == sourceHash.GetColumn(0)); +} + +TEST_CASE("SQLiteIndex_Delta_MultipleChangesAndRemovals", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + auto p3 = MakePackage("Publisher3.Id", "Package 3"); + auto p4 = MakePackage("Publisher4.Id", "Package 4"); + auto p3Updated = MakePackage(p3.Id, "Renamed 3"); + auto p5 = MakePackage("Publisher5.Id", "Package 5"); + auto p6 = MakePackage("Publisher6.Id", "Package 6"); + + DeltaTestContext context{ { p1, p2, p3, p4 } }; + + context.Remove(p1); + context.Remove(p2); + context.Update(p3Updated); + context.Add(p5); + context.Add(p6); + + context.GenerateDelta(); + + Connection delta = context.OpenDeltaConnection(); + + REQUIRE(GetStrings(delta, "SELECT [id] FROM [delta_packages] WHERE [is_removed] = 1") == + std::set{ p1.Id, p2.Id }); + REQUIRE(GetStrings(delta, "SELECT [id] FROM [delta_packages] WHERE [is_removed] = 0") == + std::set{ p3Updated.Id, p5.Id, p6.Id }); + + SQLiteIndex combined = context.OpenCombined(); + REQUIRE(GetSearchedIds(combined) == std::set{ p3Updated.Id, p4.Id, p5.Id, p6.Id }); +} + +// C6. A package that came and went within the window was never in the baseline, so there is +// nothing to suppress and the delta must not claim to remove it. +TEST_CASE("SQLiteIndex_Delta_PackageAddedAndRemovedWithinWindow", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto transient = MakePackage("Transient.Id", "Transient"); + + DeltaTestContext context{ { p1 } }; + + context.Add(transient); + context.Remove(transient); + + REQUIRE_NOTHROW(context.GenerateDelta()); + + Connection delta = context.OpenDeltaConnection(); + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_packages] WHERE [id] LIKE '" + transient.Id + "'") == 0); + + SQLiteIndex combined = context.OpenCombined(); + REQUIRE(GetSearchedIds(combined) == std::set{ p1.Id }); +} + +// C8. The identifier lookup used to be a LIKE, which treats these characters as wildcards. A +// package whose identifier contains them would have matched the wrong row. +TEST_CASE("SQLiteIndex_Delta_IdentifierWithLikeWildcards", "[sqliteindex][V2_1][delta]") +{ + auto wild = MakePackage("Publisher_A.Id", "Wildcard Underscore"); + auto decoy = MakePackage("PublisherXA.Id", "Decoy"); + auto percent = MakePackage("Pub%cent.Id", "Wildcard Percent"); + + DeltaTestContext context{ { wild, decoy, percent } }; + + context.Remove(wild); + context.GenerateDelta(); + + Connection delta = context.OpenDeltaConnection(); + + // Only the package actually removed; a LIKE would also have matched the decoy. + REQUIRE(GetStrings(delta, "SELECT [id] FROM [delta_packages] WHERE [is_removed] = 1") == std::set{ wild.Id }); + + SQLiteIndex combined = context.OpenCombined(); + REQUIRE(GetSearchedIds(combined) == std::set{ decoy.Id, percent.Id }); +} + +TEST_CASE("SQLiteIndex_Delta_NoChanges_EmptyDelta", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + + DeltaTestContext context{ { p1 } }; + + // Reset the base time without making any change, so nothing is reported. + context.OpenWorkingForChanges(); + context.GenerateDelta(); + + Connection delta = context.OpenDeltaConnection(); + + // The delta is a database in its own right, carrying the metadata that any index has. Without + // it the delta could not be opened, since opening reads the schema version to pick an interface. + REQUIRE(MetadataTable::GetNamedValue(delta, s_MetadataValueName_MajorVersion) == 2); + REQUIRE(MetadataTable::GetNamedValue(delta, s_MetadataValueName_MinorVersion) == 1); + REQUIRE(!MetadataTable::TryGetNamedValue(delta, s_MetadataValueName_DatabaseIdentifier).value_or(std::string{}).empty()); + REQUIRE(MetadataTable::TryGetNamedValue(delta, s_MetadataValueName_LastWriteTime).has_value()); + + REQUIRE(GetRowCount(delta, "delta_packages") == 0); + + for (const auto& table : Delta::SystemReferenceTables()) + { + INFO(table.TableName); + REQUIRE(GetRowCount(delta, Delta::GetTableName(table.TableName)) == 0); + } + + for (const auto& table : Delta::OneToManyTables()) + { + INFO(table.TableName); + REQUIRE(GetRowCount(delta, Delta::GetTableName(table.TableName)) == 0); + REQUIRE(GetRowCount(delta, Delta::GetMapTableName(table.TableName)) == 0); + } + + // J4. The delta ships as plain tables, just as a prepared 2.0 index does. Its indexes serve + // only generation, and the merged views probe nothing but primary keys. + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [sqlite_master] WHERE [type] = 'index'") == 0); + + // An empty delta still has to merge cleanly. + SQLiteIndex combined = context.OpenCombined(); + REQUIRE(GetSearchedIds(combined) == std::set{ p1.Id }); +} + +// L1. The delta is built beside its destination and moved into place, so a successful generation +// has to leave the file at the path that was asked for and nothing beside it. +TEST_CASE("SQLiteIndex_Delta_GenerationLeavesOnlyTheDelta", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + + DeltaTestContext context{ { p1 } }; + context.Add(p2); + context.GenerateDelta(); + + REQUIRE(std::filesystem::exists(context.DeltaFile.GetPath())); + REQUIRE(GetDeltaOutputSiblings(context.DeltaFile.GetPath()).empty()); + + // The moved file is a working database rather than something that merely exists. + SQLiteIndex combined = context.OpenCombined(); + REQUIRE(GetSearchedIds(combined) == std::set{ p1.Id, p2.Id }); +} + +// L2. An existing output is refused rather than replaced. The delta is created, so without this +// the existing file would be opened and generation would fail later complaining about its tables, +// after having written into a file it does not own. +TEST_CASE("SQLiteIndex_Delta_ExistingOutputIsRefused", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + + DeltaTestContext context{ { p1 } }; + context.Add(p2); + + std::string existingContent = "not a delta"; + + { + std::ofstream stream{ context.DeltaFile.GetPath(), std::ios::binary }; + stream << existingContent; + } + + REQUIRE_THROWS(context.GenerateDelta()); + + std::ifstream stream{ context.DeltaFile.GetPath(), std::ios::binary }; + std::string content{ std::istreambuf_iterator{ stream }, std::istreambuf_iterator{} }; + REQUIRE(content == existingContent); + + REQUIRE(GetDeltaOutputSiblings(context.DeltaFile.GetPath()).empty()); +} + +// L3. A failure after the delta database exists leaves nothing behind: neither a partial file at +// the destination, which a consumer would take for a usable delta, nor the temporary it was being +// built in. +TEST_CASE("SQLiteIndex_Delta_FailedGenerationLeavesNothing", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + + DeltaTestContext context{ { p1 } }; + context.Add(p2); + + // Generation reads the baseline's value tables to number the values it adds. Removing one fails + // the generation after the delta database has been created and written to, which is the window + // that matters -- a failure before it could not have left anything behind in the first place. + { + Connection baseline = Connection::Create(context.BaselineFile.GetPath().u8string(), Connection::OpenDisposition::ReadWrite); + Statement::Create(baseline, "DROP TABLE [" + std::string{ Delta::OneToManyTables().front().TableName } + "]").Execute(); + } + + REQUIRE_THROWS(context.GenerateDelta()); + + REQUIRE(!std::filesystem::exists(context.DeltaFile.GetPath())); + REQUIRE(GetDeltaOutputSiblings(context.DeltaFile.GetPath()).empty()); +} + +// --------------------------------------------------------------------------------------------- +// Group D - generation of the system reference tables +// +// These decide whether ARP correlation still works after an update, and nothing covered them. +// --------------------------------------------------------------------------------------------- + +TEST_CASE("SQLiteIndex_Delta_SystemReference_AddAndRemove", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1", { "t1" }, { "c1" }, { "Family1_8wekyb3d8bbwe" }, { "PC-KEEP", "PC-DROP" }); + auto p2 = MakePackage("Publisher2.Id", "Package 2", { "t1" }, { "c1" }, { "Family2_8wekyb3d8bbwe" }, { "PC-OTHER" }); + auto p1Updated = MakePackage(p1.Id, p1.Name, { "t1" }, { "c1" }, { "Family3_8wekyb3d8bbwe" }, { "PC-KEEP", "PC-NEW" }); + + DeltaTestContext context{ { p1, p2 } }; + + // Trade one product code for another while keeping a third, and swap the family name. + context.Update(p1Updated); + + context.GenerateDelta(); + + { + Connection delta = context.OpenDeltaConnection(); + + // D6. Only the difference is recorded. Writing the whole current set would destroy the size + // benefit that justifies the feature. + auto productCodeTable = Delta::GetTableName("productcodes2"); + REQUIRE(GetStrings(delta, "SELECT [productcode] FROM [" + productCodeTable + "] WHERE [is_removed] = 0") == + std::set{ "pc-new" }); + REQUIRE(GetStrings(delta, "SELECT [productcode] FROM [" + productCodeTable + "] WHERE [is_removed] = 1") == + std::set{ "pc-drop" }); + + // The kept value is not mentioned at all. + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [" + productCodeTable + "] WHERE [productcode] = 'pc-keep'") == 0); + } + + Connection merged = context.OpenMergedConnection(); + + // D3. Suppression is per row: the kept code survives even though the package changed. + REQUIRE(GetSystemReferenceValues(merged, "productcodes2", "productcode", p1Updated.Id) == + std::set{ "pc-keep", "pc-new" }); + REQUIRE(GetSystemReferenceValues(merged, "pfns2", "pfn", p1Updated.Id) == + std::set{ "family3_8wekyb3d8bbwe" }); + + // The untouched package keeps everything. + REQUIRE(GetSystemReferenceValues(merged, "productcodes2", "productcode", p2.Id) == + std::set{ "pc-other" }); +} + +// D7. The user visible consequence: correlation by product code has to find the updated package. +TEST_CASE("SQLiteIndex_Delta_SystemReference_CorrelationThroughCombined", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1", { "t1" }, { "c1" }, { "Family1_8wekyb3d8bbwe" }, { "PC-KEEP", "PC-DROP" }); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + auto p1Updated = MakePackage(p1.Id, p1.Name, { "t1" }, { "c1" }, p1.PackageFamilyNames, { "PC-KEEP", "PC-NEW" }); + + DeltaTestContext context{ { p1, p2 } }; + + context.Update(p1Updated); + context.GenerateDelta(); + + SQLiteIndex combined = context.OpenCombined(); + + for (const auto& productCode : p1Updated.ProductCodes) + { + INFO(productCode); + + SearchRequest request; + request.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::ProductCode, MatchType::Exact, std::string{ productCode })); + + REQUIRE(GetSearchedIds(combined, request) == std::set{ p1Updated.Id }); + } + + // The dropped code must no longer correlate to anything. + SearchRequest dropped; + dropped.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::ProductCode, MatchType::Exact, p1.ProductCodes[1])); + REQUIRE(combined.Search(dropped).Matches.empty()); + + // And the family name still resolves through the merged view. + SearchRequest family; + family.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::PackageFamilyName, MatchType::Exact, p1Updated.PackageFamilyNames[0])); + REQUIRE(GetSearchedIds(combined, family) == std::set{ p1Updated.Id }); +} + +// D5. The normalized name and publisher tables are populated as a side effect of the package name, +// so a rename has to move them. Nothing asserted on them before. +TEST_CASE("SQLiteIndex_Delta_SystemReference_NormalizedNameFollowsRename", "[sqliteindex][V2_1][delta]") +{ + auto original = MakePackage("Publisher1.Id", "Original Name"); + auto renamed = MakePackage(original.Id, "Replacement Name"); + + DeltaTestContext context{ { original } }; + + context.Update(renamed); + context.GenerateDelta(); + + Connection merged = context.OpenMergedConnection(); + + auto names = GetSystemReferenceValues(merged, "norm_names2", "norm_name", renamed.Id); + REQUIRE(names.size() == 1); + + // The old name must be gone rather than merely joined by the new one. + for (const auto& name : names) + { + REQUIRE(name.find("original") == std::string::npos); + } + + // Rather than predicting what normalization produces, require that the merged form holds + // exactly what a full index built from the same data holds. + Connection reference = Connection::Create(context.WorkingFile.GetPath().u8string(), Connection::OpenDisposition::ReadOnly); + + REQUIRE(GetSystemReferenceValues(merged, "norm_names2", "norm_name", renamed.Id) == + GetSystemReferenceValues(reference, "norm_names2", "norm_name", renamed.Id)); + + REQUIRE(GetSystemReferenceValues(merged, "norm_publishers2", "norm_publisher", renamed.Id) == + GetSystemReferenceValues(reference, "norm_publishers2", "norm_publisher", renamed.Id)); +} + +TEST_CASE("SQLiteIndex_Delta_SystemReference_RemovedPackageValuesAreInvisible", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1", { "t1" }, { "c1" }, { "Family1_8wekyb3d8bbwe" }, { "PC-1" }); + auto p2 = MakePackage("Publisher2.Id", "Package 2", { "t1" }, { "c1" }, { "Family2_8wekyb3d8bbwe" }, { "PC-2" }); + + DeltaTestContext context{ { p1, p2 } }; + + context.Remove(p2); + context.GenerateDelta(); + + { + Connection delta = context.OpenDeltaConnection(); + + // F3. A removal is recorded once, against the package. There are no per value tombstones, + // so the package row is the only thing that can suppress these. + REQUIRE(GetRowCount(delta, Delta::GetTableName("productcodes2")) == 0); + REQUIRE(GetRowCount(delta, Delta::GetTableName("pfns2")) == 0); + } + + Connection merged = context.OpenMergedConnection(); + + REQUIRE(GetSystemReferenceValues(merged, "productcodes2", "productcode", p2.Id).empty()); + REQUIRE(GetSystemReferenceValues(merged, "pfns2", "pfn", p2.Id).empty()); + REQUIRE(GetSystemReferenceValues(merged, "productcodes2", "productcode", p1.Id) == std::set{ "pc-1" }); + + SQLiteIndex combined = context.OpenCombined(); + + SearchRequest request; + request.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::ProductCode, MatchType::Exact, p2.ProductCodes[0])); + REQUIRE(combined.Search(request).Matches.empty()); +} + +// --------------------------------------------------------------------------------------------- +// Group E - generation of the one to many tables +// --------------------------------------------------------------------------------------------- + +// E1. A package that trades one tag for another while keeping a third. Suppressing the baseline +// at the level of the package would lose the kept tag, because the delta never mentions it. +TEST_CASE("SQLiteIndex_Delta_OneToMany_AssociationsAreSuppressedPerRow", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1", { "keep", "drop", "also_keep" }, { "cmd_keep", "cmd_drop" }); + auto p2 = MakePackage("Publisher2.Id", "Package 2", { "other" }, { "other_cmd" }); + auto p1Updated = MakePackage(p1.Id, p1.Name, { "keep", "added", "also_keep" }, { "cmd_keep", "cmd_added" }); + + DeltaTestContext context{ { p1, p2 } }; + + context.Update(p1Updated); + context.Remove(p2); + + context.GenerateDelta(); + + Connection merged = context.OpenMergedConnection(); + + REQUIRE(GetOneToManyValues(merged, "tags2", "tag", p1Updated.Id) == ToStringSet(p1Updated.Tags)); + + // E2. The same defect on the other one to many table. + REQUIRE(GetOneToManyValues(merged, "commands2", "command", p1Updated.Id) == ToStringSet(p1Updated.Commands)); + + // F3 again, for the map tables. + REQUIRE(GetOneToManyValues(merged, "tags2", "tag", p2.Id).empty()); + REQUIRE(GetOneToManyValues(merged, "commands2", "command", p2.Id).empty()); +} + +// E3/E4. A value the baseline already knows is referenced at its existing rowid rather than copied, +// and a genuinely new value is numbered above everything the baseline holds. +TEST_CASE("SQLiteIndex_Delta_OneToMany_ValueRowIdAllocation", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1", { "shared", "only1" }); + auto p2 = MakePackage("Publisher2.Id", "Package 2", { "other" }); + auto p2Updated = MakePackage(p2.Id, p2.Name, { "other", "shared", "brand_new" }); + + DeltaTestContext context{ { p1, p2 } }; + + // Publisher2 gains a tag the baseline already has, plus one it does not. + context.Update(p2Updated); + context.GenerateDelta(); + + rowid_t baselineMaxTagRowId = 0; + rowid_t sharedRowIdInBaseline = 0; + + { + Connection baseline = Connection::Create(context.BaselineFile.GetPath().u8string(), Connection::OpenDisposition::ReadOnly); + baselineMaxTagRowId = static_cast(GetScalar(baseline, "SELECT MAX([rowid]) FROM [tags2]")); + sharedRowIdInBaseline = static_cast(GetScalar(baseline, "SELECT [rowid] FROM [tags2] WHERE [tag] = '" + p1.Tags[0] + "'")); + } + + Connection delta = context.OpenDeltaConnection(); + + // E4. The shared value is not copied into the delta; the map points at the baseline rowid. + REQUIRE(GetStrings(delta, "SELECT [tag] FROM [delta_tags2]") == std::set{ p2Updated.Tags[2] }); + + auto mapTable = Delta::GetMapTableName("tags2"); + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [" + mapTable + "] WHERE [tag] = " + std::to_string(sharedRowIdInBaseline) + " AND [is_removed] = 0") == 1); + + // E3. The new value is numbered above the baseline, so the union cannot collide. + REQUIRE(GetScalar(delta, "SELECT [rowid] FROM [delta_tags2] WHERE [tag] = '" + p2Updated.Tags[2] + "'") > baselineMaxTagRowId); + + // F4. Both sides resolve through the unioned value view. + Connection merged = context.OpenMergedConnection(); + REQUIRE(GetOneToManyValues(merged, "tags2", "tag", p2Updated.Id) == ToStringSet(p2Updated.Tags)); +} + +// E5. One new value shared by two packages is stored once and mapped twice. +TEST_CASE("SQLiteIndex_Delta_OneToMany_NewValueSharedByPackages", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1", { "t1" }); + auto p2 = MakePackage("Publisher2.Id", "Package 2", { "t2" }); + auto p1Updated = MakePackage(p1.Id, p1.Name, { "t1", "common_tag" }); + auto p2Updated = MakePackage(p2.Id, p2.Name, { "t2", "common_tag" }); + + DeltaTestContext context{ { p1, p2 } }; + + context.Update(p1Updated); + context.Update(p2Updated); + + context.GenerateDelta(); + + Connection delta = context.OpenDeltaConnection(); + + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [delta_tags2] WHERE [tag] = '" + p1Updated.Tags[1] + "'") == 1); + + rowid_t valueRowId = static_cast(GetScalar(delta, "SELECT [rowid] FROM [delta_tags2] WHERE [tag] = '" + p1Updated.Tags[1] + "'")); + auto mapTable = Delta::GetMapTableName("tags2"); + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [" + mapTable + "] WHERE [tag] = " + std::to_string(valueRowId) + " AND [is_removed] = 0") == 2); + + Connection merged = context.OpenMergedConnection(); + REQUIRE(GetOneToManyValues(merged, "tags2", "tag", p1Updated.Id) == ToStringSet(p1Updated.Tags)); + REQUIRE(GetOneToManyValues(merged, "tags2", "tag", p2Updated.Id) == ToStringSet(p2Updated.Tags)); +} + +// E6. Every value removed from a package. An empty current set must not read as "nothing changed". +TEST_CASE("SQLiteIndex_Delta_OneToMany_AllValuesRemoved", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1", { "t1", "t2" }, { "c1" }); + auto p2 = MakePackage("Publisher2.Id", "Package 2", { "t3" }, { "c2" }); + auto p1Updated = MakePackage(p1.Id, p1.Name, {}, {}); + + DeltaTestContext context{ { p1, p2 } }; + + context.Update(p1Updated); + context.GenerateDelta(); + + { + Connection delta = context.OpenDeltaConnection(); + auto mapTable = Delta::GetMapTableName("tags2"); + REQUIRE(GetScalar(delta, "SELECT COUNT(*) FROM [" + mapTable + "] WHERE [is_removed] = 1") == 2); + } + + Connection merged = context.OpenMergedConnection(); + REQUIRE(GetOneToManyValues(merged, "tags2", "tag", p1Updated.Id).empty()); + REQUIRE(GetOneToManyValues(merged, "commands2", "command", p1Updated.Id).empty()); + + // The other package is untouched. + REQUIRE(GetOneToManyValues(merged, "tags2", "tag", p2.Id) == ToStringSet(p2.Tags)); +} + +// E7. A baseline with no values at all, so the maximum rowid query has nothing to report. +TEST_CASE("SQLiteIndex_Delta_OneToMany_EmptyBaselineValueTable", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1", {}, {}); + auto p1Updated = MakePackage(p1.Id, p1.Name, { "first" }, { "first_cmd" }); + + DeltaTestContext context{ { p1 } }; + + { + Connection baseline = Connection::Create(context.BaselineFile.GetPath().u8string(), Connection::OpenDisposition::ReadOnly); + REQUIRE(GetRowCount(baseline, "tags2") == 0); + } + + context.Update(p1Updated); + REQUIRE_NOTHROW(context.GenerateDelta()); + + Connection delta = context.OpenDeltaConnection(); + + // Numbering has to start somewhere valid; rowid 0 is not. + REQUIRE(GetScalar(delta, "SELECT [rowid] FROM [delta_tags2] WHERE [tag] = '" + p1Updated.Tags[0] + "'") > 0); + + Connection merged = context.OpenMergedConnection(); + REQUIRE(GetOneToManyValues(merged, "tags2", "tag", p1Updated.Id) == ToStringSet(p1Updated.Tags)); +} + +// --------------------------------------------------------------------------------------------- +// Group F - merged views +// --------------------------------------------------------------------------------------------- + +// F5. In delta read mode the packages table is a view, so the check that decides whether an index +// has been prepared cannot rely on the table existing. Without this the 1.7 internal interface +// would be built over the views. +TEST_CASE("SQLiteIndex_Delta_CombinedIndexIsInPreparedState", "[sqliteindex][V2_1][delta]") +{ + DeltaTestContext context{ { MakePackage("Publisher1.Id", "Package 1") } }; + + context.Add(MakePackage("Publisher2.Id", "Package 2")); + context.GenerateDelta(); + + SQLiteIndex combined = context.OpenCombined(); + + // A prepared index reports no manifest paths; reaching the 1.7 interface would change that, + // and would more likely fail outright looking for tables that are not there. + auto results = combined.Search({}); + REQUIRE(results.Matches.size() == 2); + + REQUIRE(combined.GetPropertyByPrimaryId(results.Matches[0].first, PackageVersionProperty::Id).has_value()); +} + +// --------------------------------------------------------------------------------------------- +// Group G - combined open, affinity, and the negative paths +// --------------------------------------------------------------------------------------------- + +TEST_CASE("SQLiteIndex_Delta_OpenWithBaseline_Search", "[sqliteindex][V2_1][delta]") +{ + // Immutable is the disposition the shipped path uses, and it is the one that reaches SQLite as + // a URI rather than a plain path, so both files have to be named that way for the attach to + // resolve at all. + auto disposition = GENERATE(SQLiteStorageBase::OpenDisposition::Read, SQLiteStorageBase::OpenDisposition::Immutable); + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + + DeltaTestContext context{ { p1 } }; + + context.Add(p2); + context.GenerateDelta(); + + SQLiteIndex combined = context.OpenCombined(disposition); + REQUIRE(GetSearchedIds(combined) == std::set{ p1.Id, p2.Id }); +} + +TEST_CASE("SQLiteIndex_Delta_OpenWithBaseline_RemovedPackageExcluded", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + + DeltaTestContext context{ { p1, p2 } }; + + context.Remove(p2); + context.GenerateDelta(); + + SQLiteIndex combined = context.OpenCombined(); + REQUIRE(GetSearchedIds(combined) == std::set{ p1.Id }); +} + +TEST_CASE("SQLiteIndex_Delta_UnmarkedBaselineRejected", "[sqliteindex][V2_1][delta]") +{ + DeltaTestContext context; + context.CreateWorking({ MakePackage("Publisher1.Id", "Package 1") }); + context.CaptureBaseline(false); + + context.Add(MakePackage("Publisher2.Id", "Package 2")); + + // Without a designation the baseline has no identity, so nothing could tie the delta to it. + REQUIRE_THROWS_HR(context.GenerateDelta(), APPINSTALLER_CLI_ERROR_INDEX_INTEGRITY_COMPROMISED); +} + +TEST_CASE("SQLiteIndex_Delta_MismatchedBaselineRejected", "[sqliteindex][V2_1][delta]") +{ + DeltaTestContext context{ { MakePackage("Publisher1.Id", "Package 1") } }; + + // A second baseline with byte identical contents. Designating each gives it its own identity, + // which is the point: a delta is tied to the baseline it was computed from, not to data that + // happens to look like it. + TempFile otherBaselineFile{ "delta_baseline_other"s, ".db"s }; + std::filesystem::copy_file(context.WorkingFile.GetPath(), otherBaselineFile.GetPath(), std::filesystem::copy_options::overwrite_existing); + + { + SQLiteIndex other = SQLiteIndex::Open(otherBaselineFile.GetPath().u8string(), SQLiteStorageBase::OpenDisposition::ReadWrite); + other.PrepareForPackaging(); + other.MarkAsBaseline(); + } + + context.Add(MakePackage("Publisher2.Id", "Package 2")); + context.GenerateDelta(); + + REQUIRE_NOTHROW(context.OpenCombined()); + + REQUIRE_THROWS_HR( + SQLiteIndex::OpenWithBaseline(context.DeltaFile.GetPath().u8string(), otherBaselineFile.GetPath().u8string()), + APPINSTALLER_CLI_ERROR_INDEX_INTEGRITY_COMPROMISED); +} + +// G9. Designating an index a second time mints a new identity, which invalidates any delta made +// against the first. Designation is deliberately not idempotent. +TEST_CASE("SQLiteIndex_Delta_ReMarkingBaselineInvalidatesDelta", "[sqliteindex][V2_1][delta]") +{ + DeltaTestContext context{ { MakePackage("Publisher1.Id", "Package 1") } }; + + context.Add(MakePackage("Publisher2.Id", "Package 2")); + context.GenerateDelta(); + + REQUIRE_NOTHROW(context.OpenCombined()); + + { + SQLiteIndex baseline = SQLiteIndex::Open(context.BaselineFile.GetPath().u8string(), SQLiteStorageBase::OpenDisposition::ReadWrite); + baseline.MarkAsBaseline(); + } + + REQUIRE_THROWS_HR(context.OpenCombined(), APPINSTALLER_CLI_ERROR_INDEX_INTEGRITY_COMPROMISED); +} + +// G5. The combined form is a set of views over a union, so there is nothing to write back to. +TEST_CASE("SQLiteIndex_Delta_OpenWithBaseline_ReadWriteRejected", "[sqliteindex][V2_1][delta]") +{ + DeltaTestContext context{ { MakePackage("Publisher1.Id", "Package 1") } }; + + context.Add(MakePackage("Publisher2.Id", "Package 2")); + context.GenerateDelta(); + + REQUIRE_THROWS_HR(context.OpenCombined(SQLiteStorageBase::OpenDisposition::ReadWrite), E_INVALIDARG); +} + +// G6. A baseline that cannot be read at all has to fail rather than silently produce a delta only +// view of the world. +TEST_CASE("SQLiteIndex_Delta_OpenWithBaseline_MissingBaseline", "[sqliteindex][V2_1][delta]") +{ + DeltaTestContext context{ { MakePackage("Publisher1.Id", "Package 1") } }; + + context.Add(MakePackage("Publisher2.Id", "Package 2")); + context.GenerateDelta(); + + TempFile missing{ "delta_missing_baseline"s, ".db"s }; + std::filesystem::remove(missing.GetPath()); + + REQUIRE_THROWS(SQLiteIndex::OpenWithBaseline(context.DeltaFile.GetPath().u8string(), missing.GetPath().u8string())); +} + +// G7/G8. The delta entry points are 2.1 only, and the base implementations say so rather than +// doing something undefined. +TEST_CASE("SQLiteIndex_Delta_NotSupportedBefore_2_1", "[sqliteindex][V2_0][delta]") +{ + TempFile indexFile{ "delta_unsupported"s, ".db"s }; + + ManifestAndPath m1; + CreateFakeManifestAndPath(m1, "Publisher1", "1.0"); + + SQLiteIndex index = SQLiteIndex::CreateNew(indexFile, SQLiteVersion{ 2, 0 }); + index.AddManifest(m1.Manifest, m1.Path); + index.PrepareForPackaging(); + + REQUIRE_THROWS_HR(index.MarkAsBaseline(), HRESULT_FROM_WIN32(ERROR_NOT_SUPPORTED)); + + // G7. A 2.0 file in the delta position is nonsense, and the interface is what says so. The + // baseline is never touched, so it does not need to exist. + TempFile baselineFile{ "delta_unsupported_baseline"s, ".db"s }; + + REQUIRE_THROWS_HR( + SQLiteIndex::OpenWithBaseline(indexFile.GetPath().u8string(), baselineFile.GetPath().u8string()), + HRESULT_FROM_WIN32(ERROR_NOT_SUPPORTED)); +} + +// M1. A baseline is what a delta is merged with, and the merged views are defined over the 2.x +// tables. An index that has not been prepared still holds the 1.7 tables, so designating one would +// mint an identity for something no delta could ever be built from or attached to. +TEST_CASE("SQLiteIndex_Delta_MarkAsBaselineRequiresPreparedIndex", "[sqliteindex][V2_1][delta]") +{ + TempFile indexFile{ "delta_unprepared"s, ".db"s }; + + ManifestAndPath m1; + CreateFakeManifestAndPath(m1, "Publisher1", "1.0"); + + SQLiteIndex index = SQLiteIndex::CreateNew(indexFile, s_DeltaVersion); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + index.AddManifest(m1.Manifest, m1.Path); + + REQUIRE_THROWS_HR(index.MarkAsBaseline(), E_NOT_VALID_STATE); + + // Nothing was recorded, so the refusal is complete rather than partial. + { + Connection connection = Connection::Create(indexFile.GetPath().u8string(), Connection::OpenDisposition::ReadOnly); + REQUIRE(!MetadataTable::TryGetNamedValue(connection, Schema::V2_1::s_MetadataValueName_BaselineIdentifier).has_value()); + } + + // Preparing it is the only thing that was missing. + index.PrepareForPackaging(); + REQUIRE_NOTHROW(index.MarkAsBaseline()); +} + +// M2. A delta describes change rather than holding a whole index, so it cannot stand as the +// baseline for another one. Opened on its own it is a perfectly valid 2.1 database that would +// otherwise be designated without complaint. A prepared delta has no packages table, so the +// delta check has to run before the prepared check for this to be refused for the right reason. +TEST_CASE("SQLiteIndex_Delta_MarkAsBaselineRejectsDelta", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + + DeltaTestContext context{ { p1 } }; + context.Add(p2); + context.GenerateDelta(); + + { + SQLiteIndex delta = SQLiteIndex::Open(context.DeltaFile.GetPath().u8string(), SQLiteStorageBase::OpenDisposition::ReadWrite); + REQUIRE_THROWS_HR(delta.MarkAsBaseline(), E_NOT_VALID_STATE); + } + + // The combined form is the same delta with its baseline attached, and its tables are views over + // a union rather than an index of its own. + SQLiteIndex combined = context.OpenCombined(); + REQUIRE_THROWS_HR(combined.MarkAsBaseline(), E_NOT_VALID_STATE); + + // The delta was left alone, so the baseline it names is still the only designation in play. + { + Connection connection = context.OpenDeltaConnection(); + REQUIRE(!MetadataTable::TryGetNamedValue(connection, Schema::V2_1::s_MetadataValueName_BaselineIdentifier).has_value()); + } +} + +// --------------------------------------------------------------------------------------------- +// Group I - consistency +// --------------------------------------------------------------------------------------------- + +// I2/I3. A package recorded as removed while still present in the index is a real inconsistency, +// but a package that was removed and re-added is not: it legitimately has both a tombstone and a +// live row. Confusing the two turns a silent bug into a loud but wrong integrity failure. +TEST_CASE("SQLiteIndex_Delta_CheckConsistency_ReAddedPackageIsNotCorruption", "[sqliteindex][V2_1][update_tracking]") +{ + TempFile indexFile{ "update_tracking"s, ".db"s }; + + ManifestAndPath m1; + CreateFakeManifestAndPath(m1, "Publisher1", "1.0"); + ManifestAndPath m2; + CreateFakeManifestAndPath(m2, "Publisher2", "1.0"); + ManifestAndPath m3; + CreateFakeManifestAndPath(m3, "Publisher3", "1.0"); + + SQLiteIndex index = SQLiteIndex::CreateNew(indexFile, s_DeltaVersion); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + index.AddManifest(m1.Manifest, m1.Path); + index.AddManifest(m2.Manifest, m2.Path); + index.AddManifest(m3.Manifest, m3.Path); + + // Removing the middle package and re-adding it leaves a tombstone beside a live row. + index.RemoveManifest(m2.Manifest, m2.Path); + index.AddManifest(m2.Manifest, m2.Path); + + REQUIRE(index.CheckConsistency(true)); + + Connection connection = Connection::Create(indexFile, Connection::OpenDisposition::ReadOnly); + REQUIRE(GetScalar(connection, "SELECT COUNT(*) FROM [update_tracking] WHERE [package] = '" + m2.Manifest.Id + "'") == 2); + + // The removal is still reported, since the old rowid genuinely was vacated. + auto removals = Tracking::GetRemovalsSince(connection, 0, Tracking::RemovalBehavior::Record); + REQUIRE(removals.size() == 1); + + // And it is not also reported as an update under that identity being gone. + auto updates = Tracking::GetUpdatesSince(connection, 0, Tracking::RemovalBehavior::Record); + REQUIRE(std::count_if(updates.begin(), updates.end(), [&](const auto& u) { return u.PackageIdentifier == m2.Manifest.Id; }) == 1); +} + +TEST_CASE("SQLiteIndex_Delta_CheckConsistency_OnCombinedIndex", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1", { "t1" }, { "c1" }, {}, { "PC-1" }); + auto p2 = MakePackage("Publisher2.Id", "Package 2", { "t2" }, { "c2" }, {}, { "PC-2" }); + auto p1Updated = MakePackage(p1.Id, p1.Name, { "t1", "t3" }, p1.Commands, p1.PackageFamilyNames, p1.ProductCodes); + + DeltaTestContext context{ { p1, p2 } }; + + context.Update(p1Updated); + context.Remove(p2); + context.GenerateDelta(); + + SQLiteIndex combined = context.OpenCombined(); + REQUIRE(combined.CheckConsistency(true)); +} + +// --------------------------------------------------------------------------------------------- +// Group H - equivalence +// +// Enumerating what could go wrong in a merge is unbounded; comparing against a full index built +// from the same data is not. The targeted cases above exist to localize what this detects. +// --------------------------------------------------------------------------------------------- + +namespace +{ + // Compares a combined index against a full index built from the same data. + void RequireEquivalent(const SQLiteIndex& combined, const SQLiteIndex& full) + { + // H1. A battery covering the fields that the merged views are responsible for. + std::vector requests; + + requests.emplace_back(); + + auto addFieldRequest = [&](PackageMatchField field, MatchType type, std::string value, std::string second = {}) + { + SearchRequest request; + if (second.empty()) + { + request.Inclusions.emplace_back(PackageMatchFilter(field, type, std::move(value))); + } + else + { + request.Inclusions.emplace_back(PackageMatchFilter(field, type, std::move(value), std::move(second))); + } + requests.emplace_back(std::move(request)); + }; + + for (MatchType type : { MatchType::Exact, MatchType::Substring, MatchType::StartsWith }) + { + addFieldRequest(PackageMatchField::Id, type, "Equivalence"); + addFieldRequest(PackageMatchField::Name, type, "Package"); + addFieldRequest(PackageMatchField::Moniker, type, "moniker"); + addFieldRequest(PackageMatchField::Tag, type, "shared"); + addFieldRequest(PackageMatchField::Tag, type, "changed"); + addFieldRequest(PackageMatchField::Command, type, "cmd"); + addFieldRequest(PackageMatchField::ProductCode, type, "PC"); + addFieldRequest(PackageMatchField::PackageFamilyName, type, "Family"); + } + + { + SearchRequest query; + query.Query = RequestMatch(MatchType::Substring, "Package"); + requests.emplace_back(std::move(query)); + } + + addFieldRequest(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, "Package Untouched", "Publisher"); + addFieldRequest(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, "Package Replacement", "Publisher"); + addFieldRequest(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, "Package Original", "Publisher"); + + size_t matchedRequests = 0; + + for (size_t i = 0; i < requests.size(); ++i) + { + INFO("request index " << i); + + // A field and match type combination that the index does not support is not a delta + // concern, but the two forms still have to agree about it. + std::optional> combinedIds; + std::optional> fullIds; + + try + { + combinedIds = GetSearchedIds(combined, requests[i]); + } + catch (...) {} + + try + { + fullIds = GetSearchedIds(full, requests[i]); + } + catch (...) {} + + REQUIRE(combinedIds.has_value() == fullIds.has_value()); + REQUIRE(combinedIds == fullIds); + + if (fullIds && !fullIds->empty()) + { + ++matchedRequests; + } + } + + // Agreement is only meaningful if the battery actually matched something. Without this the + // group passes when every request throws or returns nothing, which is exactly what a defect + // in the merged views could cause. + REQUIRE(matchedRequests != 0); + + // H3. The identifiers agreeing is not enough; the rowids that name them have to agree too, + // since that is what the merge is built on and what a caller carries around. + std::map combinedPrimaryIds; + for (const auto& match : combined.Search({}).Matches) + { + combinedPrimaryIds[combined.GetPropertyByPrimaryId(match.first, PackageVersionProperty::Id).value()] = match.first; + } + + std::map fullPrimaryIds; + for (const auto& match : full.Search({}).Matches) + { + fullPrimaryIds[full.GetPropertyByPrimaryId(match.first, PackageVersionProperty::Id).value()] = match.first; + } + + REQUIRE(combinedPrimaryIds.size() == fullPrimaryIds.size()); + + // H2 and H3 iterate this map, so an empty one would make both pass vacuously. + REQUIRE(!combinedPrimaryIds.empty()); + + for (const auto& [packageId, primaryId] : combinedPrimaryIds) + { + INFO(packageId); + + auto full_itr = fullPrimaryIds.find(packageId); + REQUIRE(full_itr != fullPrimaryIds.end()); + REQUIRE(primaryId == full_itr->second); + + // H2. Found is not the same as correct. + for (PackageVersionProperty property : { + PackageVersionProperty::Id, + PackageVersionProperty::Name, + PackageVersionProperty::Moniker, + PackageVersionProperty::Version }) + { + REQUIRE(combined.GetPropertyByPrimaryId(primaryId, property) == full.GetPropertyByPrimaryId(full_itr->second, property)); + } + + for (PackageVersionMultiProperty property : { + PackageVersionMultiProperty::Tag, + PackageVersionMultiProperty::Command, + PackageVersionMultiProperty::PackageFamilyName, + PackageVersionMultiProperty::ProductCode, + PackageVersionMultiProperty::UpgradeCode, + PackageVersionMultiProperty::Name, + PackageVersionMultiProperty::Publisher }) + { + auto combinedValues = combined.GetMultiPropertyByPrimaryId(primaryId, property); + auto fullValues = full.GetMultiPropertyByPrimaryId(full_itr->second, property); + + std::sort(combinedValues.begin(), combinedValues.end()); + std::sort(fullValues.begin(), fullValues.end()); + + REQUIRE(combinedValues == fullValues); + } + } + } +} + +TEST_CASE("SQLiteIndex_Delta_EquivalenceWithFullIndex", "[sqliteindex][V2_1][delta]") +{ + // The mutation set covers every kind of change the delta has to describe: a package added, one + // removed, one whose values change, one renamed, and one left entirely alone. + auto untouched = MakePackage("Equivalence.Untouched", "Package Untouched", { "shared", "keep" }, { "cmd_keep" }, { "Family0_8wekyb3d8bbwe" }, { "PC-UNTOUCHED" }); + auto removed = MakePackage("Equivalence.Removed", "Package Removed", { "shared", "gone" }, { "cmd_gone" }, { "Family1_8wekyb3d8bbwe" }, { "PC-REMOVED" }); + auto retagged = MakePackage("Equivalence.Retagged", "Package Retagged", { "shared", "changed" }, { "cmd_old" }, { "Family2_8wekyb3d8bbwe" }, { "PC-OLD", "PC-BOTH" }); + auto renamed = MakePackage("Equivalence.Renamed", "Package Original", { "shared" }, { "cmd_keep" }, { "Family3_8wekyb3d8bbwe" }, { "PC-RENAMED" }); + auto roundTrip = MakePackage("Equivalence.RoundTrip", "Package RoundTrip", { "shared" }, { "cmd_keep" }, {}, { "PC-ROUND" }); + auto retaggedUpdated = MakePackage(retagged.Id, retagged.Name, { "shared", "changed_new" }, { "cmd_new" }, retagged.PackageFamilyNames, { "PC-NEW", "PC-BOTH" }); + auto renamedUpdated = MakePackage(renamed.Id, "Package Replacement", renamed.Tags, renamed.Commands, renamed.PackageFamilyNames, renamed.ProductCodes); + auto added = MakePackage("Equivalence.Added", "Package Added", { "shared", "brand" }, { "cmd_added" }, { "Family4_8wekyb3d8bbwe" }, { "PC-ADDED" }); + + DeltaTestContext context{ { untouched, removed, retagged, renamed, roundTrip } }; + + context.Remove(removed); + context.Update(retaggedUpdated); + context.Update(renamedUpdated); + context.Add(added); + + // A removal and re-add in the same window, which is where rowid identity is hardest. + context.Remove(roundTrip); + context.Add(roundTrip); + + context.GenerateDelta(); + + SQLiteIndex combined = context.OpenCombined(); + SQLiteIndex full = context.OpenFullIndex(); + + REQUIRE(GetSearchedIds(full) == std::set{ + untouched.Id, retaggedUpdated.Id, renamedUpdated.Id, roundTrip.Id, added.Id }); + + RequireEquivalent(combined, full); +} + +// H5. The degenerate case, which a consumer must not have to special case. +TEST_CASE("SQLiteIndex_Delta_EquivalenceWithEmptyDelta", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Equivalence.One", "Package One", { "shared" }, { "cmd_keep" }, { "Family0_8wekyb3d8bbwe" }, { "PC-1" }); + auto p2 = MakePackage("Equivalence.Two", "Package Two", { "shared", "changed" }, { "cmd2" }, {}, { "PC-2" }); + + DeltaTestContext context{ { p1, p2 } }; + + context.OpenWorkingForChanges(); + context.GenerateDelta(); + + SQLiteIndex combined = context.OpenCombined(); + SQLiteIndex full = context.OpenFullIndex(); + + RequireEquivalent(combined, full); +} + + +// --------------------------------------------------------------------------------------------- +// Group K - the change sequence +// +// The window a delta describes is defined by a monotonic sequence rather than by the write time. +// These cases cover the boundary that a whole second time cannot express, and the one failure +// mode a sequence has that a time does not. +// --------------------------------------------------------------------------------------------- + +// K1. Every write takes a new sequence, and a package written twice keeps only the later one. +// A sequence that did not advance on update would leave the second change outside any window +// opened after the first. +TEST_CASE("SQLiteIndex_Delta_ChangeSequenceAdvancesOnEveryWrite", "[sqliteindex][V2_1][update_tracking]") +{ + TempFile indexFile{ "change_seq"s, ".db"s }; + + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + auto p1Updated = MakePackage(p1.Id, "Package 1 Renamed"); + + { + SQLiteIndex index = SQLiteIndex::CreateNew(indexFile, s_DeltaVersion); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + + Manifest m1 = CreateManifest(p1); + index.AddManifest(m1, p1.Path); + + Manifest m2 = CreateManifest(p2); + index.AddManifest(m2, p2.Path); + + Manifest m1Updated = CreateManifest(p1Updated); + REQUIRE(index.UpdateManifest(m1Updated, p1.Path)); + } + + Connection connection = Connection::Create(indexFile, Connection::OpenDisposition::ReadOnly); + + // Three writes, but only two rows: the update replaced the first package's sequence rather + // than adding a row. + REQUIRE(GetRowCount(connection, "update_tracking") == 2); + REQUIRE(GetScalar(connection, "SELECT MAX([change_seq]) FROM [update_tracking]") == 3); + + int64_t first = GetScalar(connection, "SELECT [change_seq] FROM [update_tracking] WHERE [package] = '" + p1Updated.Id + "'"); + int64_t second = GetScalar(connection, "SELECT [change_seq] FROM [update_tracking] WHERE [package] = '" + p2.Id + "'"); + + // The updated package is now the more recent of the two, having started as the older. + REQUIRE(first == 3); + REQUIRE(second == 2); +} + +// K2. The boundary case that the time based window could not express at all: a baseline captured +// in the same instant as the data it holds. Under whole second times compared inclusively, the +// entire baseline fell inside its own delta window. +TEST_CASE("SQLiteIndex_Delta_BaselineCapturedImmediatelyExcludesItsOwnData", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + + // No delay anywhere: creation, capture and generation all happen as fast as they can. + DeltaTestContext context{ { p1, p2 } }; + context.GenerateDelta(); + + { + Connection baseline = Connection::Create(context.BaselineFile.GetPath().u8string(), Connection::OpenDisposition::ReadOnly); + + // Both packages were written before the baseline was taken, so the baseline's own sequence + // must already account for them. + REQUIRE(MetadataTable::GetNamedValue(baseline, "deltaBaselineSequence") == 2); + } + + Connection delta = context.OpenDeltaConnection(); + REQUIRE(GetRowCount(delta, "delta_packages") == 0); + + SQLiteIndex combined = context.OpenCombined(); + REQUIRE(GetSearchedIds(combined) == std::set{ p1.Id, p2.Id }); +} + +// K3. A migrated table has no sequences, so every row backfills to the same value and the first +// one issued afterwards is above it. An index designated as a baseline at that moment records 0, +// and the exclusive window correctly reports nothing that preceded the migration. +TEST_CASE("SQLiteIndex_Delta_TrackingMigrationBackfillsChangeSequence", "[sqliteindex][V2_1][update_tracking]") +{ + TempFile indexFile{ "change_seq_migrate"s, ".db"s }; + + ManifestAndPath m1; + CreateFakeManifestAndPath(m1, "Publisher1", "1.0"); + ManifestAndPath m2; + CreateFakeManifestAndPath(m2, "Publisher2", "1.0"); + ManifestAndPath m3; + CreateFakeManifestAndPath(m3, "Publisher3", "1.0"); + + { + SQLiteIndex index = SQLiteIndex::CreateNew(indexFile, SQLiteVersion{ 2, 0 }); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + index.AddManifest(m1.Manifest, m1.Path); + index.AddManifest(m2.Manifest, m2.Path); + } + + { + SQLiteIndex index = SQLiteIndex::Open(indexFile, SQLiteStorageBase::OpenDisposition::ReadWrite); + REQUIRE(index.MigrateTo(s_DeltaVersion)); + REQUIRE(index.CheckConsistency(true)); + } + + { + Connection connection = Connection::Create(indexFile, Connection::OpenDisposition::ReadOnly); + REQUIRE(GetScalar(connection, "SELECT COUNT(*) FROM [update_tracking] WHERE [change_seq] = 0") == 2); + } + + // A write after the migration has to be distinguishable from everything that preceded it. + { + SQLiteIndex index = SQLiteIndex::Open(indexFile, SQLiteStorageBase::OpenDisposition::ReadWrite); + index.AddManifest(m3.Manifest, m3.Path); + } + + Connection connection = Connection::Create(indexFile, Connection::OpenDisposition::ReadOnly); + REQUIRE(GetScalar(connection, "SELECT [change_seq] FROM [update_tracking] WHERE [package] = '" + m3.Manifest.Id + "'") == 1); +} + +// K4. The one direction a sequence fails in that a time does not. If the working index is rebuilt, +// its counter restarts below the baseline's and the window is empty, which would produce a +// silently empty delta. Generation has to refuse instead. +TEST_CASE("SQLiteIndex_Delta_SequenceBelowBaselineIsRejected", "[sqliteindex][V2_1][delta]") +{ + auto p1 = MakePackage("Publisher1.Id", "Package 1"); + auto p2 = MakePackage("Publisher2.Id", "Package 2"); + auto p3 = MakePackage("Publisher3.Id", "Package 3"); + + DeltaTestContext context{ { p1, p2, p3 } }; + + { + Connection baseline = Connection::Create(context.BaselineFile.GetPath().u8string(), Connection::OpenDisposition::ReadOnly); + REQUIRE(MetadataTable::GetNamedValue(baseline, "deltaBaselineSequence") == 3); + } + + // A rebuilt working index, holding the same data but having recorded far fewer changes. + TempFile rebuiltFile{ "change_seq_rebuilt"s, ".db"s }; + TempFile rebuiltDeltaFile{ "change_seq_rebuilt_delta"s, ".db"s }; + + { + SQLiteIndex index = SQLiteIndex::CreateNew(rebuiltFile, s_DeltaVersion); + index.SetProperty(SQLiteIndex::Property::PackageUpdateTrackingBaseTime, "0"); + Manifest manifest = CreateManifest(p1); + index.AddManifest(manifest, p1.Path); + } + + SQLiteIndex rebuilt = SQLiteIndex::Open(rebuiltFile, SQLiteStorageBase::OpenDisposition::ReadWrite); + rebuilt.SetProperty(SQLiteIndex::Property::DeltaBaselineIndexPath, context.BaselineFile.GetPath().u8string()); + rebuilt.SetProperty(SQLiteIndex::Property::DeltaOutputPath, rebuiltDeltaFile.GetPath().u8string()); + + REQUIRE_THROWS_HR(rebuilt.PrepareForPackaging(), APPINSTALLER_CLI_ERROR_INDEX_INTEGRITY_COMPROMISED); +} + +// G10. The identifier says the two were once a pair; it does not say they still fit together. It +// is minted once and never changes, so a baseline migrated to a later schema keeps naming the same +// identity while its tables may no longer have the shape the delta was built against. Defining the +// merged views over that is silent corruption, so the schema versions are checked as well. +TEST_CASE("SQLiteIndex_Delta_BaselineSchemaVersionMismatchRejected", "[sqliteindex][V2_1][delta]") +{ + DeltaTestContext context{ { MakePackage("Publisher1.Id", "Package 1") } }; + + context.Add(MakePackage("Publisher2.Id", "Package 2")); + context.GenerateDelta(); + + REQUIRE_NOTHROW(context.OpenCombined()); + + // Move the baseline's recorded schema version without touching anything else, which is what a + // migration would leave behind as far as the affinity check can see. + { + Connection baseline = Connection::Create(context.BaselineFile.GetPath().u8string(), Connection::OpenDisposition::ReadWrite); + MetadataTable::SetNamedValue(baseline, s_MetadataValueName_MajorVersion, 1); + } + + REQUIRE_THROWS_HR(context.OpenCombined(), APPINSTALLER_CLI_ERROR_INDEX_INTEGRITY_COMPROMISED); +} + +// G11. A rejected baseline has to leave the connection as it was found. The attachment happens +// before the values can be read, so refusing one and then trying another on the same connection is +// the case that proves the attachment was released. +TEST_CASE("SQLiteIndex_Delta_RejectedBaselineReleasesAttachment", "[sqliteindex][V2_1][delta]") +{ + DeltaTestContext context{ { MakePackage("Publisher1.Id", "Package 1") } }; + + // A second baseline with its own identity, which this delta was not generated against. + TempFile otherBaselineFile{ "delta_baseline_other"s, ".db"s }; + std::filesystem::copy_file(context.WorkingFile.GetPath(), otherBaselineFile.GetPath(), std::filesystem::copy_options::overwrite_existing); + + { + SQLiteIndex other = SQLiteIndex::Open(otherBaselineFile.GetPath().u8string(), SQLiteStorageBase::OpenDisposition::ReadWrite); + other.PrepareForPackaging(); + other.MarkAsBaseline(); + } + + context.Add(MakePackage("Publisher2.Id", "Package 2")); + context.GenerateDelta(); + + Connection connection = context.OpenDeltaConnection(); + + REQUIRE_THROWS_HR( + Delta::SetupReadMode(connection, DatabaseSpecifier{ otherBaselineFile.GetPath().u8string(), DatabaseDisposition::Read }), + APPINSTALLER_CLI_ERROR_INDEX_INTEGRITY_COMPROMISED); + + // Had the rejected baseline stayed attached, this would fail complaining that the schema name + // is already in use, which says nothing about why the first one was refused. + REQUIRE_NOTHROW(Delta::SetupReadMode(connection, DatabaseSpecifier{ context.BaselineFile.GetPath().u8string(), DatabaseDisposition::Read })); + + REQUIRE(GetStrings(connection, "SELECT [id] FROM [packages]") == std::set{ "Publisher1.Id", "Publisher2.Id" }); +} diff --git a/src/AppInstallerCLITests/SQLiteIndexTestCommon.cpp b/src/AppInstallerCLITests/SQLiteIndexTestCommon.cpp new file mode 100644 index 0000000000..83b44015c0 --- /dev/null +++ b/src/AppInstallerCLITests/SQLiteIndexTestCommon.cpp @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "SQLiteIndexTestCommon.h" +#include "TestCommon.h" + +#include +#include + +#include + +using namespace std::string_literals; +using namespace AppInstaller::Manifest; +using namespace AppInstaller::Repository::Microsoft; +using namespace AppInstaller::Utility; + +namespace TestCommon +{ + SQLiteIndex CreateTestIndex(const std::string& filePath, std::optional version) + { + // If no specific version requested, then use generator to run against the last 3 versions. + if (!version) + { + SQLiteVersion latestVersion{ 2, 1 }; + SQLiteVersion versionMinus1 = SQLiteVersion{ 2, 0 }; + SQLiteVersion versionMinus2 = SQLiteVersion{ 1, 7 }; + + version = GENERATE_COPY(SQLiteVersion{ versionMinus2 }, SQLiteVersion{ versionMinus1 }, SQLiteVersion{ latestVersion }); + } + + return SQLiteIndex::CreateNew(filePath, version.value()); + } + + std::string GetPathFromManifest(Manifest& manifest) + { + auto publisher = manifest.Id; + AppInstaller::Utility::FindAndReplace(publisher, ".", "/"); + + return AppInstaller::Utility::ToLower(publisher).append("/").append(manifest.Version); + } + + void CreateFakeManifest(Manifest& manifest, string_t publisher, string_t version) + { + manifest.Installers.push_back({}); + manifest.Id = publisher.append(".").append("Id"); + manifest.DefaultLocalization.Add(publisher.append(" Name")); + manifest.Moniker = "testmoniker"; + manifest.Version = version; + manifest.Channel = "test"; + manifest.DefaultLocalization.Add({ "t1", "t2" }); + manifest.Installers[0].Commands = { "test1", "test2" }; + } + + void CreateFakeManifestAndPath( + ManifestAndPath& manifestAndPath, + const string_t& publisher, + std::string_view version, + std::optional arpMinVersion, + std::optional arpMaxVersion) + { + CreateFakeManifest(manifestAndPath.Manifest, publisher, string_t{ version }); + manifestAndPath.Path = ConvertToUTF8(CreateNewGuidNameWString()); + manifestAndPath.Manifest.StreamSha256 = AppInstaller::Utility::SHA256::ComputeHash(manifestAndPath.Path); + + if (arpMinVersion) + { + manifestAndPath.Manifest.Installers[0].BaseInstallerType = InstallerTypeEnum::Exe; + manifestAndPath.Manifest.Installers[0].AppsAndFeaturesEntries.push_back({}); + manifestAndPath.Manifest.Installers[0].AppsAndFeaturesEntries.back().DisplayVersion = arpMinVersion.value(); + } + + if (arpMaxVersion) + { + manifestAndPath.Manifest.Installers[0].BaseInstallerType = InstallerTypeEnum::Exe; + manifestAndPath.Manifest.Installers[0].AppsAndFeaturesEntries.push_back({}); + manifestAndPath.Manifest.Installers[0].AppsAndFeaturesEntries.back().DisplayVersion = arpMaxVersion.value(); + } + } + + void ApplyIndexFields(Manifest& manifest, const IndexFields& fields) + { + manifest.Id = fields.Id; + manifest.DefaultLocalization.Add(fields.Name); + manifest.DefaultLocalization.Add(fields.Publisher); + manifest.Moniker = fields.Moniker; + manifest.Version = fields.Version; + manifest.DefaultLocalization.Add(fields.Tags); + + manifest.Installers.resize(std::max(fields.PackageFamilyNames.size(), fields.ProductCodes.size())); + + if (manifest.Installers.size() == 0) + { + manifest.Installers.push_back({}); + } + + manifest.Channel = fields.Channel; + manifest.Installers[0].Commands = fields.Commands; + + for (size_t i = 0; i < fields.PackageFamilyNames.size(); ++i) + { + manifest.Installers[i].PackageFamilyName = fields.PackageFamilyNames[i]; + } + + for (size_t i = 0; i < fields.ProductCodes.size(); ++i) + { + manifest.Installers[i].ProductCode = fields.ProductCodes[i]; + } + + if (!fields.ArpName.empty() || !fields.ArpPublisher.empty()) + { + manifest.Installers[0].AppsAndFeaturesEntries.push_back({}); + manifest.Installers[0].AppsAndFeaturesEntries[0].DisplayName = fields.ArpName; + manifest.Installers[0].AppsAndFeaturesEntries[0].Publisher = fields.ArpPublisher; + } + } + + Manifest CreateManifest(const IndexFields& fields) + { + Manifest manifest; + ApplyIndexFields(manifest, fields); + return manifest; + } + + SQLiteIndex SearchTestSetup(const std::string& filePath, std::initializer_list data, std::optional version) + { + SQLiteIndex index = CreateTestIndex(filePath, version); + + // A single manifest is carried across the whole set, matching what this helper has always + // done. Anything the fields do not overwrite therefore persists from the previous entry. + Manifest manifest; + + for (const auto& d : data) + { + ApplyIndexFields(manifest, d); + index.AddManifest(manifest, d.Path); + } + + return index; + } +} diff --git a/src/AppInstallerCLITests/SQLiteIndexTestCommon.h b/src/AppInstallerCLITests/SQLiteIndexTestCommon.h new file mode 100644 index 0000000000..56d25cd702 --- /dev/null +++ b/src/AppInstallerCLITests/SQLiteIndexTestCommon.h @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include +#include +#include + + +// Fixture helpers shared by the index test files. +namespace TestCommon +{ + using SQLiteVersion = AppInstaller::SQLite::Version; + + // Creates an index to test against. + // When no version is given, the test is generated once for each of the last few schema + // versions, so callers that care about a specific version must name it. + AppInstaller::Repository::Microsoft::SQLiteIndex CreateTestIndex(const std::string& filePath, std::optional version = {}); + + // Gets the relative path that the given manifest would be stored at. + std::string GetPathFromManifest(AppInstaller::Manifest::Manifest& manifest); + + // Fills in a manifest with values derived from the given publisher. + void CreateFakeManifest( + AppInstaller::Manifest::Manifest& manifest, + AppInstaller::Manifest::string_t publisher, + AppInstaller::Manifest::string_t version = "1.0.0"); + + // A manifest along with the path that it is stored at. + struct ManifestAndPath + { + AppInstaller::Manifest::Manifest Manifest; + std::string Path; + }; + + // Fills in a manifest and gives it a unique path, along with the hash of that path. + void CreateFakeManifestAndPath( + ManifestAndPath& manifestAndPath, + const AppInstaller::Manifest::string_t& publisher, + std::string_view version = "1.0.0", + std::optional arpMinVersion = {}, + std::optional arpMaxVersion = {}); + + // The values that a test places into an index for a single manifest. + struct IndexFields + { + IndexFields( + std::string id, + std::string name, + std::string moniker, + std::string version, + std::string channel, + std::vector tags, + std::vector commands, + std::string path + ) : + Id(std::move(id)), + Name(std::move(name)), + Moniker(std::move(moniker)), + Version(std::move(version)), + Channel(std::move(channel)), + Tags(std::move(tags)), + Commands(std::move(commands)), + Path(std::move(path)) + {} + + IndexFields( + std::string id, + std::string name, + std::string moniker, + std::string version, + std::string channel, + std::vector tags, + std::vector commands, + std::string path, + std::vector packageFamilyNames, + std::vector productCodes + ) : + Id(std::move(id)), + Name(std::move(name)), + Moniker(std::move(moniker)), + Version(std::move(version)), + Channel(std::move(channel)), + Tags(std::move(tags)), + Commands(std::move(commands)), + Path(std::move(path)), + PackageFamilyNames(std::move(packageFamilyNames)), + ProductCodes(std::move(productCodes)) + {} + + IndexFields( + std::string id, + std::string name, + std::string publisher, + std::string moniker, + std::string version, + std::string channel, + std::vector tags, + std::vector commands, + std::string path, + std::vector packageFamilyNames, + std::vector productCodes + ) : + Id(std::move(id)), + Name(std::move(name)), + Publisher(std::move(publisher)), + Moniker(std::move(moniker)), + Version(std::move(version)), + Channel(std::move(channel)), + Tags(std::move(tags)), + Commands(std::move(commands)), + Path(std::move(path)), + PackageFamilyNames(std::move(packageFamilyNames)), + ProductCodes(std::move(productCodes)) + {} + + IndexFields( + std::string id, + std::string name, + std::string publisher, + std::string moniker, + std::string version, + std::string channel, + std::vector tags, + std::vector commands, + std::string path, + std::vector packageFamilyNames, + std::vector productCodes, + std::string arpName, + std::string arpPublisher + ) : + Id(std::move(id)), + Name(std::move(name)), + Publisher(std::move(publisher)), + Moniker(std::move(moniker)), + Version(std::move(version)), + Channel(std::move(channel)), + Tags(std::move(tags)), + Commands(std::move(commands)), + Path(std::move(path)), + PackageFamilyNames(std::move(packageFamilyNames)), + ProductCodes(std::move(productCodes)), + ArpName(std::move(arpName)), + ArpPublisher(std::move(arpPublisher)) + {} + + std::string Id; + std::string Name; + std::string Publisher; + std::string Moniker; + std::string Version; + std::string Channel; + std::vector Tags; + std::vector Commands; + std::string Path; + std::vector PackageFamilyNames; + std::vector ProductCodes; + std::string ArpName; + std::string ArpPublisher; + }; + + // Applies the given fields to a manifest, overwriting what it names and leaving the rest. + void ApplyIndexFields(AppInstaller::Manifest::Manifest& manifest, const IndexFields& fields); + + // Produces the manifest described by the given fields. + AppInstaller::Manifest::Manifest CreateManifest(const IndexFields& fields); + + // Creates an index containing the given data. + AppInstaller::Repository::Microsoft::SQLiteIndex SearchTestSetup( + const std::string& filePath, + std::initializer_list data = {}, + std::optional version = {}); +} diff --git a/src/AppInstallerCLITests/SQLiteWrapper.cpp b/src/AppInstallerCLITests/SQLiteWrapper.cpp index 10fd960372..b23bd174e8 100644 --- a/src/AppInstallerCLITests/SQLiteWrapper.cpp +++ b/src/AppInstallerCLITests/SQLiteWrapper.cpp @@ -5,9 +5,12 @@ #include #include #include +#include +#include using namespace AppInstaller::SQLite; using namespace std::string_literals; +using namespace std::string_view_literals; static const char* s_firstColumn = "first"; static const char* s_secondColumn = "second"; @@ -730,6 +733,452 @@ TEST_CASE("SQLBuilder_InsertValueBinding", "[sqlbuilder]") } } +TEST_CASE("SQLBuilder_AssignValueNull", "[sqlbuilder]") +{ + Connection connection = Connection::Create(SQLITE_MEMORY_DB_CONNECTION_TARGET, Connection::OpenDisposition::Create); + + CreateSimpleTestTable(connection); + InsertIntoSimpleTestTable(connection, 1, "value"); + + { + INFO("Equals(nullptr) remains blocked as a filter"); + Builder::StatementBuilder builder; + REQUIRE_THROWS_HR(builder.Select(s_firstColumn).From(s_tableName).Where(s_secondColumn).Equals(nullptr), E_NOTIMPL); + } + + { + INFO("AssignValue(nullptr) assigns NULL in an update"); + Builder::StatementBuilder update; + update.Update(s_tableName).Set().Column(s_secondColumn).AssignValue(nullptr).Where(s_firstColumn).Equals(1); + update.Execute(connection); + } + + { + INFO("The value is now NULL"); + Builder::StatementBuilder select; + select.Select({ s_firstColumn, s_secondColumn }).From(s_tableName); + + Statement statement = select.Prepare(connection); + REQUIRE(statement.Step()); + REQUIRE(statement.GetColumn(0) == 1); + REQUIRE(statement.GetColumnIsNull(1)); + REQUIRE(!statement.Step()); + } +} + +TEST_CASE("SQLBuilder_AddColumnWithConstraints", "[sqlbuilder]") +{ + Connection connection = Connection::Create(SQLITE_MEMORY_DB_CONNECTION_TARGET, Connection::OpenDisposition::Create); + + CreateSimpleTestTable(connection); + InsertIntoSimpleTestTable(connection, 1, "one"); + + constexpr std::string_view addedColumn = "added"; + + { + // SQLite requires a non-null default when adding a column declared as not null, + // so the plain Add(column, type) form cannot express this. + INFO("Add a not null column with a default"); + Builder::StatementBuilder alter; + alter.AlterTable(s_tableName).Add(Builder::ColumnBuilder(addedColumn, Builder::Type::Int64).NotNull().Default(0)); + alter.Execute(connection); + } + + { + INFO("The existing row receives the default rather than null"); + Builder::StatementBuilder select; + select.Select(addedColumn).From(s_tableName); + + Statement statement = select.Prepare(connection); + REQUIRE(statement.Step()); + REQUIRE(!statement.GetColumnIsNull(0)); + REQUIRE(statement.GetColumn(0) == 0); + REQUIRE(!statement.Step()); + } +} + +TEST_CASE("SQLBuilder_CreateTempView", "[sqlbuilder]") +{ + Connection connection = Connection::Create(SQLITE_MEMORY_DB_CONNECTION_TARGET, Connection::OpenDisposition::Create); + + CreateSimpleTestTable(connection); + InsertIntoSimpleTestTable(connection, 1, "one"); + InsertIntoSimpleTestTable(connection, 2, "two"); + + constexpr std::string_view viewName = "simple_view"; + + { + // Note that SQLite prohibits bound parameters in a view definition, so the + // statement that defines a view must be structural only. + INFO("Create a view over the table"); + Builder::StatementBuilder createView; + createView.CreateTempView(viewName).Select({ s_firstColumn, s_secondColumn }).From(s_tableName).OrderBy(s_firstColumn); + createView.Execute(connection); + } + + { + INFO("The view returns the underlying rows"); + Builder::StatementBuilder select; + select.Select({ s_firstColumn, s_secondColumn }).From(viewName); + + Statement statement = select.Prepare(connection); + REQUIRE(statement.Step()); + REQUIRE(statement.GetColumn(0) == 1); + REQUIRE(statement.GetColumn(1) == "one"); + REQUIRE(statement.Step()); + REQUIRE(statement.GetColumn(0) == 2); + REQUIRE(statement.GetColumn(1) == "two"); + REQUIRE(!statement.Step()); + } + + { + INFO("A filter can still be applied when reading the view"); + Builder::StatementBuilder select; + select.Select(s_secondColumn).From(viewName).Where(s_firstColumn).Equals(2); + + Statement statement = select.Prepare(connection); + REQUIRE(statement.Step()); + REQUIRE(statement.GetColumn(0) == "two"); + REQUIRE(!statement.Step()); + } +} + +TEST_CASE("SQLBuilder_UnionAll", "[sqlbuilder]") +{ + Connection connection = Connection::Create(SQLITE_MEMORY_DB_CONNECTION_TARGET, Connection::OpenDisposition::Create); + + CreateSimpleTestTable(connection); + InsertIntoSimpleTestTable(connection, 1, "one"); + InsertIntoSimpleTestTable(connection, 2, "two"); + + Builder::StatementBuilder select; + select.Select(s_firstColumn).From(s_tableName).Where(s_firstColumn).Equals(1). + UnionAll(). + Select(s_firstColumn).From(s_tableName).Where(s_firstColumn).Equals(2); + + Statement statement = select.Prepare(connection); + + REQUIRE(statement.Step()); + REQUIRE(statement.GetColumn(0) == 1); + REQUIRE(statement.Step()); + REQUIRE(statement.GetColumn(0) == 2); + REQUIRE(!statement.Step()); +} + +TEST_CASE("SQLBuilder_NotExists", "[sqlbuilder]") +{ + Connection connection = Connection::Create(SQLITE_MEMORY_DB_CONNECTION_TARGET, Connection::OpenDisposition::Create); + + constexpr std::string_view otherTable = "other_test"; + + CreateSimpleTestTable(connection); + InsertIntoSimpleTestTable(connection, 1, "one"); + InsertIntoSimpleTestTable(connection, 2, "two"); + + { + Builder::StatementBuilder createTable; + createTable.CreateTable(otherTable).Columns({ Builder::ColumnBuilder(s_firstColumn, Builder::Type::Int) }); + createTable.Execute(connection); + + Builder::StatementBuilder insert; + insert.InsertInto(otherTable).Columns(s_firstColumn).Values(2); + insert.Execute(connection); + } + + // Select rows from the simple table that have no matching row in the other table. + Builder::StatementBuilder select; + select.Select(Builder::QualifiedColumn{ s_tableName, s_firstColumn }).From(s_tableName). + Where().NotExists().BeginParenthetical(). + Select(Builder::QualifiedColumn{ otherTable, s_firstColumn }).From(otherTable). + Where(Builder::QualifiedColumn{ otherTable, s_firstColumn }).Equals(Builder::QualifiedColumn{ s_tableName, s_firstColumn }). + EndParenthetical(); + + Statement statement = select.Prepare(connection); + + REQUIRE(statement.Step()); + REQUIRE(statement.GetColumn(0) == 1); + REQUIRE(!statement.Step()); +} + +TEST_CASE("SQLBuilder_AttachAndTempView", "[sqlbuilder]") +{ + TestCommon::TempFile baselineFile{ "repolibtest_baseline"s, ".db"s }; + INFO("Using temporary file named: " << baselineFile.GetPath()); + + { + INFO("Create the database that will be attached"); + Connection baseline = Connection::Create(baselineFile, Connection::OpenDisposition::Create); + CreateSimpleTestTable(baseline); + InsertIntoSimpleTestTable(baseline, 1, "baseline"); + } + + // The host is created rather than named through a specifier, which is the case that proves URI + // handling is a property of the connection: SQLite decides whether names are URIs when the + // connection is opened and applies that to every later ATTACH, so a host that did not ask for + // it would take the attached database's URI as a literal filename. + Connection connection = Connection::Create(SQLITE_MEMORY_DB_CONNECTION_TARGET, Connection::OpenDisposition::Create); + + constexpr std::string_view baselineAlias = "baseline"; + constexpr std::string_view deltaTable = "delta_test"; + + { + INFO("Create a local table with a distinct row"); + Builder::StatementBuilder createTable; + createTable.CreateTable(deltaTable).Columns({ + Builder::ColumnBuilder(s_firstColumn, Builder::Type::Int), + Builder::ColumnBuilder(s_secondColumn, Builder::Type::Text), + }); + createTable.Execute(connection); + + Builder::StatementBuilder insert; + insert.InsertInto(deltaTable).Columns({ s_firstColumn, s_secondColumn }).Values(2, "delta"sv); + insert.Execute(connection); + } + + { + INFO("Attach the baseline database"); + Builder::StatementBuilder attach; + attach.Attach(DatabaseSpecifier{ baselineFile.GetPath().u8string(), DatabaseDisposition::Read }, baselineAlias); + attach.Execute(connection); + } + + { + INFO("A temp view can span the local and attached databases"); + Builder::StatementBuilder createView; + createView.CreateTempView(s_tableName). + Select({ s_firstColumn, s_secondColumn }).From(deltaTable). + UnionAll(). + Select({ s_firstColumn, s_secondColumn }).From(Builder::QualifiedTable{ baselineAlias, s_tableName }); + createView.Execute(connection); + } + + { + INFO("Reading the view returns the merged rows"); + Builder::StatementBuilder select; + select.Select({ s_firstColumn, s_secondColumn }).From(s_tableName).OrderBy(s_firstColumn); + + Statement statement = select.Prepare(connection); + + REQUIRE(statement.Step()); + REQUIRE(statement.GetColumn(0) == 1); + REQUIRE(statement.GetColumn(1) == "baseline"); + + REQUIRE(statement.Step()); + REQUIRE(statement.GetColumn(0) == 2); + REQUIRE(statement.GetColumn(1) == "delta"); + + REQUIRE(!statement.Step()); + } +} + +// ATTACH takes no flags of its own: it starts from the ones the connection was opened with. A +// database named by a plain path would therefore be attached read/write whenever its host is, so +// the disposition has to travel in the name. +TEST_CASE("SQLBuilder_AttachHonorsDisposition", "[sqlbuilder]") +{ + TestCommon::TempFile mainFile{ "repolibtest_attach_main"s, ".db"s }; + TestCommon::TempFile attachedFile{ "repolibtest_attach_readonly"s, ".db"s }; + + { + Connection main = Connection::Create(mainFile, Connection::OpenDisposition::Create); + CreateSimpleTestTable(main); + } + + { + Connection attached = Connection::Create(attachedFile, Connection::OpenDisposition::Create); + CreateSimpleTestTable(attached); + InsertIntoSimpleTestTable(attached, 1, "attached"); + } + + constexpr std::string_view alias = "other"; + + // The host connection can write, which is what makes the attachment worth asserting. + Connection connection = Connection::Create(DatabaseSpecifier{ mainFile.GetPath().u8string(), DatabaseDisposition::ReadWrite }); + + { + Builder::StatementBuilder attach; + attach.Attach(DatabaseSpecifier{ attachedFile.GetPath().u8string(), DatabaseDisposition::Read }, alias); + attach.Execute(connection); + } + + { + INFO("The primary database is writable"); + Builder::StatementBuilder insert; + insert.InsertInto(s_tableName).Columns({ s_firstColumn, s_secondColumn }).Values(2, "main"sv); + REQUIRE_NOTHROW(insert.Execute(connection)); + } + + { + INFO("The attached database is not"); + Builder::StatementBuilder insert; + insert.InsertInto(Builder::QualifiedTable{ alias, s_tableName }).Columns({ s_firstColumn, s_secondColumn }).Values(3, "attached"sv); + REQUIRE_THROWS(insert.Execute(connection)); + } +} + +// Reading metadata from an attached database is what lets a caller validate the database it is +// actually going to read, rather than a separate connection's view of the same path. +TEST_CASE("SQLiteMetadata_AttachedDatabase", "[sqlitewrapper]") +{ + TestCommon::TempFile attachedFile{ "repolibtest_metadata_attached"s, ".db"s }; + INFO("Using temporary file named: " << attachedFile.GetPath()); + + { + Connection attached = Connection::Create(attachedFile, Connection::OpenDisposition::Create); + MetadataTable::Create(attached); + MetadataTable::SetNamedValue(attached, s_MetadataValueName_MajorVersion, 2); + MetadataTable::SetNamedValue(attached, s_MetadataValueName_MinorVersion, 1); + MetadataTable::SetNamedValue(attached, "shared"sv, "attachedValue"s); + MetadataTable::SetNamedValue(attached, "onlyAttached"sv, "present"s); + } + + Connection connection = Connection::Create(SQLITE_MEMORY_DB_CONNECTION_TARGET, Connection::OpenDisposition::Create); + MetadataTable::Create(connection); + MetadataTable::SetNamedValue(connection, s_MetadataValueName_MajorVersion, 3); + MetadataTable::SetNamedValue(connection, s_MetadataValueName_MinorVersion, 4); + MetadataTable::SetNamedValue(connection, "shared"sv, "mainValue"s); + + constexpr std::string_view alias = "other"; + + { + Builder::StatementBuilder attach; + attach.Attach(DatabaseSpecifier{ attachedFile.GetPath().u8string(), DatabaseDisposition::Read }, alias); + attach.Execute(connection); + } + + // Both databases have a metadata table holding the same name, so an unqualified read would + // silently answer from the wrong one. + REQUIRE(MetadataTable::GetNamedValue(connection, "shared"sv) == "mainValue"); + REQUIRE(MetadataTable::GetNamedValue(connection, "shared"sv, alias) == "attachedValue"); + + // A value only the attachment has is unreachable without targeting it. + REQUIRE(!MetadataTable::TryGetNamedValue(connection, "onlyAttached"sv).has_value()); + REQUIRE(MetadataTable::TryGetNamedValue(connection, "onlyAttached"sv, alias) == "present"s); + + // Absence is still reported as absence rather than falling back to the primary database. + REQUIRE(!MetadataTable::TryGetNamedValue(connection, "onlyMain"sv, alias).has_value()); + + REQUIRE(Version::GetSchemaVersion(connection) == Version{ 3, 4 }); + REQUIRE(Version::GetSchemaVersion(connection, alias) == Version{ 2, 1 }); +} + +// Detaching has to actually release the alias, or a caller that rejects one database cannot try +// another on the same connection. +TEST_CASE("SQLBuilder_Detach", "[sqlbuilder]") +{ + TestCommon::TempFile firstFile{ "repolibtest_detach_first"s, ".db"s }; + TestCommon::TempFile secondFile{ "repolibtest_detach_second"s, ".db"s }; + + constexpr std::string_view firstMarker = "first"; + constexpr std::string_view secondMarker = "second"; + + auto seed = [](const TestCommon::TempFile& file, std::string_view marker) + { + Connection attached = Connection::Create(file, Connection::OpenDisposition::Create); + MetadataTable::Create(attached); + MetadataTable::SetNamedValue(attached, "which"sv, std::string{ marker }); + }; + + seed(firstFile, firstMarker); + seed(secondFile, secondMarker); + + Connection connection = Connection::Create(SQLITE_MEMORY_DB_CONNECTION_TARGET, Connection::OpenDisposition::Create); + + constexpr std::string_view alias = "attached"; + + auto attach = [&](const TestCommon::TempFile& file) + { + Builder::StatementBuilder builder; + builder.Attach(DatabaseSpecifier{ file.GetPath().u8string(), DatabaseDisposition::Read }, alias); + builder.Execute(connection); + }; + + attach(firstFile); + REQUIRE(MetadataTable::GetNamedValue(connection, "which"sv, alias) == firstMarker); + + // Reusing an alias that is still in use is an error, which is what makes releasing it matter. + REQUIRE_THROWS(attach(secondFile)); + + { + Builder::StatementBuilder detach; + detach.Detach(alias); + detach.Execute(connection); + } + + attach(secondFile); + REQUIRE(MetadataTable::GetNamedValue(connection, "which"sv, alias) == secondMarker); +} + +TEST_CASE("SQLBuilder_ViewWithTombstoneSuppression", "[sqlbuilder]") +{ + Connection connection = Connection::Create(SQLITE_MEMORY_DB_CONNECTION_TARGET, Connection::OpenDisposition::Create); + + constexpr std::string_view valueTombstoneTable = "value_tombstone"; + constexpr std::string_view ownerTombstoneTable = "owner_tombstone"; + constexpr std::string_view removedColumn = "is_removed"; + constexpr std::string_view valueAlias = "v"; + constexpr std::string_view ownerAlias = "o"; + constexpr std::string_view viewName = "survivors"; + + CreateSimpleTestTable(connection); + InsertIntoSimpleTestTable(connection, 1, "kept"); + InsertIntoSimpleTestTable(connection, 2, "value removed"); + InsertIntoSimpleTestTable(connection, 3, "owner removed"); + + auto createTombstoneTable = [&](std::string_view tableName, int suppressedValue) + { + Builder::StatementBuilder createTable; + createTable.CreateTable(tableName).Columns({ + Builder::ColumnBuilder(s_firstColumn, Builder::Type::Int), + Builder::ColumnBuilder(removedColumn, Builder::Type::Int), + }); + createTable.Execute(connection); + + Builder::StatementBuilder insertSuppressed; + insertSuppressed.InsertInto(tableName).Columns({ s_firstColumn, removedColumn }).Values(suppressedValue, 1); + insertSuppressed.Execute(connection); + + // Row 1 is named by both tables without being removed by either, which is what + // distinguishes a test of the removal flag from a test of mere presence. + Builder::StatementBuilder insertMentioned; + insertMentioned.InsertInto(tableName).Columns({ s_firstColumn, removedColumn }).Values(1, 0); + insertMentioned.Execute(connection); + }; + + createTombstoneTable(valueTombstoneTable, 2); + createTombstoneTable(ownerTombstoneTable, 3); + + { + INFO("A view cannot contain bound parameters, so its comparisons have to be literals"); + Builder::StatementBuilder createView; + createView.CreateTempView(viewName). + Select({ s_firstColumn, s_secondColumn }).From(s_tableName). + Where().NotExists().BeginParenthetical(). + Select(s_firstColumn).From(valueTombstoneTable).As(valueAlias). + Where(Builder::QualifiedColumn{ valueAlias, s_firstColumn }).Equals(Builder::QualifiedColumn{ s_tableName, s_firstColumn }). + And(Builder::QualifiedColumn{ valueAlias, removedColumn }).EqualsLiteral(1). + EndParenthetical(). + And().NotExists().BeginParenthetical(). + Select(s_firstColumn).From(ownerTombstoneTable).As(ownerAlias). + Where(Builder::QualifiedColumn{ ownerAlias, s_firstColumn }).Equals(Builder::QualifiedColumn{ s_tableName, s_firstColumn }). + And(Builder::QualifiedColumn{ ownerAlias, removedColumn }).EqualsLiteral(1). + EndParenthetical(); + createView.Execute(connection); + } + + INFO("Only the row that neither tombstone removes survives"); + Builder::StatementBuilder select; + select.Select({ s_firstColumn, s_secondColumn }).From(viewName).OrderBy(s_firstColumn); + + Statement statement = select.Prepare(connection); + + REQUIRE(statement.Step()); + REQUIRE(statement.GetColumn(0) == 1); + REQUIRE(statement.GetColumn(1) == "kept"); + + REQUIRE(!statement.Step()); +} + TEST_CASE("SQLiteWrapperTransactionRollback", "[sqlitewrapper]") { Connection connection = Connection::Create(SQLITE_MEMORY_DB_CONNECTION_TARGET, Connection::OpenDisposition::Create); @@ -854,3 +1303,125 @@ TEST_CASE("SQLiteWrapperTransactionWriteConflict", "[sqlitewrapper]") SelectFromSimpleTestTableOnlyOneRow(connection, firstVal, secondVal); } + +TEST_CASE("SQLiteDatabaseSpecifierTargets", "[sqlitewrapper]") +{ + // Every disposition is carried as a URI query parameter, because ATTACH takes no flags of its + // own and would otherwise give the attached database whatever access its host connection has. + DatabaseSpecifier read{ "D:\\test\\index.db"s, DatabaseDisposition::Read }; + REQUIRE(read.Target() == "file:/D:/test/index.db?mode=ro"); + REQUIRE(read.ConnectionDisposition() == Connection::OpenDisposition::ReadOnly); + + DatabaseSpecifier readWrite{ "D:\\test\\index.db"s, DatabaseDisposition::ReadWrite }; + REQUIRE(readWrite.Target() == "file:/D:/test/index.db?mode=rw"); + REQUIRE(readWrite.ConnectionDisposition() == Connection::OpenDisposition::ReadWrite); + + // Immutability is not something that the mode parameter can express. + DatabaseSpecifier immutable{ "D:\\test\\index.db"s, DatabaseDisposition::Immutable }; + REQUIRE(immutable.Target() == "file:/D:/test/index.db?immutable=1"); + REQUIRE(immutable.ConnectionDisposition() == Connection::OpenDisposition::ReadOnly); + + // Characters that would otherwise start the query or fragment are escaped, and repeated + // separators collapse, per the conversion the URI documentation prescribes. + DatabaseSpecifier escaped{ "D:\\a#b\\\\c?d\\index.db"s, DatabaseDisposition::Immutable }; + REQUIRE(escaped.Target() == "file:/D:/a%23b/c%3fd/index.db?immutable=1"); + + // A UNC path is the one case where the leading separators must not collapse: the pair has to + // survive, which takes an empty authority ahead of it. Anything else names the server as the + // authority, which SQLite rejects. + DatabaseSpecifier unc{ "\\\\server\\share\\index.db"s, DatabaseDisposition::Read }; + REQUIRE(unc.Target() == "file:////server/share/index.db?mode=ro"); + + // A percent is a legal filename character, but SQLite decodes %HH escapes out of the path. Left + // alone, this name would be read as index#.db -- a different file, if it exists at all. + DatabaseSpecifier percent{ "D:\\test\\index%23.db"s, DatabaseDisposition::Read }; + REQUIRE(percent.Target() == "file:/D:/test/index%2523.db?mode=ro"); +} + +// The escaping is only worth anything if a file so named can actually be opened, which is the part +// no amount of string comparison can establish. +TEST_CASE("SQLiteDatabaseSpecifierEscapedPathOpen", "[sqlitewrapper]") +{ + TestCommon::TempFile tempFile{ "repolibtest_temp%23db"s, ".db"s }; + INFO("Using temporary file named: " << tempFile.GetPath()); + + int firstVal = 1; + std::string secondVal = "test"; + + { + Connection connection = Connection::Create(tempFile, Connection::OpenDisposition::Create); + CreateSimpleTestTable(connection); + InsertIntoSimpleTestTable(connection, firstVal, secondVal); + } + + DatabaseSpecifier specifier{ tempFile.GetPath().u8string(), DatabaseDisposition::Read }; + Connection connection = Connection::Create(specifier); + + SelectFromSimpleTestTableOnlyOneRow(connection, firstVal, secondVal); +} + +// The administrative share is used rather than creating one, so this reaches the same local file by +// a UNC name without changing the machine. It is not reachable without elevation, so the test +// yields instead of failing when it is absent. +TEST_CASE("SQLiteDatabaseSpecifierUncOpen", "[sqlitewrapper]") +{ + TestCommon::TempFile tempFile{ "repolibtest_tempdb"s, ".db"s }; + INFO("Using temporary file named: " << tempFile.GetPath()); + + std::wstring localPath = tempFile.GetPath().wstring(); + + if (localPath.size() < 3 || localPath[1] != L':' || localPath[2] != L'\\') + { + WARN("Temporary file is not named by a drive letter; skipping UNC coverage"); + return; + } + + // C:\dir\file.db -> \\localhost\C$\dir\file.db + std::filesystem::path uncPath{ L"\\\\localhost\\" + localPath.substr(0, 1) + L"$" + localPath.substr(2) }; + INFO("Using UNC name: " << uncPath); + + int firstVal = 1; + std::string secondVal = "test"; + + { + Connection connection = Connection::Create(tempFile, Connection::OpenDisposition::Create); + CreateSimpleTestTable(connection); + InsertIntoSimpleTestTable(connection, firstVal, secondVal); + } + + if (!std::filesystem::exists(uncPath)) + { + WARN("Administrative share is not reachable; skipping UNC coverage"); + return; + } + + DatabaseSpecifier specifier{ uncPath.u8string(), DatabaseDisposition::Read }; + + std::string expectedPrefix = "file:////localhost/" + std::string{ static_cast(localPath[0]) } + "$/"; + REQUIRE(specifier.Target().substr(0, expectedPrefix.size()) == expectedPrefix); + + Connection connection = Connection::Create(specifier); + + SelectFromSimpleTestTableOnlyOneRow(connection, firstVal, secondVal); +} + +TEST_CASE("SQLiteDatabaseSpecifierImmutableOpen", "[sqlitewrapper]") +{ + TestCommon::TempFile tempFile{ "repolibtest_tempdb"s, ".db"s }; + INFO("Using temporary file named: " << tempFile.GetPath()); + + int firstVal = 1; + std::string secondVal = "test"; + + { + Connection connection = Connection::Create(tempFile, Connection::OpenDisposition::Create); + + CreateSimpleTestTable(connection); + + InsertIntoSimpleTestTable(connection, firstVal, secondVal); + } + + Connection connection = Connection::Create(DatabaseSpecifier{ tempFile.GetPath().u8string(), DatabaseDisposition::Immutable }); + + SelectFromSimpleTestTableOnlyOneRow(connection, firstVal, secondVal); +} diff --git a/src/AppInstallerCLITests/main.cpp b/src/AppInstallerCLITests/main.cpp index a91ca2ab11..77aaef8671 100644 --- a/src/AppInstallerCLITests/main.cpp +++ b/src/AppInstallerCLITests/main.cpp @@ -33,12 +33,22 @@ struct LoggingBreakListener : public Catch::EventListenerBase if (!testCaseStats.totals.delta(lastTotals).testCases.allOk()) { TestCommon::TempFile::SetTestFailed(true); + if (testCaseStats.testInfo) + { + GetFailedTests().push_back(testCaseStats.testInfo->name); + } } lastTotals = testCaseStats.totals; Catch::EventListenerBase::testCaseEnded(testCaseStats); } Catch::Totals lastTotals{}; + + static std::vector& GetFailedTests() + { + static std::vector failedTests; + return failedTests; + } }; CATCH_REGISTER_LISTENER(LoggingBreakListener); @@ -78,6 +88,7 @@ int main(int argc, char** argv) bool hasSetTestDataBasePath = false; bool waitBeforeReturn = false; bool keepSQLLogging = false; + bool printSummary = false; std::vector args; for (int i = 0; i < argc; ++i) @@ -132,6 +143,10 @@ int main(int argc, char** argv) Debugging::EnableSelfInitiatedMinidump(std::filesystem::path{ argv[i] }); } } + else if ("-summary"s == argv[i]) + { + printSummary = true; + } else { args.push_back(argv[i]); @@ -175,6 +190,15 @@ int main(int argc, char** argv) int result = Catch::Session().run(static_cast(args.size()), args.data()); + if (printSummary) + { + std::cout << "\nFailed tests:\n"; + for (const auto& test : LoggingBreakListener::GetFailedTests()) + { + std::cout << " " << test << "\n"; + } + } + if (waitBeforeReturn) { // Wait for some input before returning diff --git a/src/AppInstallerRepositoryCore/AppInstallerRepositoryCore.vcxproj b/src/AppInstallerRepositoryCore/AppInstallerRepositoryCore.vcxproj index 3a5a39da6a..3f69f65c86 100644 --- a/src/AppInstallerRepositoryCore/AppInstallerRepositoryCore.vcxproj +++ b/src/AppInstallerRepositoryCore/AppInstallerRepositoryCore.vcxproj @@ -331,6 +331,10 @@ + + + + @@ -443,6 +447,10 @@ + + + + diff --git a/src/AppInstallerRepositoryCore/AppInstallerRepositoryCore.vcxproj.filters b/src/AppInstallerRepositoryCore/AppInstallerRepositoryCore.vcxproj.filters index 64f1b186c2..2071579398 100644 --- a/src/AppInstallerRepositoryCore/AppInstallerRepositoryCore.vcxproj.filters +++ b/src/AppInstallerRepositoryCore/AppInstallerRepositoryCore.vcxproj.filters @@ -127,6 +127,9 @@ {b4c8d9e2-f3a5-6b7c-0d1e-2f3a4b5c6d7e} + + {b4b0b84d-5dea-42bb-817b-7cf269008f52} + @@ -519,6 +522,18 @@ Header Files + + Microsoft\Schema\2_1 + + + Microsoft\Schema\2_1 + + + Microsoft\Schema\2_1 + + + Microsoft\Schema\2_1 + @@ -812,6 +827,18 @@ Source Files + + Microsoft\Schema\2_1 + + + Microsoft\Schema\2_1 + + + Microsoft\Schema\2_1 + + + Microsoft\Schema\2_1 + diff --git a/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndex.cpp b/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndex.cpp index 61247ebf5f..2aaec84dc5 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndex.cpp +++ b/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndex.cpp @@ -1,379 +1,431 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -#include "pch.h" -#include "SQLiteIndex.h" -#include -#include "ArpVersionValidation.h" -#include - -namespace AppInstaller::Repository::Microsoft -{ - namespace - { - size_t GetPageSizeFromOptions(SQLiteIndex::CreateOptions options) - { - return WI_IsFlagSet(options, SQLiteIndex::CreateOptions::LargePageSize) ? 65536 : 0; - } - } - - SQLiteIndex SQLiteIndex::CreateNew(const std::string& filePath, SQLite::Version version, CreateOptions options) - { - AICLI_LOG(Repo, Info, << "Creating new SQLite Index with version [" << version << "] at '" << filePath << "'"); - SQLiteIndex result{ filePath, version, options }; - - SQLite::Savepoint savepoint = SQLite::Savepoint::Create(result.m_dbconn, "sqliteindex_createnew"); - - // Use calculated version, as incoming version could be 'latest' - result.m_version.SetSchemaVersion(result.m_dbconn); - - result.m_interface->CreateTables(result.m_dbconn, options); - - result.SetLastWriteTime(); - - savepoint.Commit(); - - return result; - } - - SQLiteIndex SQLiteIndex::Open(const std::string& filePath, OpenDisposition disposition, Utility::ManagedFile&& indexFile) - { - return { filePath, disposition, std::move(indexFile) }; - } - - SQLiteIndex SQLiteIndex::CopyFrom(const std::string& filePath, SQLiteIndex& source) - { - return { filePath, source }; - } - - SQLiteIndex::SQLiteIndex(const std::string& target, const SQLite::Version& version, CreateOptions options) : SQLiteStorageBase(target, version, GetPageSizeFromOptions(options)) - { - m_dbconn.EnableICU(); - m_interface = Schema::CreateISQLiteIndex(version); - m_version = m_interface->GetVersion(); - SetDatabaseFilePath(target); - } - - SQLiteIndex::SQLiteIndex(const std::string& target, SQLiteStorageBase::OpenDisposition disposition, Utility::ManagedFile&& indexFile) : - SQLiteStorageBase(target, disposition, std::move(indexFile)) - { - m_dbconn.EnableICU(); - AICLI_LOG(Repo, Info, << "Opened SQLite Index with version [" << m_version << "], last write [" << GetLastWriteTime() << "]"); - m_interface = Schema::CreateISQLiteIndex(m_version); - THROW_HR_IF(APPINSTALLER_CLI_ERROR_CANNOT_WRITE_TO_UPLEVEL_INDEX, disposition == SQLiteStorageBase::OpenDisposition::ReadWrite && m_version != m_interface->GetVersion()); - SetDatabaseFilePath(target); - } - - SQLiteIndex::SQLiteIndex(const std::string& target, SQLiteIndex& source) : - SQLiteStorageBase(target, source) - { - m_dbconn.EnableICU(); - m_interface = Schema::CreateISQLiteIndex(m_version); - SetDatabaseFilePath(target); - } - - void SQLiteIndex::SetDatabaseFilePath(const std::string& target) - { - if (target != SQLITE_MEMORY_DB_CONNECTION_TARGET) - { - m_contextData.Add(Utility::ConvertToUTF16(target)); - } - } - -#ifndef AICLI_DISABLE_TEST_HOOKS - void SQLiteIndex::ForceVersion(const SQLite::Version& version) - { - m_interface = Schema::CreateISQLiteIndex(version); - } - - SQLite::Version SQLiteIndex::GetLatestVersion() - { - return Schema::CreateISQLiteIndex(SQLite::Version::Latest())->GetVersion(); - } - - const Schema::SQLiteIndexContextData& SQLiteIndex::GetContextData() const - { - return m_contextData; - } -#endif - - SQLiteIndex::IdType SQLiteIndex::AddManifest(const std::filesystem::path& manifestPath, const std::filesystem::path& relativePath) - { - AICLI_LOG(Repo, Verbose, << "Adding manifest from file [" << manifestPath << "]"); - - Manifest::Manifest manifest = Manifest::YamlParser::CreateFromPath(manifestPath); - return AddManifestInternal(manifest, relativePath); - } - - SQLiteIndex::IdType SQLiteIndex::AddManifest(const Manifest::Manifest& manifest, const std::filesystem::path& relativePath) - { - return AddManifestInternal(manifest, relativePath); - } - - SQLiteIndex::IdType SQLiteIndex::AddManifest(const Manifest::Manifest& manifest) - { - return AddManifestInternal(manifest, {}); - } - - SQLiteIndex::IdType SQLiteIndex::AddManifestInternal(const Manifest::Manifest& manifest, const std::optional& relativePath) - { - std::lock_guard lockInterface{ *m_interfaceLock }; - return AddManifestInternalHoldingLock(manifest, relativePath); - } - - SQLiteIndex::IdType SQLiteIndex::AddManifestInternalHoldingLock(const Manifest::Manifest& manifest, const std::optional& relativePath) - { - AICLI_LOG(Repo, Verbose, << "Adding manifest for [" << manifest.Id << ", " << manifest.Version << "] at relative path [" << relativePath.value_or("") << "]"); - - SQLite::Savepoint savepoint = SQLite::Savepoint::Create(m_dbconn, "sqliteindex_addmanifest"); - - IdType result = m_interface->AddManifest(m_dbconn, manifest, relativePath); - - SetLastWriteTime(); - - savepoint.Commit(); - - return result; - } - - bool SQLiteIndex::UpdateManifest(const std::filesystem::path& manifestPath, const std::filesystem::path& relativePath) - { - AICLI_LOG(Repo, Verbose, << "Updating manifest from file [" << manifestPath << "]"); - - Manifest::Manifest manifest = Manifest::YamlParser::CreateFromPath(manifestPath); - return UpdateManifestInternal(manifest, relativePath); - } - - bool SQLiteIndex::UpdateManifest(const Manifest::Manifest& manifest, const std::filesystem::path& relativePath) - { - return UpdateManifestInternal(manifest, relativePath); - } - - bool SQLiteIndex::UpdateManifest(const Manifest::Manifest& manifest) - { - return UpdateManifestInternal(manifest, {}); - } - - bool SQLiteIndex::UpdateManifestInternal(const Manifest::Manifest& manifest, const std::optional& relativePath) - { - std::lock_guard lockInterface{ *m_interfaceLock }; - return UpdateManifestInternalHoldingLock(manifest, relativePath); - } - - bool SQLiteIndex::UpdateManifestInternalHoldingLock(const Manifest::Manifest& manifest, const std::optional& relativePath) - { - AICLI_LOG(Repo, Verbose, << "Updating manifest for [" << manifest.Id << ", " << manifest.Version << "] at relative path [" << relativePath.value_or("") << "]"); - - SQLite::Savepoint savepoint = SQLite::Savepoint::Create(m_dbconn, "sqliteindex_updatemanifest"); - - bool result = m_interface->UpdateManifest(m_dbconn, manifest, relativePath).first; - - if (result) - { - SetLastWriteTime(); - - savepoint.Commit(); - } - - return result; - } - - bool SQLiteIndex::AddOrUpdateManifest(const std::filesystem::path& manifestPath, const std::filesystem::path& relativePath) - { - AICLI_LOG(Repo, Verbose, << "Adding or Updating manifest from file [" << manifestPath << "]"); - - Manifest::Manifest manifest = Manifest::YamlParser::CreateFromPath(manifestPath); - return AddOrUpdateManifestInternal(manifest, relativePath); - } - - bool SQLiteIndex::AddOrUpdateManifest(const Manifest::Manifest& manifest, const std::filesystem::path& relativePath) - { - return AddOrUpdateManifestInternal(manifest, relativePath); - } - - bool SQLiteIndex::AddOrUpdateManifest(const Manifest::Manifest& manifest) - { - return AddOrUpdateManifestInternal(manifest, {}); - } - - bool SQLiteIndex::AddOrUpdateManifestInternal(const Manifest::Manifest& manifest, const std::optional& relativePath) - { - std::lock_guard lockInterface{ *m_interfaceLock }; - AICLI_LOG(Repo, Verbose, << "Adding or Updating manifest for [" << manifest.Id << ", " << manifest.Version << "] at relative path [" << relativePath.value_or("") << "]"); - - if (m_interface->GetManifestIdByManifest(m_dbconn, manifest)) - { - UpdateManifestInternalHoldingLock(manifest, relativePath); - return false; - } - else - { - AddManifestInternalHoldingLock(manifest, relativePath); - return true; - } - } - - void SQLiteIndex::RemoveManifest(const std::filesystem::path& manifestPath, const std::filesystem::path& relativePath) - { - AICLI_LOG(Repo, Verbose, << "Removing manifest from file [" << manifestPath << "]"); - - Manifest::Manifest manifest = Manifest::YamlParser::CreateFromPath(manifestPath); - RemoveManifest(manifest, relativePath); - } - - void SQLiteIndex::RemoveManifest(const Manifest::Manifest& manifest, const std::filesystem::path& relativePath) - { - AICLI_LOG(Repo, Verbose, << "Removing manifest for [" << manifest.Id << ", " << manifest.Version << "] at relative path [" << relativePath << "]"); - RemoveManifest(manifest); - } - - void SQLiteIndex::RemoveManifest(const Manifest::Manifest& manifest) - { - std::lock_guard lockInterface{ *m_interfaceLock }; - SQLite::Savepoint savepoint = SQLite::Savepoint::Create(m_dbconn, "sqliteindex_removemanifest"); - - m_interface->RemoveManifest(m_dbconn, manifest); - - SetLastWriteTime(); - - savepoint.Commit(); - } - - void SQLiteIndex::RemoveManifestById(IdType manifestId) - { - SQLite::Savepoint savepoint = SQLite::Savepoint::Create(m_dbconn, "SQLiteIndex_RemoveManifestById"); - - m_interface->RemoveManifestById(m_dbconn, manifestId); - - SetLastWriteTime(); - - savepoint.Commit(); - } - - void SQLiteIndex::PrepareForPackaging() - { - std::lock_guard lockInterface{ *m_interfaceLock }; - AICLI_LOG(Repo, Info, << "Preparing index for packaging"); - - m_interface->PrepareForPackaging(Schema::SQLiteIndexContext{ m_dbconn, m_contextData }); - } - - bool SQLiteIndex::CheckConsistency(bool log) const - { - std::lock_guard lockInterface{ *m_interfaceLock }; - AICLI_LOG(Repo, Info, << "Checking index consistency..."); - - bool result = m_interface->CheckConsistency(m_dbconn, log); - - AICLI_LOG(Repo, Info, << "...index *WAS" << (result ? "*" : " NOT*") << " consistent."); - - return result; - } - - Schema::ISQLiteIndex::SearchResult SQLiteIndex::Search(const SearchRequest& request) const - { - std::lock_guard lockInterface{ *m_interfaceLock }; - AICLI_LOG(Repo, Verbose, << "Performing search: " << request.ToString()); - - return m_interface->Search(m_dbconn, request); - } - - std::optional SQLiteIndex::GetPropertyByPrimaryId(IdType primaryId, PackageVersionProperty property) const - { - std::lock_guard lockInterface{ *m_interfaceLock }; - return m_interface->GetPropertyByPrimaryId(m_dbconn, primaryId, property); - } - - std::vector SQLiteIndex::GetMultiPropertyByPrimaryId(IdType primaryId, PackageVersionMultiProperty property) const - { - std::lock_guard lockInterface{ *m_interfaceLock }; - return m_interface->GetMultiPropertyByPrimaryId(m_dbconn, primaryId, property); - } - - std::optional SQLiteIndex::GetManifestIdByKey(IdType id, std::string_view version, std::string_view channel) const - { - std::lock_guard lockInterface{ *m_interfaceLock }; - return m_interface->GetManifestIdByKey(m_dbconn, id, version, channel); - } - - std::optional SQLiteIndex::GetManifestIdByManifest(const Manifest::Manifest& manifest) const - { - return m_interface->GetManifestIdByManifest(m_dbconn, manifest); - } - - std::vector SQLiteIndex::GetVersionKeysById(IdType id) const - { - std::lock_guard lockInterface{ *m_interfaceLock }; - return m_interface->GetVersionKeysById(m_dbconn, id); - } - - SQLiteIndex::MetadataResult SQLiteIndex::GetMetadataByManifestId(SQLite::rowid_t manifestId) const - { - std::lock_guard lockInterface{ *m_interfaceLock }; - return m_interface->GetMetadataByManifestId(m_dbconn, manifestId); - } - - void SQLiteIndex::SetMetadataByManifestId(IdType manifestId, PackageVersionMetadata metadata, std::string_view value) - { - std::lock_guard lockInterface{ *m_interfaceLock }; - m_interface->SetMetadataByManifestId(m_dbconn, manifestId, metadata, value); - } - - Utility::NormalizedName SQLiteIndex::NormalizeName(std::string_view name, std::string_view publisher) const - { - std::lock_guard lockInterface{ *m_interfaceLock }; - return m_interface->NormalizeName(name, publisher); - } - - std::set> SQLiteIndex::GetDependenciesByManifestRowId(SQLite::rowid_t manifestRowId) const - { - return m_interface->GetDependenciesByManifestRowId(m_dbconn, manifestRowId); - } - - std::vector> SQLiteIndex::GetDependentsById(AppInstaller::Manifest::string_t packageId) const - { - return m_interface->GetDependentsById(m_dbconn, packageId); - } - - bool SQLiteIndex::MigrateTo(SQLite::Version version) - { - std::lock_guard lockInterface{ *m_interfaceLock }; - SQLite::Savepoint savepoint = SQLite::Savepoint::Create(m_dbconn, "sqliteindex_migrate_to"); - - AICLI_LOG(Repo, Info, << "Attempting to migrate index from [" << m_interface->GetVersion() << "] to [" << version << "]..."); - std::unique_ptr newInterface = Schema::CreateISQLiteIndex(version); - - bool result = newInterface->MigrateFrom(m_dbconn, m_interface.get()); - - AICLI_LOG(Repo, Info, << "...migration was " << (result ? "" : "NOT ") << "successful"); - if (result) - { - version.SetSchemaVersion(m_dbconn); - SetLastWriteTime(); - savepoint.Commit(); - - m_version = version; - m_interface = std::move(newInterface); - } - - return result; - } - - void SQLiteIndex::SetProperty(Property property, const std::string& value) - { - std::lock_guard lockInterface{ *m_interfaceLock }; - - switch (property) - { - case Property::PackageUpdateTrackingBaseTime: - m_interface->SetProperty(m_dbconn, Schema::Property::PackageUpdateTrackingBaseTime, value); - break; - case Property::IntermediateFileOutputPath: - { - std::filesystem::path pathValue{ Utility::ConvertToUTF16(value) }; - THROW_HR_IF(E_INVALIDARG, pathValue.empty() || pathValue.is_relative()); - m_contextData.Add(std::move(pathValue)); - } - break; - } - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "SQLiteIndex.h" +#include +#include "ArpVersionValidation.h" +#include + +namespace AppInstaller::Repository::Microsoft +{ + namespace + { + size_t GetPageSizeFromOptions(SQLiteIndex::CreateOptions options) + { + return WI_IsFlagSet(options, SQLiteIndex::CreateOptions::LargePageSize) ? 65536 : 0; + } + } + + SQLiteIndex SQLiteIndex::CreateNew(const std::string& filePath, SQLite::Version version, CreateOptions options) + { + AICLI_LOG(Repo, Info, << "Creating new SQLite Index with version [" << version << "] at '" << filePath << "'"); + SQLiteIndex result{ filePath, version, options }; + + SQLite::Savepoint savepoint = SQLite::Savepoint::Create(result.m_dbconn, "sqliteindex_createnew"); + + // Use calculated version, as incoming version could be 'latest' + result.m_version.SetSchemaVersion(result.m_dbconn); + + result.m_interface->CreateTables(result.m_dbconn, options); + + result.SetLastWriteTime(); + + savepoint.Commit(); + + return result; + } + + SQLiteIndex SQLiteIndex::Open(const std::string& filePath, OpenDisposition disposition, Utility::ManagedFile&& indexFile) + { + return { filePath, disposition, std::move(indexFile) }; + } + + SQLiteIndex SQLiteIndex::CopyFrom(const std::string& filePath, SQLiteIndex& source) + { + return { filePath, source }; + } + + SQLiteIndex SQLiteIndex::OpenWithBaseline(const std::string& deltaFilePath, const std::string& baselineFilePath, OpenDisposition disposition) + { + AICLI_LOG(Repo, Info, << "Opening delta index [" << deltaFilePath << "] with baseline [" << baselineFilePath << "]"); + + // The combined form is presented through views over a union of two databases, so there is + // nothing here that could be written back to. + THROW_HR_IF(E_INVALIDARG, disposition == OpenDisposition::ReadWrite); + + std::filesystem::path baselinePath{ Utility::ConvertToUTF16(baselineFilePath) }; + THROW_HR_IF(E_INVALIDARG, baselinePath.empty() || baselinePath.is_relative()); + + SQLiteIndex result{ SQLite::DatabaseSpecifier{ deltaFilePath, disposition }, {} }; + + // The interface for the delta's schema version establishes the combined view. + result.m_interface->SetupDeltaReadMode(result.m_dbconn, SQLite::DatabaseSpecifier{ baselineFilePath, disposition }); + + return result; + } + + SQLiteIndex::SQLiteIndex(const std::string& target, const SQLite::Version& version, CreateOptions options) : SQLiteStorageBase(target, version, GetPageSizeFromOptions(options)) + { + m_dbconn.EnableICU(); + m_interface = Schema::CreateISQLiteIndex(version); + m_version = m_interface->GetVersion(); + SetDatabaseFilePath(target); + } + + SQLiteIndex::SQLiteIndex(const std::string& target, SQLiteStorageBase::OpenDisposition disposition, Utility::ManagedFile&& indexFile) : + SQLiteIndex(SQLite::DatabaseSpecifier{ target, disposition }, std::move(indexFile)) + { + } + + SQLiteIndex::SQLiteIndex(const SQLite::DatabaseSpecifier& specifier, Utility::ManagedFile&& indexFile) : + SQLiteStorageBase(specifier, std::move(indexFile)) + { + m_dbconn.EnableICU(); + AICLI_LOG(Repo, Info, << "Opened SQLite Index with version [" << m_version << "], last write [" << GetLastWriteTime() << "]"); + m_interface = Schema::CreateISQLiteIndex(m_version); + THROW_HR_IF(APPINSTALLER_CLI_ERROR_CANNOT_WRITE_TO_UPLEVEL_INDEX, specifier.Disposition() == SQLiteStorageBase::OpenDisposition::ReadWrite && m_version != m_interface->GetVersion()); + SetDatabaseFilePath(specifier.Path()); + } + + SQLiteIndex::SQLiteIndex(const std::string& target, SQLiteIndex& source) : + SQLiteStorageBase(target, source) + { + m_dbconn.EnableICU(); + m_interface = Schema::CreateISQLiteIndex(m_version); + SetDatabaseFilePath(target); + } + + void SQLiteIndex::SetDatabaseFilePath(const std::string& target) + { + if (target != SQLITE_MEMORY_DB_CONNECTION_TARGET) + { + m_contextData.Add(Utility::ConvertToUTF16(target)); + } + } + +#ifndef AICLI_DISABLE_TEST_HOOKS + void SQLiteIndex::ForceVersion(const SQLite::Version& version) + { + m_interface = Schema::CreateISQLiteIndex(version); + } + + SQLite::Version SQLiteIndex::GetLatestVersion() + { + return Schema::CreateISQLiteIndex(SQLite::Version::Latest())->GetVersion(); + } + + const Schema::SQLiteIndexContextData& SQLiteIndex::GetContextData() const + { + return m_contextData; + } +#endif + + SQLiteIndex::IdType SQLiteIndex::AddManifest(const std::filesystem::path& manifestPath, const std::filesystem::path& relativePath) + { + AICLI_LOG(Repo, Verbose, << "Adding manifest from file [" << manifestPath << "]"); + + Manifest::Manifest manifest = Manifest::YamlParser::CreateFromPath(manifestPath); + return AddManifestInternal(manifest, relativePath); + } + + SQLiteIndex::IdType SQLiteIndex::AddManifest(const Manifest::Manifest& manifest, const std::filesystem::path& relativePath) + { + return AddManifestInternal(manifest, relativePath); + } + + SQLiteIndex::IdType SQLiteIndex::AddManifest(const Manifest::Manifest& manifest) + { + return AddManifestInternal(manifest, {}); + } + + SQLiteIndex::IdType SQLiteIndex::AddManifestInternal(const Manifest::Manifest& manifest, const std::optional& relativePath) + { + std::lock_guard lockInterface{ *m_interfaceLock }; + return AddManifestInternalHoldingLock(manifest, relativePath); + } + + SQLiteIndex::IdType SQLiteIndex::AddManifestInternalHoldingLock(const Manifest::Manifest& manifest, const std::optional& relativePath) + { + AICLI_LOG(Repo, Verbose, << "Adding manifest for [" << manifest.Id << ", " << manifest.Version << "] at relative path [" << relativePath.value_or("") << "]"); + + SQLite::Savepoint savepoint = SQLite::Savepoint::Create(m_dbconn, "sqliteindex_addmanifest"); + + IdType result = m_interface->AddManifest(m_dbconn, manifest, relativePath); + + SetLastWriteTime(); + + savepoint.Commit(); + + return result; + } + + bool SQLiteIndex::UpdateManifest(const std::filesystem::path& manifestPath, const std::filesystem::path& relativePath) + { + AICLI_LOG(Repo, Verbose, << "Updating manifest from file [" << manifestPath << "]"); + + Manifest::Manifest manifest = Manifest::YamlParser::CreateFromPath(manifestPath); + return UpdateManifestInternal(manifest, relativePath); + } + + bool SQLiteIndex::UpdateManifest(const Manifest::Manifest& manifest, const std::filesystem::path& relativePath) + { + return UpdateManifestInternal(manifest, relativePath); + } + + bool SQLiteIndex::UpdateManifest(const Manifest::Manifest& manifest) + { + return UpdateManifestInternal(manifest, {}); + } + + bool SQLiteIndex::UpdateManifestInternal(const Manifest::Manifest& manifest, const std::optional& relativePath) + { + std::lock_guard lockInterface{ *m_interfaceLock }; + return UpdateManifestInternalHoldingLock(manifest, relativePath); + } + + bool SQLiteIndex::UpdateManifestInternalHoldingLock(const Manifest::Manifest& manifest, const std::optional& relativePath) + { + AICLI_LOG(Repo, Verbose, << "Updating manifest for [" << manifest.Id << ", " << manifest.Version << "] at relative path [" << relativePath.value_or("") << "]"); + + SQLite::Savepoint savepoint = SQLite::Savepoint::Create(m_dbconn, "sqliteindex_updatemanifest"); + + bool result = m_interface->UpdateManifest(m_dbconn, manifest, relativePath).first; + + if (result) + { + SetLastWriteTime(); + + savepoint.Commit(); + } + + return result; + } + + bool SQLiteIndex::AddOrUpdateManifest(const std::filesystem::path& manifestPath, const std::filesystem::path& relativePath) + { + AICLI_LOG(Repo, Verbose, << "Adding or Updating manifest from file [" << manifestPath << "]"); + + Manifest::Manifest manifest = Manifest::YamlParser::CreateFromPath(manifestPath); + return AddOrUpdateManifestInternal(manifest, relativePath); + } + + bool SQLiteIndex::AddOrUpdateManifest(const Manifest::Manifest& manifest, const std::filesystem::path& relativePath) + { + return AddOrUpdateManifestInternal(manifest, relativePath); + } + + bool SQLiteIndex::AddOrUpdateManifest(const Manifest::Manifest& manifest) + { + return AddOrUpdateManifestInternal(manifest, {}); + } + + bool SQLiteIndex::AddOrUpdateManifestInternal(const Manifest::Manifest& manifest, const std::optional& relativePath) + { + std::lock_guard lockInterface{ *m_interfaceLock }; + AICLI_LOG(Repo, Verbose, << "Adding or Updating manifest for [" << manifest.Id << ", " << manifest.Version << "] at relative path [" << relativePath.value_or("") << "]"); + + if (m_interface->GetManifestIdByManifest(m_dbconn, manifest)) + { + UpdateManifestInternalHoldingLock(manifest, relativePath); + return false; + } + else + { + AddManifestInternalHoldingLock(manifest, relativePath); + return true; + } + } + + void SQLiteIndex::RemoveManifest(const std::filesystem::path& manifestPath, const std::filesystem::path& relativePath) + { + AICLI_LOG(Repo, Verbose, << "Removing manifest from file [" << manifestPath << "]"); + + Manifest::Manifest manifest = Manifest::YamlParser::CreateFromPath(manifestPath); + RemoveManifest(manifest, relativePath); + } + + void SQLiteIndex::RemoveManifest(const Manifest::Manifest& manifest, const std::filesystem::path& relativePath) + { + AICLI_LOG(Repo, Verbose, << "Removing manifest for [" << manifest.Id << ", " << manifest.Version << "] at relative path [" << relativePath << "]"); + RemoveManifest(manifest); + } + + void SQLiteIndex::RemoveManifest(const Manifest::Manifest& manifest) + { + std::lock_guard lockInterface{ *m_interfaceLock }; + SQLite::Savepoint savepoint = SQLite::Savepoint::Create(m_dbconn, "sqliteindex_removemanifest"); + + m_interface->RemoveManifest(m_dbconn, manifest); + + SetLastWriteTime(); + + savepoint.Commit(); + } + + void SQLiteIndex::RemoveManifestById(IdType manifestId) + { + SQLite::Savepoint savepoint = SQLite::Savepoint::Create(m_dbconn, "SQLiteIndex_RemoveManifestById"); + + m_interface->RemoveManifestById(m_dbconn, manifestId); + + SetLastWriteTime(); + + savepoint.Commit(); + } + + void SQLiteIndex::PrepareForPackaging() + { + std::lock_guard lockInterface{ *m_interfaceLock }; + AICLI_LOG(Repo, Info, << "Preparing index for packaging"); + + m_interface->PrepareForPackaging(Schema::SQLiteIndexContext{ m_dbconn, m_contextData }); + } + + void SQLiteIndex::MarkAsBaseline() + { + std::lock_guard lockInterface{ *m_interfaceLock }; + AICLI_LOG(Repo, Info, << "Marking index as a delta baseline"); + + SQLite::Savepoint savepoint = SQLite::Savepoint::Create(m_dbconn, "sqliteindex_mark_as_baseline"); + + m_interface->MarkAsBaseline(m_dbconn); + + SetLastWriteTime(); + + savepoint.Commit(); + } + + bool SQLiteIndex::CheckConsistency(bool log) const + { + std::lock_guard lockInterface{ *m_interfaceLock }; + AICLI_LOG(Repo, Info, << "Checking index consistency..."); + + bool result = m_interface->CheckConsistency(m_dbconn, log); + + AICLI_LOG(Repo, Info, << "...index *WAS" << (result ? "*" : " NOT*") << " consistent."); + + return result; + } + + Schema::ISQLiteIndex::SearchResult SQLiteIndex::Search(const SearchRequest& request) const + { + std::lock_guard lockInterface{ *m_interfaceLock }; + AICLI_LOG(Repo, Verbose, << "Performing search: " << request.ToString()); + + return m_interface->Search(m_dbconn, request); + } + + std::optional SQLiteIndex::GetPropertyByPrimaryId(IdType primaryId, PackageVersionProperty property) const + { + std::lock_guard lockInterface{ *m_interfaceLock }; + return m_interface->GetPropertyByPrimaryId(m_dbconn, primaryId, property); + } + + std::vector SQLiteIndex::GetMultiPropertyByPrimaryId(IdType primaryId, PackageVersionMultiProperty property) const + { + std::lock_guard lockInterface{ *m_interfaceLock }; + return m_interface->GetMultiPropertyByPrimaryId(m_dbconn, primaryId, property); + } + + std::optional SQLiteIndex::GetManifestIdByKey(IdType id, std::string_view version, std::string_view channel) const + { + std::lock_guard lockInterface{ *m_interfaceLock }; + return m_interface->GetManifestIdByKey(m_dbconn, id, version, channel); + } + + std::optional SQLiteIndex::GetManifestIdByManifest(const Manifest::Manifest& manifest) const + { + return m_interface->GetManifestIdByManifest(m_dbconn, manifest); + } + + std::vector SQLiteIndex::GetVersionKeysById(IdType id) const + { + std::lock_guard lockInterface{ *m_interfaceLock }; + return m_interface->GetVersionKeysById(m_dbconn, id); + } + + SQLiteIndex::MetadataResult SQLiteIndex::GetMetadataByManifestId(SQLite::rowid_t manifestId) const + { + std::lock_guard lockInterface{ *m_interfaceLock }; + return m_interface->GetMetadataByManifestId(m_dbconn, manifestId); + } + + void SQLiteIndex::SetMetadataByManifestId(IdType manifestId, PackageVersionMetadata metadata, std::string_view value) + { + std::lock_guard lockInterface{ *m_interfaceLock }; + m_interface->SetMetadataByManifestId(m_dbconn, manifestId, metadata, value); + } + + Utility::NormalizedName SQLiteIndex::NormalizeName(std::string_view name, std::string_view publisher) const + { + std::lock_guard lockInterface{ *m_interfaceLock }; + return m_interface->NormalizeName(name, publisher); + } + + std::set> SQLiteIndex::GetDependenciesByManifestRowId(SQLite::rowid_t manifestRowId) const + { + return m_interface->GetDependenciesByManifestRowId(m_dbconn, manifestRowId); + } + + std::vector> SQLiteIndex::GetDependentsById(AppInstaller::Manifest::string_t packageId) const + { + return m_interface->GetDependentsById(m_dbconn, packageId); + } + + bool SQLiteIndex::MigrateTo(SQLite::Version version) + { + std::lock_guard lockInterface{ *m_interfaceLock }; + SQLite::Savepoint savepoint = SQLite::Savepoint::Create(m_dbconn, "sqliteindex_migrate_to"); + + AICLI_LOG(Repo, Info, << "Attempting to migrate index from [" << m_interface->GetVersion() << "] to [" << version << "]..."); + std::unique_ptr newInterface = Schema::CreateISQLiteIndex(version); + + bool result = newInterface->MigrateFrom(m_dbconn, m_interface.get()); + + AICLI_LOG(Repo, Info, << "...migration was " << (result ? "" : "NOT ") << "successful"); + if (result) + { + version.SetSchemaVersion(m_dbconn); + SetLastWriteTime(); + savepoint.Commit(); + + m_version = version; + m_interface = std::move(newInterface); + } + + return result; + } + + void SQLiteIndex::SetProperty(Property property, const std::string& value) + { + std::lock_guard lockInterface{ *m_interfaceLock }; + + switch (property) + { + case Property::PackageUpdateTrackingBaseTime: + m_interface->SetProperty(m_dbconn, Schema::Property::PackageUpdateTrackingBaseTime, value); + break; + case Property::IntermediateFileOutputPath: + { + std::filesystem::path pathValue{ Utility::ConvertToUTF16(value) }; + THROW_HR_IF(E_INVALIDARG, pathValue.empty() || pathValue.is_relative()); + m_contextData.Add(std::move(pathValue)); + } + break; + case Property::DeltaBaselineIndexPath: + { + std::filesystem::path pathValue{ Utility::ConvertToUTF16(value) }; + THROW_HR_IF(E_INVALIDARG, pathValue.empty() || pathValue.is_relative()); + m_contextData.Add(std::move(pathValue)); + } + break; + case Property::DeltaOutputPath: + { + std::filesystem::path pathValue{ Utility::ConvertToUTF16(value) }; + THROW_HR_IF(E_INVALIDARG, pathValue.empty() || pathValue.is_relative()); + m_contextData.Add(std::move(pathValue)); + } + break; + } + } +} diff --git a/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndex.h b/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndex.h index be63be9632..8cb8842e93 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndex.h +++ b/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndex.h @@ -55,6 +55,12 @@ namespace AppInstaller::Repository::Microsoft // Creates a copy of the given index. static SQLiteIndex CopyFrom(const std::string& filePath, SQLiteIndex& source); + // Opens a delta index combined with its baseline for reading. + // The delta is the main connection; the baseline is attached and temp views are created + // so that existing search code operates transparently across both. + // The disposition applies to both files, because the pair is only meaningful as a unit. + static SQLiteIndex OpenWithBaseline(const std::string& deltaFilePath, const std::string& baselineFilePath, OpenDisposition disposition = OpenDisposition::Read); + #ifndef AICLI_DISABLE_TEST_HOOKS // Changes the version of the interface being used to operate on the database. // Should only be used for testing. @@ -121,6 +127,11 @@ namespace AppInstaller::Repository::Microsoft // Removes data that is no longer needed for an index that is to be published. void PrepareForPackaging(); + // Designates this index as a baseline that delta indexes may be generated against. + // Should be called on an index that has been prepared for packaging, as that is the form + // that will be published and that a delta will later be paired with. + void MarkAsBaseline(); + // Checks the consistency of the index to ensure that every referenced row exists. // Returns true if index is consistent; false if it is not. bool CheckConsistency(bool log = false) const; @@ -167,6 +178,8 @@ namespace AppInstaller::Repository::Microsoft { PackageUpdateTrackingBaseTime, IntermediateFileOutputPath, + DeltaBaselineIndexPath, + DeltaOutputPath, }; // Sets the given property. @@ -180,6 +193,8 @@ namespace AppInstaller::Repository::Microsoft // Constructor used to open an existing index. SQLiteIndex(const std::string& target, SQLiteStorageBase::OpenDisposition disposition, Utility::ManagedFile&& indexFile); + SQLiteIndex(const SQLite::DatabaseSpecifier& specifier, Utility::ManagedFile&& indexFile); + // Constructor used to copy the given index. SQLiteIndex(const std::string& target, SQLiteIndex& source); diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/Interface.h b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/Interface.h index 361fc28e8d..f74a86bf5b 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/Interface.h +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/Interface.h @@ -4,6 +4,7 @@ #include "Microsoft/Schema/ISQLiteIndex.h" #include "Microsoft/Schema/2_0/SearchResultsTable.h" #include "Microsoft/Schema/2_0/OneToManyTableWithMap.h" +#include "Microsoft/Schema/2_0/PackageUpdateTrackingTable.h" #include #include @@ -74,6 +75,12 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 // Prepares for packaging, optionally vacuuming the database. virtual void PrepareForPackaging(const SQLiteIndexContext& context, bool vacuum); + // Extends PrepareForPackaging at the point where the 2.0 tables have been populated but + // the update tracking and internal 1.7 tables have not yet been dropped. That is the only + // window in which both the packaged and the pre-packaged forms of the data exist, so any + // output that must correlate the two has to be produced here. Does nothing by default. + virtual void CreateAdditionalPackagingOutput(const SQLiteIndexContext& context); + // Force the database to shrink the file size. // This *must* be done outside of an active transaction. void Vacuum(const SQLite::Connection& connection); @@ -89,6 +96,13 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 // If EnsureInternalInterface has been called. mutable bool m_internalInterfaceChecked = false; + // Determines how the removal of a package is recorded in the update tracking table. + PackageUpdateTrackingTable::RemovalBehavior m_trackingRemovalBehavior = PackageUpdateTrackingTable::RemovalBehavior::Delete; + + // Set when the tables that this interface reads are the merged views over a delta and its + // baseline rather than tables of this database. + mutable bool m_isDeltaReadMode = false; + // Interface to the data before PrepareForPackaging is called. mutable std::unique_ptr m_internalInterface; diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/Interface_2_0.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/Interface_2_0.cpp index 99c25fe319..c3974e5b7b 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/Interface_2_0.cpp +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/Interface_2_0.cpp @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include "pch.h" #include +#include #include "Microsoft/Schema/2_0/Interface.h" #include "Microsoft/Schema/2_0/PackagesTable.h" @@ -16,6 +17,7 @@ #include "Microsoft/Schema/2_0/SearchResultsTable.h" #include "Microsoft/Schema/2_0/PackageUpdateTrackingTable.h" +#include "Microsoft/Schema/1_0/IdTable.h" #include @@ -105,7 +107,7 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 { EnsureInternalInterface(connection, true); SQLite::rowid_t manifestId = m_internalInterface->AddManifest(connection, manifest, relativePath); - PackageUpdateTrackingTable::Update(connection, m_internalInterface.get(), m_internalInterface->GetPropertyByPrimaryId(connection, manifestId, PackageVersionProperty::Id).value()); + PackageUpdateTrackingTable::Update(connection, m_internalInterface.get(), m_internalInterface->GetPropertyByPrimaryId(connection, manifestId, PackageVersionProperty::Id).value(), m_trackingRemovalBehavior); return manifestId; } @@ -115,7 +117,7 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 std::pair result = m_internalInterface->UpdateManifest(connection, manifest, relativePath); if (result.first) { - PackageUpdateTrackingTable::Update(connection, m_internalInterface.get(), m_internalInterface->GetPropertyByPrimaryId(connection, result.second, PackageVersionProperty::Id).value()); + PackageUpdateTrackingTable::Update(connection, m_internalInterface.get(), m_internalInterface->GetPropertyByPrimaryId(connection, result.second, PackageVersionProperty::Id).value(), m_trackingRemovalBehavior); } return result; } @@ -138,10 +140,21 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 { EnsureInternalInterface(connection, true); std::optional identifier = m_internalInterface->GetPropertyByPrimaryId(connection, manifestId, PackageVersionProperty::Id); + + // Resolve the rowid the package occupies while it is still present. If this removes its + // last version the ids row goes with it, and the value becomes unrecoverable; the tracking + // table needs it in order to record which rowid was vacated. + std::optional packageRowId; + + if (identifier && m_trackingRemovalBehavior == PackageUpdateTrackingTable::RemovalBehavior::Record) + { + packageRowId = V1_0::IdTable::SelectIdByValue(connection, identifier.value(), true); + } + m_internalInterface->RemoveManifestById(connection, manifestId); if (identifier) { - PackageUpdateTrackingTable::Update(connection, m_internalInterface.get(), identifier.value()); + PackageUpdateTrackingTable::Update(connection, m_internalInterface.get(), identifier.value(), m_trackingRemovalBehavior, true, packageRowId); } } @@ -172,7 +185,7 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 if (m_internalInterface) { AICLI_CHECK_CONSISTENCY(m_internalInterface->CheckConsistency(connection, log)); - AICLI_CHECK_CONSISTENCY(PackageUpdateTrackingTable::CheckConsistency(connection, m_internalInterface.get(), log)); + AICLI_CHECK_CONSISTENCY(PackageUpdateTrackingTable::CheckConsistency(connection, m_internalInterface.get(), m_trackingRemovalBehavior, log)); return result; } @@ -396,14 +409,14 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 SQLite::Savepoint savepoint = SQLite::Savepoint::Create(connection, "migrate_from_v2_0"); // We only need to insert all of the existing packages into the update tracking table. - PackageUpdateTrackingTable::EnsureExists(connection); + PackageUpdateTrackingTable::EnsureExists(connection, m_trackingRemovalBehavior); SearchResult allPackages = current->Search(connection, {}); for (const auto& packageMatch : allPackages.Matches) { std::vector versionKeys = current->GetVersionKeysById(connection, packageMatch.first); ISQLiteIndex::VersionKey& latestVersionKey = versionKeys[0]; - PackageUpdateTrackingTable::Update(connection, current, current->GetPropertyByPrimaryId(connection, latestVersionKey.ManifestId, PackageVersionProperty::Id).value(), false); + PackageUpdateTrackingTable::Update(connection, current, current->GetPropertyByPrimaryId(connection, latestVersionKey.ManifestId, PackageVersionProperty::Id).value(), m_trackingRemovalBehavior, false); } savepoint.Commit(); @@ -434,6 +447,10 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 } } + void Interface::CreateAdditionalPackagingOutput(const SQLiteIndexContext&) + { + } + std::unique_ptr Interface::CreateSearchResultsTable(const SQLite::Connection& connection) const { return std::make_unique(connection); @@ -644,7 +661,7 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 THROW_WIN32_IF(ERROR_INVALID_STATE, baseOutputDirectory.empty() || baseOutputDirectory.is_relative()); // Output all of the changed package version manifests since the base time to the target location - for (const auto& packageData : PackageUpdateTrackingTable::GetUpdatesSince(connection, updateBaseTime)) + for (const auto& packageData : PackageUpdateTrackingTable::GetUpdatesSince(connection, updateBaseTime, m_trackingRemovalBehavior)) { std::filesystem::path packageDirectory = baseOutputDirectory / Manifest::PackageVersionDataManifest::GetRelativeDirectoryPath(packageData.PackageIdentifier, Utility::SHA256::ConvertToString(packageData.Hash)); @@ -712,9 +729,12 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 addIfPresent(PackagesTable::ARPMinVersionColumn::Name, m_internalInterface->GetPropertyByPrimaryId(connection, latestVersionKey.ManifestId, PackageVersionProperty::ArpMinVersion).value()); addIfPresent(PackagesTable::ARPMaxVersionColumn::Name, m_internalInterface->GetPropertyByPrimaryId(connection, latestVersionKey.ManifestId, PackageVersionProperty::ArpMaxVersion).value()); - SQLite::rowid_t packageId = PackagesTable::Insert(connection, packageData); + auto idRowId = V1_0::IdTable::SelectIdByValue(connection, packageIdentifier); + THROW_HR_IF(E_NOT_VALID_STATE, !idRowId); - PackagesTable::UpdateValueIdById(connection, packageId, PackageUpdateTrackingTable::GetDataHash(connection, packageIdentifier)); + SQLite::rowid_t packageId = PackagesTable::Insert(connection, packageData, idRowId); + + PackagesTable::UpdateValueIdById(connection, packageId, PackageUpdateTrackingTable::GetDataHash(connection, packageIdentifier, m_trackingRemovalBehavior)); for (const auto& versionKey : versionKeys) { @@ -729,6 +749,8 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 } } + CreateAdditionalPackagingOutput(context); + PackagesTable::PrepareForPackaging< PackagesTable::IdColumn, PackagesTable::NameColumn, @@ -770,7 +792,8 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 { if (!m_internalInterfaceChecked) { - if (!PackagesTable::Exists(connection)) + // In delta read mode the TEMP VIEWs are already set up; no internal interface needed. + if (!m_isDeltaReadMode && !PackagesTable::Exists(connection)) { m_internalInterface = CreateInternalInterface(); } diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/OneToManyTableWithMap.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/OneToManyTableWithMap.cpp index 76f650db82..2739282285 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/OneToManyTableWithMap.cpp +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/OneToManyTableWithMap.cpp @@ -18,6 +18,18 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 static constexpr std::string_view s_OneToManyTableWithMap_MapTable_IndexSuffix = "_index"sv; static constexpr std::string_view s_OneToManyTableWithMap_PrimaryKeyIndexSuffix = "_pkindex"sv; + std::string_view OneToManyTableGetManifestColumnName() + { + return s_OneToManyTableWithMap_MapTable_PrimaryName; + } + + std::string OneToManyTableGetMapTableName(std::string_view tableName) + { + std::string result{ tableName }; + result += s_OneToManyTableWithMap_MapTable_Suffix; + return result; + } + namespace anon { // Create the mapping table insert statement for multiple use. diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackageUpdateTrackingTable.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackageUpdateTrackingTable.cpp index cf253fd8a1..3a88d5ecd7 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackageUpdateTrackingTable.cpp +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackageUpdateTrackingTable.cpp @@ -2,8 +2,10 @@ // Licensed under the MIT License. #include "pch.h" #include "PackageUpdateTrackingTable.h" +#include "Microsoft/Schema/1_0/IdTable.h" #include #include +#include using namespace AppInstaller::SQLite; @@ -12,28 +14,191 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 using namespace std::string_view_literals; static constexpr std::string_view s_PUTT_Table_Name = "update_tracking"sv; static constexpr std::string_view s_PUTT_WriteTimeIndex_Name = "update_tracking_write_idx"sv; + static constexpr std::string_view s_PUTT_LiveIndex_Name = "update_tracking_live_idx"sv; static constexpr std::string_view s_PUTT_Package = "package"sv; static constexpr std::string_view s_PUTT_WriteTime = "write_time"sv; static constexpr std::string_view s_PUTT_Manifest = "manifest"sv; static constexpr std::string_view s_PUTT_Hash = "hash"sv; + static constexpr std::string_view s_PUTT_IsRemoved = "is_removed"sv; + static constexpr std::string_view s_PUTT_PackageRowId = "package_rowid"sv; + static constexpr std::string_view s_PUTT_ChangeSequence = "change_seq"sv; + static constexpr std::string_view s_PUTT_ChangeSequenceIndex_Name = "update_tracking_change_seq_idx"sv; + + namespace + { + // Finds the rowid that the package occupies in the index. + std::optional GetPackageRowIdInIndex(const SQLite::Connection& connection, const std::string& packageIdentifier) + { + return V1_0::IdTable::SelectIdByValue(connection, packageIdentifier, true); + } + + // The tombstones, as the identifier recorded for the package and the rowid it vacated. + std::vector> GetRemovedRows( + const SQLite::Connection& connection, + PackageUpdateTrackingTable::RemovalBehavior removals) + { + std::vector> result; + + if (removals == PackageUpdateTrackingTable::RemovalBehavior::Delete) + { + return result; + } + + Builder::StatementBuilder builder; + builder.Select({ s_PUTT_Package, s_PUTT_PackageRowId }).From(s_PUTT_Table_Name). + Where(s_PUTT_IsRemoved).Equals(1); + + Statement statement = builder.Prepare(connection); + + while (statement.Step()) + { + result.emplace_back(statement.GetColumn(0), statement.GetColumn(1)); + } + + return result; + } + + // The sequence to stamp on the row about to be written. + int64_t GetNextChangeSequence(const SQLite::Connection& connection) + { + Builder::StatementBuilder builder; + builder.Select().Column(Builder::Aggregate::Max, s_PUTT_ChangeSequence).From(s_PUTT_Table_Name); + + Statement statement = builder.Prepare(connection); + + // The aggregate produces a single row holding null when the table is empty. + if (statement.Step() && !statement.GetColumnIsNull(0)) + { + return statement.GetColumn(0) + 1; + } + + return 1; + } + + // The rows written after the given point, as measured by the given column. + // The boundary is exclusive for the sequence and inclusive for the write time. + std::vector GetUpdates( + const SQLite::Connection& connection, + std::string_view boundaryColumn, + int64_t boundaryValue, + bool exclusive, + PackageUpdateTrackingTable::RemovalBehavior removals) + { + bool recordingRemovals = (removals == PackageUpdateTrackingTable::RemovalBehavior::Record); + + Builder::StatementBuilder builder; + + if (recordingRemovals) + { + builder.Select({ RowIDName, s_PUTT_Package, s_PUTT_WriteTime, s_PUTT_Manifest, s_PUTT_Hash, s_PUTT_PackageRowId }); + } + else + { + builder.Select({ RowIDName, s_PUTT_Package, s_PUTT_WriteTime, s_PUTT_Manifest, s_PUTT_Hash }); + } + + builder.From(s_PUTT_Table_Name).Where(boundaryColumn); + + if (exclusive) + { + builder.IsGreaterThan(boundaryValue); + } + else + { + builder.IsGreaterThanOrEqualTo(boundaryValue); + } + + if (recordingRemovals) + { + // Removals are reported separately, so that this remains the set of packages that + // have data to write out, exactly as it is when removals delete their row. + builder.And(s_PUTT_IsRemoved).Equals(0); + } + + Statement select = builder.Prepare(connection); + + std::vector result; + + while (select.Step()) + { + PackageUpdateTrackingTable::PackageData item; + item.RowID = select.GetColumn(0); + item.PackageIdentifier = select.GetColumn(1); + item.WriteTime = select.GetColumn(2); + item.Manifest = select.GetColumn(3); + item.Hash = select.GetColumn(4); + + if (recordingRemovals) + { + item.PackageRowId = select.GetColumn(5); + } + + result.emplace_back(std::move(item)); + } + + return result; + } + + // The rowids vacated after the given point, as measured by the given column. + std::set GetRemovals( + const SQLite::Connection& connection, + std::string_view boundaryColumn, + int64_t boundaryValue, + bool exclusive) + { + Builder::StatementBuilder builder; + builder.Select(s_PUTT_PackageRowId).From(s_PUTT_Table_Name).Where(boundaryColumn); + + if (exclusive) + { + builder.IsGreaterThan(boundaryValue); + } + else + { + builder.IsGreaterThanOrEqualTo(boundaryValue); + } + + builder.And(s_PUTT_IsRemoved).Equals(1); + + Statement select = builder.Prepare(connection); + + std::set result; + + while (select.Step()) + { + result.emplace(select.GetColumn(0)); + } + + return result; + } + } std::string_view PackageUpdateTrackingTable::TableName() { return s_PUTT_Table_Name; } - void PackageUpdateTrackingTable::Create(SQLite::Connection& connection) + void PackageUpdateTrackingTable::Create(SQLite::Connection& connection, RemovalBehavior removals) { using namespace Builder; + bool recordingRemovals = (removals == RemovalBehavior::Record); + StatementBuilder builder; builder.CreateTable(s_PUTT_Table_Name).BeginColumns(); builder.Column(IntegerPrimaryKey()); builder.Column(ColumnBuilder(s_PUTT_Package, Type::Text).NotNull()); builder.Column(ColumnBuilder(s_PUTT_WriteTime, Type::Int64).NotNull()); - builder.Column(ColumnBuilder(s_PUTT_Manifest, Type::Blob).NotNull()); - builder.Column(ColumnBuilder(s_PUTT_Hash, Type::Blob).NotNull()); + builder.Column(ColumnBuilder(s_PUTT_Manifest, Type::Blob).NotNull(!recordingRemovals)); + builder.Column(ColumnBuilder(s_PUTT_Hash, Type::Blob).NotNull(!recordingRemovals)); + + if (recordingRemovals) + { + builder.Column(ColumnBuilder(s_PUTT_IsRemoved, Type::Int64).NotNull().Default(0)); + builder.Column(ColumnBuilder(s_PUTT_PackageRowId, Type::Int64).NotNull()); + builder.Column(ColumnBuilder(s_PUTT_ChangeSequence, Type::Int64).NotNull()); + } builder.EndColumns(); @@ -42,13 +207,48 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 StatementBuilder indexBuilder; indexBuilder.CreateIndex(s_PUTT_WriteTimeIndex_Name).On(s_PUTT_Table_Name).Columns(s_PUTT_WriteTime); indexBuilder.Execute(connection); + + if (recordingRemovals) + { + CreateLiveRowIndex(connection); + CreateChangeSequenceIndex(connection); + } + } + + void PackageUpdateTrackingTable::CreateChangeSequenceIndex(SQLite::Connection& connection) + { + // Serves both the range scan that reports changes and the maximum that allocates the next + // sequence. Not unique: a row updated in place takes a new sequence and leaves none behind, + // but nothing depends on two rows never sharing one, and the migration backfills every + // existing row with the same value. + Builder::StatementBuilder builder; + builder.CreateIndex(s_PUTT_ChangeSequenceIndex_Name).On(s_PUTT_Table_Name).Columns(s_PUTT_ChangeSequence); + builder.Execute(connection); + } + + void PackageUpdateTrackingTable::CreateLiveRowIndex(SQLite::Connection& connection) + { + // A package occupies exactly one rowid at a time, so at most one row per rowid can be + // live. Tombstones are excluded because a rowid vacated by one package can be taken by + // another, leaving the old package's tombstone and the new package's live row sharing it. + // + // The constraint is deliberately on the rowid rather than the identifier: a unique index + // cannot use LIKE, and no available collation matches it. NOCASE is ASCII-only, while + // LIKE here is the ICU implementation registered by sqlite3IcuInit, so NOCASE would + // disagree with every other accessor on non-ASCII identifiers. An ICU collation cannot be + // used either, since it would bake the ICU version into a published index file and make + // the table unreadable to any connection that had not registered it. + Builder::StatementBuilder builder; + builder.CreateUniqueIndex(s_PUTT_LiveIndex_Name).On(s_PUTT_Table_Name).Columns(s_PUTT_PackageRowId). + Where(s_PUTT_IsRemoved).EqualsLiteral(0); + builder.Execute(connection); } - void PackageUpdateTrackingTable::EnsureExists(SQLite::Connection& connection) + void PackageUpdateTrackingTable::EnsureExists(SQLite::Connection& connection, RemovalBehavior removals) { if (!Exists(connection)) { - Create(connection); + Create(connection, removals); } } @@ -71,11 +271,11 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 return statement.GetColumn(0) != 0; } - void PackageUpdateTrackingTable::Update(SQLite::Connection& connection, const ISQLiteIndex* internalIndex, const std::string& packageIdentifier, bool ensureTable) + void PackageUpdateTrackingTable::Update(SQLite::Connection& connection, const ISQLiteIndex* internalIndex, const std::string& packageIdentifier, RemovalBehavior removals, bool ensureTable, std::optional removedPackageRowId) { if (ensureTable) { - EnsureExists(connection); + EnsureExists(connection, removals); } SearchRequest request; @@ -84,11 +284,46 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 if (result.Matches.empty()) { - // Remove any existing package update row - Builder::StatementBuilder deleteBuilder; - deleteBuilder.DeleteFrom(s_PUTT_Table_Name).Where(s_PUTT_Package).LikeWithEscape(packageIdentifier); + if (removals == RemovalBehavior::Delete) + { + // Remove any existing package update row + Builder::StatementBuilder deleteBuilder; + deleteBuilder.DeleteFrom(s_PUTT_Table_Name).Where(s_PUTT_Package).LikeWithEscape(packageIdentifier); - deleteBuilder.Execute(connection); + deleteBuilder.Execute(connection); + } + else + { + // Mark the package as removed rather than deleting the row, clearing the data columns. + // The row is found by the rowid the package occupied rather than by its identifier. + THROW_HR_IF(E_NOT_VALID_STATE, !removedPackageRowId); + + int64_t currentTime = Utility::GetCurrentUnixEpoch(); + int64_t changeSequence = GetNextChangeSequence(connection); + + Builder::StatementBuilder updateBuilder; + updateBuilder.Update(s_PUTT_Table_Name).Set(). + Column(s_PUTT_WriteTime).Equals(currentTime). + Column(s_PUTT_ChangeSequence).Equals(changeSequence). + Column(s_PUTT_Manifest).AssignValue(nullptr). + Column(s_PUTT_Hash).AssignValue(nullptr). + Column(s_PUTT_IsRemoved).Equals(1). + Where(s_PUTT_PackageRowId).Equals(removedPackageRowId.value()). + And(s_PUTT_IsRemoved).Equals(0); + updateBuilder.Execute(connection); + + if (connection.GetChanges() == 0) + { + // The package was added and removed without an intervening tracking checkpoint, + // so there is no row to mark. Record the removal so that a delta built against an + // older baseline still learns that the rowid was vacated. + Builder::StatementBuilder insertBuilder; + insertBuilder.InsertInto(s_PUTT_Table_Name). + Columns({ s_PUTT_Package, s_PUTT_WriteTime, s_PUTT_IsRemoved, s_PUTT_PackageRowId, s_PUTT_ChangeSequence }). + Values(packageIdentifier, currentTime, 1, removedPackageRowId.value(), changeSequence); + insertBuilder.Execute(connection); + } + } } else { @@ -120,34 +355,91 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 Utility::SHA256::HashBuffer manifestHash = Utility::SHA256::ComputeHash(compressedManifest); int64_t currentTime = Utility::GetCurrentUnixEpoch(); + // The rowid the package occupies is the identity that a delta is keyed on, so it is + // recorded alongside the data. It is resolved here rather than at removal time because + // the package is still in the index at this point. + SQLite::rowid_t packageRowId = 0; + int64_t changeSequence = 0; + + if (removals == RemovalBehavior::Record) + { + std::optional indexRowId = GetPackageRowIdInIndex(connection, packageIdentifier); + THROW_HR_IF(E_NOT_VALID_STATE, !indexRowId); + packageRowId = indexRowId.value(); + changeSequence = GetNextChangeSequence(connection); + } + // First attempt to update the row and then insert it if no modification occurred. Builder::StatementBuilder updateBuilder; updateBuilder.Update(s_PUTT_Table_Name).Set(). Column(s_PUTT_WriteTime).Equals(currentTime). Column(s_PUTT_Manifest).Equals(compressedManifest). - Column(s_PUTT_Hash).Equals(manifestHash). - Where(s_PUTT_Package).LikeWithEscape(packageIdentifier); + Column(s_PUTT_Hash).Equals(manifestHash); + + if (removals == RemovalBehavior::Record) + { + // Clear the flag in case this package was previously removed and is now being re-added. + updateBuilder.Column(s_PUTT_IsRemoved).Equals(0); + updateBuilder.Column(s_PUTT_ChangeSequence).Equals(changeSequence); + } + + updateBuilder.Where(s_PUTT_Package).LikeWithEscape(packageIdentifier); + + if (removals == RemovalBehavior::Record) + { + // Match on the rowid as well, so that a re-add only revives the tombstone that + // refers to the rowid the package now occupies. A package removed and re-added + // elsewhere leaves its old tombstone intact, which is what tells a delta to + // suppress the baseline row it still holds. Restricting by rowid also prevents + // this update from overwriting a different package's tombstone that happens to + // name the same rowid. + updateBuilder.And(s_PUTT_PackageRowId).Equals(packageRowId); + } updateBuilder.Execute(connection); if (connection.GetChanges() == 0) { Builder::StatementBuilder insertBuilder; - insertBuilder.InsertInto(s_PUTT_Table_Name). - Columns({ s_PUTT_Package, s_PUTT_WriteTime, s_PUTT_Manifest, s_PUTT_Hash }). - Values(packageIdentifier, currentTime, compressedManifest, manifestHash); + insertBuilder.InsertInto(s_PUTT_Table_Name).BeginColumns(); + + insertBuilder.Column(s_PUTT_Package); + insertBuilder.Column(s_PUTT_WriteTime); + insertBuilder.Column(s_PUTT_Manifest); + insertBuilder.Column(s_PUTT_Hash); + + if (removals == RemovalBehavior::Record) + { + insertBuilder.Column(s_PUTT_PackageRowId); + insertBuilder.Column(s_PUTT_ChangeSequence); + } + + insertBuilder.EndColumns().BeginValues(); + + insertBuilder.Value(packageIdentifier); + insertBuilder.Value(currentTime); + insertBuilder.Value(compressedManifest); + insertBuilder.Value(manifestHash); + + if (removals == RemovalBehavior::Record) + { + insertBuilder.Value(packageRowId); + insertBuilder.Value(changeSequence); + } + + insertBuilder.EndValues(); insertBuilder.Execute(connection); } } } - bool PackageUpdateTrackingTable::CheckConsistency(const SQLite::Connection& connection, ISQLiteIndex* internalIndex, bool log) + bool PackageUpdateTrackingTable::CheckConsistency(const SQLite::Connection& connection, ISQLiteIndex* internalIndex, RemovalBehavior removals, bool log) { bool result = true; // Ensure that all data in the update table matches the internal index - for (const PackageData& packageData : GetUpdatesSince(connection, 0)) + for (const PackageData& packageData : GetUpdatesSince(connection, 0, removals)) { auto manifestHash = Utility::SHA256::ComputeHash(packageData.Manifest); if (!Utility::SHA256::AreEqual(packageData.Hash, manifestHash)) @@ -178,10 +470,56 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 } } + // A package recorded as removed must no longer occupy the rowid it vacated. + for (const auto& [packageIdentifier, vacatedRowId] : GetRemovedRows(connection, removals)) + { + std::optional indexRowId = GetPackageRowIdInIndex(connection, packageIdentifier); + + if (indexRowId && indexRowId.value() == vacatedRowId) + { + if (!log) + { + return false; + } + + result = false; + AICLI_LOG(Repo, Info, << " [INVALID] value [" << s_PUTT_Package << "] in table [" << s_PUTT_Table_Name << + "]; the package [" << packageIdentifier << "] is marked as having vacated rowid [" << vacatedRowId << + "] but still occupies it in the internal index"); + } + } + + // Every live row must name the rowid that the package actually occupies. + if (removals == RemovalBehavior::Record) + { + for (const PackageData& packageData : GetUpdatesSince(connection, 0, removals)) + { + std::optional indexRowId = GetPackageRowIdInIndex(connection, packageData.PackageIdentifier); + + if (!indexRowId || indexRowId.value() != packageData.PackageRowId) + { + if (!log) + { + return false; + } + + result = false; + AICLI_LOG(Repo, Info, << " [INVALID] value [" << s_PUTT_PackageRowId << "] in table [" << s_PUTT_Table_Name << + "] at row [" << packageData.RowID << "]; the package [" << packageData.PackageIdentifier << "] records rowid [" << + packageData.PackageRowId << "] but occupies [" << (indexRowId ? std::to_string(indexRowId.value()) : "none") << "]"); + } + } + } + // Ensure that all packages in the internal index are present in the update table Builder::StatementBuilder builder; builder.Select(Builder::RowCount).From(s_PUTT_Table_Name).Where(s_PUTT_Package).Like(Builder::Unbound).Escape(EscapeCharForLike); + if (removals == RemovalBehavior::Record) + { + builder.And(s_PUTT_IsRemoved).Equals(0); + } + Statement select = builder.Prepare(connection); for (const auto& packageMatch : internalIndex->Search(connection, {}).Matches) @@ -203,47 +541,148 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 } result = false; - AICLI_LOG(Repo, Info, << " [INVALID] value [" << packageIdentifier << "] in the internal index was not found in [" << s_PUTT_Table_Name << "]"); + AICLI_LOG(Repo, Info, << " [INVALID] value [" << packageIdentifier << "] in the internal index was not found as a non-removed entry in [" << s_PUTT_Table_Name << "]"); } } return result; } - std::vector PackageUpdateTrackingTable::GetUpdatesSince(const SQLite::Connection& connection, int64_t updateBaseTime) + std::vector PackageUpdateTrackingTable::GetUpdatesSince(const SQLite::Connection& connection, int64_t updateBaseTime, RemovalBehavior removals) { - Builder::StatementBuilder builder; - builder.Select({ RowIDName, s_PUTT_Package, s_PUTT_WriteTime, s_PUTT_Manifest, s_PUTT_Hash }). - From(s_PUTT_Table_Name).Where(s_PUTT_WriteTime).IsGreaterThanOrEqualTo(updateBaseTime); + return GetUpdates(connection, s_PUTT_WriteTime, updateBaseTime, false, removals); + } - Statement select = builder.Prepare(connection); + std::vector PackageUpdateTrackingTable::GetUpdatesSinceSequence(const SQLite::Connection& connection, int64_t baseSequence, RemovalBehavior removals) + { + // Only the recording form has the column, and only a delta asks this question. + THROW_HR_IF(E_NOT_VALID_STATE, removals != RemovalBehavior::Record); - std::vector result; + return GetUpdates(connection, s_PUTT_ChangeSequence, baseSequence, true, removals); + } - while (select.Step()) + std::set PackageUpdateTrackingTable::GetRemovalsSince(const SQLite::Connection& connection, int64_t updateBaseTime, RemovalBehavior removals) + { + if (removals == RemovalBehavior::Delete) { - PackageData item; - item.RowID = select.GetColumn(0); - item.PackageIdentifier = select.GetColumn(1); - item.WriteTime = select.GetColumn(2); - item.Manifest = select.GetColumn(3); - item.Hash = select.GetColumn(4); + // Removals delete their row, so there is nothing to report. + return {}; + } + + return GetRemovals(connection, s_PUTT_WriteTime, updateBaseTime, false); + } + + std::set PackageUpdateTrackingTable::GetRemovalsSinceSequence(const SQLite::Connection& connection, int64_t baseSequence, RemovalBehavior removals) + { + THROW_HR_IF(E_NOT_VALID_STATE, removals != RemovalBehavior::Record); + + return GetRemovals(connection, s_PUTT_ChangeSequence, baseSequence, true); + } - result.emplace_back(std::move(item)); + int64_t PackageUpdateTrackingTable::GetCurrentChangeSequence(const SQLite::Connection& connection, RemovalBehavior removals) + { + THROW_HR_IF(E_NOT_VALID_STATE, removals != RemovalBehavior::Record); + + if (!Exists(connection)) + { + // The table is created on demand, so an index to which no manifest has ever been + // written has none. Nothing has been recorded, so nothing has been sequenced. + return 0; } - return result; + return GetNextChangeSequence(connection) - 1; } - SQLite::blob_t PackageUpdateTrackingTable::GetDataHash(const SQLite::Connection& connection, const std::string& packageIdentifier) + SQLite::blob_t PackageUpdateTrackingTable::GetDataHash(const SQLite::Connection& connection, const std::string& packageIdentifier, RemovalBehavior removals) { Builder::StatementBuilder builder; builder.Select(s_PUTT_Hash).From(s_PUTT_Table_Name).Where(s_PUTT_Package).LikeWithEscape(packageIdentifier); + if (removals == RemovalBehavior::Record) + { + // A tombstone has its hash cleared, so restrict to the live row or this would be + // liable to return a null hash for a package that was removed and re-added. + builder.And(s_PUTT_IsRemoved).Equals(0); + } + Statement select = builder.Prepare(connection); THROW_HR_IF(E_NOT_SET, !select.Step()); return select.GetColumn(0); } + + void PackageUpdateTrackingTable::MigrateToRemovalTracking(SQLite::Connection& connection) + { + if (!Exists(connection)) + { + return; + } + + // The 2.1 shape differs from 2.0 in more than the columns it adds: a tombstone carries no + // manifest or hash, so both have to become nullable, and SQLite cannot drop a not null + // constraint in place. + struct MigratedRow + { + rowid_t RowId = 0; + std::string Package; + int64_t WriteTime = 0; + SQLite::blob_t Manifest; + SQLite::blob_t Hash; + rowid_t PackageRowId = 0; + }; + + std::vector rows; + + { + Builder::StatementBuilder selectBuilder; + selectBuilder.Select({ RowIDName, s_PUTT_Package, s_PUTT_WriteTime, s_PUTT_Manifest, s_PUTT_Hash }).From(s_PUTT_Table_Name); + + Statement select = selectBuilder.Prepare(connection); + + while (select.Step()) + { + MigratedRow& row = rows.emplace_back(); + row.RowId = select.GetColumn(0); + row.Package = select.GetColumn(1); + row.WriteTime = select.GetColumn(2); + row.Manifest = select.GetColumn(3); + row.Hash = select.GetColumn(4); + } + } + + // Resolve the rowid for the rows already present. Every one of them is live: 2.0 deletes + // the row when a package is removed, so a table being migrated has no tombstones and every + // package it names is still in the index. + for (MigratedRow& row : rows) + { + std::optional packageRowId = GetPackageRowIdInIndex(connection, row.Package); + THROW_HR_IF(E_NOT_VALID_STATE, !packageRowId); + row.PackageRowId = packageRowId.value(); + } + + // Dropping the table takes its indexes with it, so the rebuilt form is free to reuse their + // names. The caller holds a savepoint, so the data is never at risk between the two. + Drop(connection); + Create(connection, RemovalBehavior::Record); + + Builder::StatementBuilder insertBuilder; + insertBuilder.InsertInto(s_PUTT_Table_Name). + Columns({ RowIDName, s_PUTT_Package, s_PUTT_WriteTime, s_PUTT_Manifest, s_PUTT_Hash, s_PUTT_PackageRowId, s_PUTT_IsRemoved, s_PUTT_ChangeSequence }). + Values(Builder::Unbound, Builder::Unbound, Builder::Unbound, Builder::Unbound, Builder::Unbound, Builder::Unbound, 0, 0); + + Statement insert = insertBuilder.Prepare(connection); + + for (const MigratedRow& row : rows) + { + insert.Reset(); + insert.Bind(1, row.RowId); + insert.Bind(2, row.Package); + insert.Bind(3, row.WriteTime); + insert.Bind(4, row.Manifest); + insert.Bind(5, row.Hash); + insert.Bind(6, row.PackageRowId); + insert.Execute(); + } + } } diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackageUpdateTrackingTable.h b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackageUpdateTrackingTable.h index 5cf429bd1f..f9d7a7a528 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackageUpdateTrackingTable.h +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackageUpdateTrackingTable.h @@ -11,14 +11,27 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 // only the necessary package manifests. struct PackageUpdateTrackingTable { + // Determines how the removal of a package is reflected in the table. + enum class RemovalBehavior + { + Delete, + Record, + }; + // Get the table name. static std::string_view TableName(); // Creates the table. - static void Create(SQLite::Connection& connection); + static void Create(SQLite::Connection& connection, RemovalBehavior removals); + + // Creates the unique index that allows at most one live row per package rowid. + static void CreateLiveRowIndex(SQLite::Connection& connection); + + // Creates the index over the change sequence. + static void CreateChangeSequenceIndex(SQLite::Connection& connection); // Creates the table if it does not exist. - static void EnsureExists(SQLite::Connection& connection); + static void EnsureExists(SQLite::Connection& connection, RemovalBehavior removals); // Drops the table. static void Drop(SQLite::Connection& connection); @@ -27,11 +40,11 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 static bool Exists(const SQLite::Connection& connection); // Updates the tracking table for the given package identifier in the internal index. - static void Update(SQLite::Connection& connection, const ISQLiteIndex* internalIndex, const std::string& packageIdentifier, bool ensureTable = true); + static void Update(SQLite::Connection& connection, const ISQLiteIndex* internalIndex, const std::string& packageIdentifier, RemovalBehavior removals, bool ensureTable = true, std::optional removedPackageRowId = {}); // Checks the consistency of the index to ensure that every referenced row exists. // Returns true if index is consistent; false if it is not. - static bool CheckConsistency(const SQLite::Connection& connection, ISQLiteIndex* internalIndex, bool log); + static bool CheckConsistency(const SQLite::Connection& connection, ISQLiteIndex* internalIndex, RemovalBehavior removals, bool log); // Data on a single row in the table. struct PackageData @@ -41,12 +54,36 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 int64_t WriteTime = 0; SQLite::blob_t Manifest; SQLite::blob_t Hash; + // The rowid the package occupies in the index, or 0 when it is not known. + SQLite::rowid_t PackageRowId = 0; }; // Gets the data on updates that have been written since the given base time. - static std::vector GetUpdatesSince(const SQLite::Connection& connection, int64_t updateBaseTime); + // Removed packages are never included; use GetRemovalsSince to retrieve those. + static std::vector GetUpdatesSince(const SQLite::Connection& connection, int64_t updateBaseTime, RemovalBehavior removals); + + // Gets the rowids vacated by packages removed since the given base time. + // Only meaningful when removals are being recorded; always empty otherwise. + static std::set GetRemovalsSince(const SQLite::Connection& connection, int64_t updateBaseTime, RemovalBehavior removals); + + // Gets the data on updates written after the given change sequence, exclusive. + // Only available when removals are being recorded, as only then does the column exist. + static std::vector GetUpdatesSinceSequence(const SQLite::Connection& connection, int64_t baseSequence, RemovalBehavior removals); + + // Gets the rowids vacated by packages removed after the given change sequence, exclusive. + // Only available when removals are being recorded, as only then does the column exist. + static std::set GetRemovalsSinceSequence(const SQLite::Connection& connection, int64_t baseSequence, RemovalBehavior removals); + + // Gets the most recently issued change sequence, or 0 if nothing has been written. + // Only available when removals are being recorded, as only then does the column exist. + static int64_t GetCurrentChangeSequence(const SQLite::Connection& connection, RemovalBehavior removals); // Gets the data hash for the given package identifier. - static SQLite::blob_t GetDataHash(const SQLite::Connection& connection, const std::string& packageIdentifier); + static SQLite::blob_t GetDataHash(const SQLite::Connection& connection, const std::string& packageIdentifier, RemovalBehavior removals); + + // Rebuilds an existing 2.0 table in the shape that recording removals requires. + // The table is rebuilt rather than altered because a tombstone has no manifest or hash, + // and SQLite cannot drop the not null constraint that a 2.0 table places on them. + static void MigrateToRemovalTracking(SQLite::Connection& connection); }; } diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackagesTable.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackagesTable.cpp index c58a8bfb0b..a30c92c0e4 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackagesTable.cpp +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackagesTable.cpp @@ -213,11 +213,16 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 savepoint.Commit(); } - SQLite::rowid_t PackagesTable::Insert(SQLite::Connection& connection, const std::vector& values) + SQLite::rowid_t PackagesTable::Insert(SQLite::Connection& connection, const std::vector& values, std::optional rowid) { SQLite::Builder::StatementBuilder builder; builder.InsertInto(s_PackagesTable_Table_Name).BeginColumns(); + if (rowid) + { + builder.Column(SQLite::RowIDName); + } + for (const NameValuePair& value : values) { builder.Column(value.Name); @@ -225,6 +230,11 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 builder.EndColumns().BeginValues(); + if (rowid) + { + builder.Value(rowid.value()); + } + for (const NameValuePair& value : values) { builder.Value(value.Value); @@ -234,7 +244,7 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 builder.Execute(connection); - return connection.GetLastInsertRowID(); + return rowid ? rowid.value() : connection.GetLastInsertRowID(); } bool PackagesTable::ExistsById(const SQLite::Connection& connection, SQLite::rowid_t id) diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackagesTable.h b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackagesTable.h index cb879092b9..7f44f400b9 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackagesTable.h +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_0/PackagesTable.h @@ -144,7 +144,7 @@ namespace AppInstaller::Repository::Microsoft::Schema::V2_0 }; // Insert the given values into the table. - static SQLite::rowid_t Insert(SQLite::Connection& connection, const std::vector& values); + static SQLite::rowid_t Insert(SQLite::Connection& connection, const std::vector& values, std::optional rowid = std::nullopt); // Gets a value indicating whether the package with rowid exists. static bool ExistsById(const SQLite::Connection& connection, SQLite::rowid_t rowid); diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaGeneration.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaGeneration.cpp new file mode 100644 index 0000000000..98b5f008e2 --- /dev/null +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaGeneration.cpp @@ -0,0 +1,433 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "Microsoft/Schema/2_1/DeltaGeneration.h" +#include "Microsoft/Schema/2_1/DeltaTables.h" +#include "Microsoft/Schema/2_1/Interface.h" + +#include "Microsoft/Schema/2_0/PackagesTable.h" +#include "Microsoft/Schema/2_0/OneToManyTableWithMap.h" +#include "Microsoft/Schema/2_0/SystemReferenceStringTable.h" + +#include +#include +#include + + +namespace AppInstaller::Repository::Microsoft::Schema::V2_1::Delta +{ + using namespace SQLite::Builder; + + namespace + { + struct DeltaDatabase : public SQLite::SQLiteStorageBase + { + DeltaDatabase(const std::filesystem::path& path, const SQLite::Version& version) : + SQLiteStorageBase(path.u8string(), version) + { + SQLite::Savepoint savepoint = SQLite::Savepoint::Create(m_dbconn, "delta_create_database_v2_1"); + + m_version.SetSchemaVersion(m_dbconn); + CreateTables(m_dbconn); + SetLastWriteTime(); + + savepoint.Commit(); + } + + SQLite::Connection& GetConnection() { return m_dbconn; } + }; + + // Gets the rowid of the given package identifier in the packages table, if it is present. + std::optional SelectPackageRowId(const SQLite::Connection& connection, const std::string& packageIdentifier) + { + StatementBuilder builder; + builder.Select(SQLite::RowIDName).From(V2_0::PackagesTable::TableName()). + Where(V2_0::PackagesTable::IdColumn::Name).LikeWithEscape(packageIdentifier); + + SQLite::Statement statement = builder.Prepare(connection); + + if (statement.Step()) + { + return statement.GetColumn(0); + } + + return {}; + } + + // Gets the identifier of the package at the given rowid, if there is one. + std::optional SelectPackageIdByRowId(const SQLite::Connection& connection, SQLite::rowid_t packageRowId) + { + StatementBuilder builder; + builder.Select(V2_0::PackagesTable::IdColumn::Name).From(V2_0::PackagesTable::TableName()). + Where(SQLite::RowIDName).Equals(packageRowId); + + SQLite::Statement statement = builder.Prepare(connection); + + if (statement.Step()) + { + return statement.GetColumn(0); + } + + return {}; + } + + // Gets the rowid of the given value in a one to many data table, if it is present. + std::optional SelectValueRowId( + const SQLite::Connection& connection, + std::string_view tableName, + std::string_view valueName, + const std::string& value) + { + StatementBuilder builder; + builder.Select(SQLite::RowIDName).From(tableName).Where(valueName).Equals(value); + + SQLite::Statement statement = builder.Prepare(connection); + + if (statement.Step()) + { + return statement.GetColumn(0); + } + + return {}; + } + + // Gets the largest rowid in the given table, or 0 when it is empty. + SQLite::rowid_t GetMaximumRowId(const SQLite::Connection& connection, std::string_view tableName) + { + StatementBuilder builder; + builder.Select().Column(Aggregate::Max, SQLite::RowIDName).From(tableName); + + SQLite::Statement statement = builder.Prepare(connection); + + // The aggregate produces a single row holding null when the table is empty. + if (statement.Step() && !statement.GetColumnIsNull(0)) + { + return statement.GetColumn(0); + } + + return 0; + } + + // Determines the values that are in the first collection but not the second. + std::vector GetValuesNotIn(const std::vector& values, const std::vector& other) + { + std::vector result; + + for (const std::string& value : values) + { + if (std::find(other.begin(), other.end(), value) == other.end()) + { + result.emplace_back(value); + } + } + + return result; + } + + // Records that the package with the given identifier is no longer present. + void WriteRemovedPackage(SQLite::Connection& deltaConnection, SQLite::rowid_t packageRowId, const std::string& packageIdentifier) + { + std::string tableName = GetTableName(V2_0::PackagesTable::TableName()); + + StatementBuilder builder; + builder.InsertInto(tableName). + Columns({ SQLite::RowIDName, V2_0::PackagesTable::IdColumn::Name, IsRemovedColumnName() }). + Values(packageRowId, packageIdentifier, 1); + + builder.Execute(deltaConnection); + } + + // Copies the current state of a package into the delta. + void WriteChangedPackage(SQLite::Connection& deltaConnection, const SQLite::Connection& sourceConnection, SQLite::rowid_t packageRowId) + { + auto [id, name, moniker, latestVersion, arpMinVersion, arpMaxVersion, hash] = + V2_0::PackagesTable::GetValuesById< + V2_0::PackagesTable::IdColumn, + V2_0::PackagesTable::NameColumn, + V2_0::PackagesTable::MonikerColumn, + V2_0::PackagesTable::LatestVersionColumn, + V2_0::PackagesTable::ARPMinVersionColumn, + V2_0::PackagesTable::ARPMaxVersionColumn, + V2_0::PackagesTable::HashColumn + >(sourceConnection, packageRowId); + + std::string tableName = GetTableName(V2_0::PackagesTable::TableName()); + + StatementBuilder builder; + builder.InsertInto(tableName). + Columns({ + SQLite::RowIDName, + V2_0::PackagesTable::IdColumn::Name, + V2_0::PackagesTable::NameColumn::Name, + V2_0::PackagesTable::MonikerColumn::Name, + V2_0::PackagesTable::LatestVersionColumn::Name, + V2_0::PackagesTable::ARPMinVersionColumn::Name, + V2_0::PackagesTable::ARPMaxVersionColumn::Name, + V2_0::PackagesTable::HashColumn::Name, + IsRemovedColumnName() }). + Values(packageRowId, id, name, moniker, latestVersion, arpMinVersion, arpMaxVersion, hash, 0); + + builder.Execute(deltaConnection); + } + + // Records a single system reference value as added or removed for a package. + void WriteSystemReferenceValue( + SQLite::Connection& deltaConnection, + const ValueTableInfo& table, + const std::string& value, + SQLite::rowid_t packageRowId, + bool isRemoved) + { + std::string tableName = GetTableName(table.TableName); + + StatementBuilder builder; + builder.InsertInto(tableName). + Columns({ table.ValueName, V2_0::details::SystemReferenceStringTableGetPrimaryColumnName(), IsRemovedColumnName() }). + Values(value, packageRowId, isRemoved ? 1 : 0); + + builder.Execute(deltaConnection); + } + + // Records only the system reference values that changed for the package. + void WriteSystemReferenceDifference( + SQLite::Connection& deltaConnection, + const SQLite::Connection& sourceConnection, + const SQLite::Connection& baselineConnection, + const ValueTableInfo& table, + SQLite::rowid_t packageRowId) + { + std::vector currentValues = V2_0::details::SystemReferenceStringTableGetValuesByPrimaryId( + sourceConnection, table.TableName, table.ValueName, packageRowId); + std::vector baselineValues = V2_0::details::SystemReferenceStringTableGetValuesByPrimaryId( + baselineConnection, table.TableName, table.ValueName, packageRowId); + + for (const std::string& value : GetValuesNotIn(currentValues, baselineValues)) + { + WriteSystemReferenceValue(deltaConnection, table, value, packageRowId, false); + } + + for (const std::string& value : GetValuesNotIn(baselineValues, currentValues)) + { + WriteSystemReferenceValue(deltaConnection, table, value, packageRowId, true); + } + } + + // Gets a rowid that identifies the value in the merged data table, creating one if needed. + SQLite::rowid_t EnsureValueRowId( + SQLite::Connection& deltaConnection, + const SQLite::Connection& baselineConnection, + const ValueTableInfo& table, + const std::string& value, + SQLite::rowid_t& nextValueRowId) + { + std::optional baselineRowId = SelectValueRowId(baselineConnection, table.TableName, table.ValueName, value); + + if (baselineRowId) + { + return baselineRowId.value(); + } + + std::string tableName = GetTableName(table.TableName); + + std::optional deltaRowId = SelectValueRowId(deltaConnection, tableName, table.ValueName, value); + + if (deltaRowId) + { + return deltaRowId.value(); + } + + SQLite::rowid_t newRowId = ++nextValueRowId; + + StatementBuilder builder; + builder.InsertInto(tableName). + Columns({ SQLite::RowIDName, table.ValueName }). + Values(newRowId, value); + + builder.Execute(deltaConnection); + + return newRowId; + } + + // Records a single map entry as added or removed for a package. + void WriteOneToManyValue( + SQLite::Connection& deltaConnection, + const ValueTableInfo& table, + SQLite::rowid_t valueRowId, + SQLite::rowid_t packageRowId, + bool isRemoved) + { + std::string mapTableName = GetMapTableName(table.TableName); + + StatementBuilder builder; + builder.InsertInto(mapTableName). + Columns({ table.ValueName, V2_0::details::OneToManyTableGetManifestColumnName(), IsRemovedColumnName() }). + Values(valueRowId, packageRowId, isRemoved ? 1 : 0); + + builder.Execute(deltaConnection); + } + + // Records only the map entries that changed for the package. + void WriteOneToManyDifference( + SQLite::Connection& deltaConnection, + const SQLite::Connection& sourceConnection, + const SQLite::Connection& baselineConnection, + const ValueTableInfo& table, + SQLite::rowid_t packageRowId, + SQLite::rowid_t& nextValueRowId) + { + std::vector currentValues = V2_0::details::OneToManyTableWithMapGetValuesByPrimaryId( + sourceConnection, table.TableName, table.ValueName, packageRowId); + std::vector baselineValues = V2_0::details::OneToManyTableWithMapGetValuesByPrimaryId( + baselineConnection, table.TableName, table.ValueName, packageRowId); + + for (const std::string& value : GetValuesNotIn(currentValues, baselineValues)) + { + SQLite::rowid_t valueRowId = EnsureValueRowId(deltaConnection, baselineConnection, table, value, nextValueRowId); + WriteOneToManyValue(deltaConnection, table, valueRowId, packageRowId, false); + } + + for (const std::string& value : GetValuesNotIn(baselineValues, currentValues)) + { + // The value came from the baseline's own data table, so it must be found there. + std::optional valueRowId = SelectValueRowId(baselineConnection, table.TableName, table.ValueName, value); + THROW_HR_IF(E_NOT_VALID_STATE, !valueRowId); + + WriteOneToManyValue(deltaConnection, table, valueRowId.value(), packageRowId, true); + } + } + } + + void Generate( + const SQLite::Connection& sourceConnection, + const SQLite::Connection& baselineConnection, + const std::filesystem::path& deltaOutputPath, + const SQLite::Version& version, + const std::vector& changedPackages, + const std::set& removedPackages) + { + AICLI_LOG(Repo, Info, << "Generating delta index at [" << deltaOutputPath << "] for " << changedPackages.size() << + " changed and " << removedPackages.size() << " removed packages"); + + // A delta is only meaningful alongside the exact baseline it was computed from, so the + // baseline has to be one that was designated as such and can therefore be named. + std::optional baselineIdentifier = + SQLite::MetadataTable::TryGetNamedValue(baselineConnection, s_MetadataValueName_BaselineIdentifier); + THROW_HR_IF(APPINSTALLER_CLI_ERROR_INDEX_INTEGRITY_COMPROMISED, !baselineIdentifier || baselineIdentifier->empty()); + + // The output is written in full before anything appears at the path that was asked for, so + // that a failure partway through cannot leave a file that looks like a usable delta. An + // existing file is refused rather than replaced: the database is opened for creation, so it + // would otherwise be reopened and fail later with a confusing complaint about its tables. + THROW_WIN32_IF(ERROR_FILE_EXISTS, std::filesystem::exists(deltaOutputPath)); + + // A sibling of the destination, so that the two are necessarily on the same volume and + // moving the result is a rename rather than a copy. The name carries a fresh GUID, so it + // cannot collide with another generation running against the same destination directory. + std::filesystem::path temporaryPath = deltaOutputPath.u8string() + "." + Utility::ConvertToUTF8(Utility::CreateNewGuidNameWString()) + ".tmp"; + THROW_WIN32_IF(ERROR_FILE_EXISTS, std::filesystem::exists(temporaryPath)); + + auto removeTemporaryFilesOnExit = wil::scope_exit([&]() + { + try + { + // The auxiliary files should be gone once the connection is closed, but a + // failure can leave them behind and they must not outlive the database. + for (const auto& suffix : { L"", L"-journal", L"-wal" }) + { + std::filesystem::path auxiliaryPath = temporaryPath; + auxiliaryPath += suffix; + std::filesystem::remove(auxiliaryPath); + } + } + catch (...) + { + AICLI_LOG(Repo, Info, << "Failed to remove temporary delta index file at: " << temporaryPath); + } + }); + + { + DeltaDatabase deltaDatabase{ temporaryPath, version }; + SQLite::Connection& deltaConnection = deltaDatabase.GetConnection(); + + SQLite::Savepoint savepoint = SQLite::Savepoint::Create(deltaConnection, "delta_generate_v2_1"); + + SQLite::MetadataTable::SetNamedValue(deltaConnection, s_MetadataValueName_DeltaBaselineIdentifier, baselineIdentifier.value()); + + std::map nextValueRowIds; + + for (const auto& table : OneToManyTables()) + { + nextValueRowIds[table.TableName] = GetMaximumRowId(baselineConnection, table.TableName); + } + + // Every rowid already written to the delta's packages table. The changed packages are + // written first so that a removal can tell whether the rowid it is about to vacate has + // since been taken, and each removal joins the set so that two tombstones resolving to one + // baseline rowid cannot both be written. + std::set writtenRowIds; + + for (const auto& package : changedPackages) + { + // The rowid comes from the source rather than the baseline so that packages new to this + // delta are covered by the same lookup; rowid stability is what makes the two agree. + std::optional packageRowId = SelectPackageRowId(sourceConnection, package.PackageIdentifier); + THROW_HR_IF(E_NOT_VALID_STATE, !packageRowId); + + AICLI_LOG(Repo, Verbose, << "Delta: recording change to [" << package.PackageIdentifier << "] (rowid " << packageRowId.value() << ")"); + + writtenRowIds.insert(packageRowId.value()); + WriteChangedPackage(deltaConnection, sourceConnection, packageRowId.value()); + + for (const auto& table : SystemReferenceTables()) + { + WriteSystemReferenceDifference(deltaConnection, sourceConnection, baselineConnection, table, packageRowId.value()); + } + + for (const auto& table : OneToManyTables()) + { + WriteOneToManyDifference(deltaConnection, sourceConnection, baselineConnection, table, packageRowId.value(), nextValueRowIds[table.TableName]); + } + } + + for (SQLite::rowid_t removedRowId : removedPackages) + { + // The rowid is resolved against the baseline directly, which is exact and is a primary + // key lookup. Whatever identifier the tracking table recorded is irrelevant here: what + // the delta suppresses is the baseline row at this rowid, so that row is also where the + // identifier stored alongside the tombstone comes from. + std::optional baselinePackageId = SelectPackageIdByRowId(baselineConnection, removedRowId); + + if (!baselinePackageId) + { + // The rowid was allocated after the baseline was produced, so as far as the + // baseline is concerned it never held anything and there is nothing to suppress. + AICLI_LOG(Repo, Verbose, << "Delta: rowid " << removedRowId << " was vacated but is not in the baseline"); + continue; + } + + if (writtenRowIds.count(removedRowId)) + { + // The rowid has already been written by a package that has since taken it. + AICLI_LOG(Repo, Verbose, << "Delta: rowid " << removedRowId << " was vacated but has already been written"); + continue; + } + + AICLI_LOG(Repo, Verbose, << "Delta: recording removal of [" << baselinePackageId.value() << "] (rowid " << removedRowId << ")"); + + WriteRemovedPackage(deltaConnection, removedRowId, baselinePackageId.value()); + } + + savepoint.Commit(); + + // Outside the savepoint, since this vacuums. + PrepareTablesForPackaging(deltaConnection); + } + + // Only now, with the database complete and its connection closed, does anything appear at + // the path that was asked for. + SQLite::SQLiteStorageBase::RenameSQLiteDatabase(temporaryPath, deltaOutputPath); + removeTemporaryFilesOnExit.release(); + + AICLI_LOG(Repo, Info, << "Delta index generation complete"); + } +} diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaGeneration.h b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaGeneration.h new file mode 100644 index 0000000000..098939a42a --- /dev/null +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaGeneration.h @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include "Microsoft/Schema/2_0/PackageUpdateTrackingTable.h" +#include +#include +#include +#include +#include +#include + + +namespace AppInstaller::Repository::Microsoft::Schema::V2_1::Delta +{ + // Writes a delta database describing the difference between a baseline index and the index + // that is currently being packaged. + // + // This must run while the index being packaged still holds its update tracking table, as that + // is the only record of which packages have changed since the baseline was produced. + // + // The source and the baseline assign the same rowid to a given package, so a package that + // exists in both is described by rows that carry its baseline rowid, and a package that is new + // to the source carries a rowid that the baseline cannot have used. + // + // The version is recorded as the delta's own schema version, so that opening the delta selects + // the interface that knows how to merge it with a baseline. + // + // Nothing appears at the output path until the delta is complete: it is built beside the + // destination and moved into place only on success, so a failure partway through cannot leave + // something that looks like a usable delta. An output path that already exists is refused. + void Generate( + const SQLite::Connection& sourceConnection, + const SQLite::Connection& baselineConnection, + const std::filesystem::path& deltaOutputPath, + const SQLite::Version& version, + const std::vector& changedPackages, + const std::set& removedPackages); +} diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaTables.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaTables.cpp new file mode 100644 index 0000000000..d278fb476d --- /dev/null +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaTables.cpp @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "Microsoft/Schema/2_1/DeltaTables.h" + +#include "Microsoft/Schema/2_0/PackagesTable.h" +#include "Microsoft/Schema/2_0/TagsTable.h" +#include "Microsoft/Schema/2_0/CommandsTable.h" +#include "Microsoft/Schema/2_0/PackageFamilyNameTable.h" +#include "Microsoft/Schema/2_0/ProductCodeTable.h" +#include "Microsoft/Schema/2_0/NormalizedPackageNameTable.h" +#include "Microsoft/Schema/2_0/NormalizedPackagePublisherTable.h" +#include "Microsoft/Schema/2_0/UpgradeCodeTable.h" + +#include + + +namespace AppInstaller::Repository::Microsoft::Schema::V2_1::Delta +{ + using namespace std::string_view_literals; + + namespace + { + constexpr std::string_view s_Delta_TablePrefix = "delta_"sv; + constexpr std::string_view s_Delta_MapTableSuffix = "_map"sv; + constexpr std::string_view s_Delta_ValueIndexSuffix = "_pkindex"sv; + constexpr std::string_view s_Delta_IsRemovedColumn = "is_removed"sv; + + template + ValueTableInfo MakeValueTableInfo() + { + return { Table::TableName(), Table::ValueName() }; + } + } + + std::vector SystemReferenceTables() + { + return std::vector{ + MakeValueTableInfo(), + MakeValueTableInfo(), + MakeValueTableInfo(), + MakeValueTableInfo(), + MakeValueTableInfo(), + }; + } + + std::vector OneToManyTables() + { + return std::vector{ + MakeValueTableInfo(), + MakeValueTableInfo(), + }; + } + + std::string GetTableName(std::string_view baseTableName) + { + auto result = std::string{ s_Delta_TablePrefix }; + result.append(baseTableName); + return result; + } + + std::string GetMapTableName(std::string_view baseTableName) + { + auto result = GetTableName(baseTableName); + result.append(s_Delta_MapTableSuffix); + return result; + } + + std::string_view IsRemovedColumnName() + { + return s_Delta_IsRemovedColumn; + } + + void CreateTables(SQLite::Connection& connection) + { + using namespace SQLite::Builder; + + SQLite::Savepoint savepoint = SQLite::Savepoint::Create(connection, "delta_createtables_v2_1"); + + // The packages table mirrors its 2.0 counterpart, with the addition of the removal flag. + // Every column other than the identifier is nullable here, because a row that records a + // removal carries no data beyond the identity of what was removed. + { + std::string tableName = GetTableName(V2_0::PackagesTable::TableName()); + + StatementBuilder builder; + builder.CreateTable(tableName).Columns({ + IntegerPrimaryKey(), + ColumnBuilder(V2_0::PackagesTable::IdColumn::Name, Type::Text).NotNull(), + ColumnBuilder(V2_0::PackagesTable::NameColumn::Name, Type::Text), + ColumnBuilder(V2_0::PackagesTable::MonikerColumn::Name, Type::Text), + ColumnBuilder(V2_0::PackagesTable::LatestVersionColumn::Name, Type::Text), + ColumnBuilder(V2_0::PackagesTable::ARPMinVersionColumn::Name, Type::Text), + ColumnBuilder(V2_0::PackagesTable::ARPMaxVersionColumn::Name, Type::Text), + ColumnBuilder(V2_0::PackagesTable::HashColumn::Name, Type::Blob), + ColumnBuilder(s_Delta_IsRemovedColumn, Type::Int64).NotNull() + }); + builder.Execute(connection); + } + + // The system reference tables hold the value itself, so the delta only adds the removal flag. + for (const auto& table : SystemReferenceTables()) + { + std::string tableName = GetTableName(table.TableName); + + StatementBuilder builder; + builder.CreateTable(tableName).Columns({ + ColumnBuilder(table.ValueName, Type::Text).NotNull(), + ColumnBuilder(V2_0::details::SystemReferenceStringTableGetPrimaryColumnName(), Type::RowId).NotNull(), + ColumnBuilder(s_Delta_IsRemovedColumn, Type::Int64).NotNull(), + PrimaryKeyBuilder({ table.ValueName, V2_0::details::SystemReferenceStringTableGetPrimaryColumnName() }) + }).WithoutRowID(); + builder.Execute(connection); + } + + for (const auto& table : OneToManyTables()) + { + // The data table holds only values that the baseline does not already have. There is no + // removal flag because a value is only unreferenced once every map entry naming it is + // removed, which the map table already records. + { + std::string tableName = GetTableName(table.TableName); + + StatementBuilder builder; + builder.CreateTable(tableName).Columns({ + IntegerPrimaryKey(), + ColumnBuilder(table.ValueName, Type::Text).NotNull() + }); + builder.Execute(connection); + + // Generation looks values up by string to reuse an already allocated rowid. + StatementBuilder indexBuilder; + indexBuilder.CreateUniqueIndex({ tableName, s_Delta_ValueIndexSuffix }). + On(tableName).Columns(table.ValueName); + indexBuilder.Execute(connection); + } + + { + std::string mapTableName = GetMapTableName(table.TableName); + + StatementBuilder builder; + builder.CreateTable(mapTableName).Columns({ + ColumnBuilder(table.ValueName, Type::Int64).NotNull(), + ColumnBuilder(V2_0::details::OneToManyTableGetManifestColumnName(), Type::Int64).NotNull(), + ColumnBuilder(s_Delta_IsRemovedColumn, Type::Int64).NotNull(), + PrimaryKeyBuilder({ table.ValueName, V2_0::details::OneToManyTableGetManifestColumnName() }) + }).WithoutRowID(); + builder.Execute(connection); + } + } + + savepoint.Commit(); + } + + void PrepareTablesForPackaging(SQLite::Connection& connection) + { + using namespace SQLite::Builder; + + // Every index here exists only to serve generation: those on the one to many data tables + // let generation find the rowid it already allocated for a value. + { + SQLite::Savepoint savepoint = SQLite::Savepoint::Create(connection, "delta_prepare_tables_v2_1"); + + for (const auto& table : OneToManyTables()) + { + StatementBuilder builder; + auto tableName = GetTableName(table.TableName); + builder.DropIndex({ tableName, s_Delta_ValueIndexSuffix }); + builder.Execute(connection); + } + + savepoint.Commit(); + } + + StatementBuilder vacuumBuilder; + vacuumBuilder.Vacuum(); + vacuumBuilder.Execute(connection); + } +} diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaTables.h b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaTables.h new file mode 100644 index 0000000000..5d2fa0179e --- /dev/null +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaTables.h @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include +#include +#include +#include + + +namespace AppInstaller::Repository::Microsoft::Schema::V2_1::Delta +{ + // Describes a 2.0 table that associates string values with packages. + struct ValueTableInfo + { + std::string_view TableName; + std::string_view ValueName; + }; + + // The tables that store a value directly alongside the package that it refers to. + std::vector SystemReferenceTables(); + + // The tables that store values in a data table, associated with packages through a map table. + std::vector OneToManyTables(); + + // Gets the name of the delta table that mirrors the given 2.0 table. + // The delta tables are named distinctly so that a delta database can be attached alongside + // a baseline, and so that the merged views can take the 2.0 names for themselves. + std::string GetTableName(std::string_view baseTableName); + + // Gets the name of the delta map table that mirrors the map table of the given 2.0 table. + std::string GetMapTableName(std::string_view baseTableName); + + // The column that records a row as representing the removal of the data that it identifies. + std::string_view IsRemovedColumnName(); + + // Creates the full set of delta tables in the given database. + void CreateTables(SQLite::Connection& connection); + + // Performs the necessary packaging steps for the delta tables. + void PrepareTablesForPackaging(SQLite::Connection& connection); +} diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaViews.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaViews.cpp new file mode 100644 index 0000000000..0c3364ae49 --- /dev/null +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaViews.cpp @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "Microsoft/Schema/2_1/DeltaViews.h" +#include "Microsoft/Schema/2_1/DeltaTables.h" +#include "Microsoft/Schema/2_1/Interface.h" + +#include "Microsoft/Schema/2_0/PackagesTable.h" +#include "Microsoft/Schema/2_0/OneToManyTableWithMap.h" +#include "Microsoft/Schema/2_0/SystemReferenceStringTable.h" + +#include +#include +#include + + +namespace AppInstaller::Repository::Microsoft::Schema::V2_1::Delta +{ + using namespace std::string_view_literals; + using namespace SQLite::Builder; + + namespace + { + // The schema name that the baseline database is attached under. + constexpr std::string_view s_Delta_BaselineSchema = "baseline"sv; + + // Aliases for the two sides of a merge. The correlated subqueries below name the same + // table on both sides, so the aliases are what keep the two references apart. + constexpr std::string_view s_Delta_BaselineAlias = "b"sv; + constexpr std::string_view s_Delta_DeltaAlias = "d"sv; + constexpr std::string_view s_Delta_PackagesAlias = "p"sv; + + // Appends a test that the package owning the current baseline row still exists. + // + // When a package is removed, the delta records that fact once, in its packages table; it + // does not write a removal row for each of the package's associations. This is therefore + // the only thing standing between a removed package and its associations continuing to + // appear in the merged data. + void AppendPackageNotRemoved(StatementBuilder& builder, std::string_view packageColumn) + { + builder.NotExists().BeginParenthetical(). + Select(SQLite::RowIDName). + From(GetTableName(V2_0::PackagesTable::TableName())).As(s_Delta_PackagesAlias). + Where(QualifiedColumn{ s_Delta_PackagesAlias, SQLite::RowIDName }). + Equals(QualifiedColumn{ s_Delta_BaselineAlias, packageColumn }). + And(QualifiedColumn{ s_Delta_PackagesAlias, IsRemovedColumnName() }).EqualsLiteral(1). + EndParenthetical(); + } + + // Creates the view that merges the packages themselves. + // + // The delta holds a row for every package that changed and every package that was removed, + // both keyed by the rowid that the package also has in the baseline. A baseline package is + // therefore superseded whenever the delta mentions its rowid at all: the delta row replaces + // it when it changed, and stands for its absence when it was removed. + void CreatePackagesView(SQLite::Connection& connection) + { + std::string deltaPackages = GetTableName(V2_0::PackagesTable::TableName()); + + StatementBuilder builder; + builder.CreateTempView(V2_0::PackagesTable::TableName()). + Select({ + SQLite::RowIDName, + V2_0::PackagesTable::IdColumn::Name, + V2_0::PackagesTable::NameColumn::Name, + V2_0::PackagesTable::MonikerColumn::Name, + V2_0::PackagesTable::LatestVersionColumn::Name, + V2_0::PackagesTable::ARPMinVersionColumn::Name, + V2_0::PackagesTable::ARPMaxVersionColumn::Name, + V2_0::PackagesTable::HashColumn::Name }). + From(deltaPackages). + Where(IsRemovedColumnName()).EqualsLiteral(0). + UnionAll(). + Select({ + QualifiedColumn{ s_Delta_BaselineAlias, SQLite::RowIDName }, + QualifiedColumn{ s_Delta_BaselineAlias, V2_0::PackagesTable::IdColumn::Name }, + QualifiedColumn{ s_Delta_BaselineAlias, V2_0::PackagesTable::NameColumn::Name }, + QualifiedColumn{ s_Delta_BaselineAlias, V2_0::PackagesTable::MonikerColumn::Name }, + QualifiedColumn{ s_Delta_BaselineAlias, V2_0::PackagesTable::LatestVersionColumn::Name }, + QualifiedColumn{ s_Delta_BaselineAlias, V2_0::PackagesTable::ARPMinVersionColumn::Name }, + QualifiedColumn{ s_Delta_BaselineAlias, V2_0::PackagesTable::ARPMaxVersionColumn::Name }, + QualifiedColumn{ s_Delta_BaselineAlias, V2_0::PackagesTable::HashColumn::Name } }). + From(QualifiedTable{ s_Delta_BaselineSchema, V2_0::PackagesTable::TableName() }).As(s_Delta_BaselineAlias). + Where().NotExists().BeginParenthetical(). + Select(SQLite::RowIDName).From(deltaPackages).As(s_Delta_DeltaAlias). + Where(QualifiedColumn{ s_Delta_DeltaAlias, SQLite::RowIDName }). + Equals(QualifiedColumn{ s_Delta_BaselineAlias, SQLite::RowIDName }). + EndParenthetical(); + + builder.Execute(connection); + } + + // Creates the view that merges a table associating values with packages. This covers both + // the system reference tables, which hold the value inline, and the one to many map tables, + // which hold a reference to it; the two have the same shape as far as merging is concerned. + // + // The delta records only the associations that changed, rather than the full current set + // for a changed package. A baseline association therefore survives unless the delta names + // that exact pair, or the package it belongs to has gone away entirely. + void CreateAssociationView( + SQLite::Connection& connection, + std::string_view viewName, + const std::string& deltaTableName, + std::string_view valueColumn, + std::string_view packageColumn) + { + StatementBuilder builder; + builder.CreateTempView(viewName). + Select({ valueColumn, packageColumn }). + From(deltaTableName). + Where(IsRemovedColumnName()).EqualsLiteral(0). + UnionAll(). + Select({ + QualifiedColumn{ s_Delta_BaselineAlias, valueColumn }, + QualifiedColumn{ s_Delta_BaselineAlias, packageColumn } }). + From(QualifiedTable{ s_Delta_BaselineSchema, viewName }).As(s_Delta_BaselineAlias). + Where().NotExists().BeginParenthetical(). + Select(packageColumn).From(deltaTableName).As(s_Delta_DeltaAlias). + Where(QualifiedColumn{ s_Delta_DeltaAlias, valueColumn }). + Equals(QualifiedColumn{ s_Delta_BaselineAlias, valueColumn }). + And(QualifiedColumn{ s_Delta_DeltaAlias, packageColumn }). + Equals(QualifiedColumn{ s_Delta_BaselineAlias, packageColumn }). + EndParenthetical(). + And(); + + AppendPackageNotRemoved(builder, packageColumn); + + builder.Execute(connection); + } + + // Creates the view that merges a one to many data table. + // + // Nothing is ever suppressed here. The delta only contains values that the baseline has + // never held, and it numbers them above the baseline's highest rowid, so the two sets are + // disjoint. A value that no package refers to any more is left in place; the map table + // governs what is visible, and an unreferenced value simply never appears. + void CreateValueView(SQLite::Connection& connection, std::string_view tableName, std::string_view valueColumn) + { + StatementBuilder builder; + builder.CreateTempView(tableName). + Select({ SQLite::RowIDName, valueColumn }). + From(GetTableName(tableName)). + UnionAll(). + Select({ SQLite::RowIDName, valueColumn }). + From(QualifiedTable{ s_Delta_BaselineSchema, tableName }); + + builder.Execute(connection); + } + + // Verifies that the attached baseline is the one that the delta was generated against. + void ValidateAttachedBaseline(const SQLite::Connection& connection) + { + std::optional expected = + SQLite::MetadataTable::TryGetNamedValue(connection, s_MetadataValueName_DeltaBaselineIdentifier); + + std::optional actual = + SQLite::MetadataTable::TryGetNamedValue(connection, s_MetadataValueName_BaselineIdentifier, s_Delta_BaselineSchema); + + if (!expected || !actual || expected.value() != actual.value()) + { + AICLI_LOG(Repo, Error, << "Delta expects baseline [" << expected.value_or("") << + "] but was given [" << actual.value_or("") << "]"); + THROW_HR(APPINSTALLER_CLI_ERROR_INDEX_INTEGRITY_COMPROMISED); + } + + // Ensure that the baseline also has the same schema version + auto baselineVersion = SQLite::Version::GetSchemaVersion(connection, s_Delta_BaselineSchema); + auto deltaVersion = SQLite::Version::GetSchemaVersion(connection); + + if (baselineVersion != deltaVersion) + { + AICLI_LOG(Repo, Error, << "Delta at version [" << deltaVersion << "] cannot use a baseline at version [" << baselineVersion << "]"); + THROW_HR(APPINSTALLER_CLI_ERROR_INDEX_INTEGRITY_COMPROMISED); + } + } + } + + void SetupReadMode(SQLite::Connection& connection, const SQLite::DatabaseSpecifier& baseline) + { + AICLI_LOG(Repo, Info, << "Setting up delta read mode with baseline [" << baseline.Path() << "]"); + + { + StatementBuilder builder; + builder.Attach(baseline, s_Delta_BaselineSchema); + builder.Execute(connection); + } + + try + { + ValidateAttachedBaseline(connection); + } + catch (...) + { + // Best effort attempt to restore the state + try + { + StatementBuilder builder; + builder.Detach(s_Delta_BaselineSchema); + builder.Execute(connection); + } + CATCH_LOG(); + + throw; + } + + CreatePackagesView(connection); + + for (const auto& table : SystemReferenceTables()) + { + CreateAssociationView( + connection, + table.TableName, + GetTableName(table.TableName), + table.ValueName, + V2_0::details::SystemReferenceStringTableGetPrimaryColumnName()); + } + + for (const auto& table : OneToManyTables()) + { + CreateValueView(connection, table.TableName, table.ValueName); + + CreateAssociationView( + connection, + V2_0::details::OneToManyTableGetMapTableName(table.TableName), + GetMapTableName(table.TableName), + table.ValueName, + V2_0::details::OneToManyTableGetManifestColumnName()); + } + } +} diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaViews.h b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaViews.h new file mode 100644 index 0000000000..0bded48d1b --- /dev/null +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/DeltaViews.h @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include + + +namespace AppInstaller::Repository::Microsoft::Schema::V2_1::Delta +{ + // Attaches the given baseline database to the delta database that the connection is open on, + // then defines a temporary view for each 2.0 table that presents the combination of the two. + // + // The views take the 2.0 table names for themselves, and the delta's own tables are named + // distinctly, so every 2.0 read path operates on the merged data without knowing that it is + // merged. The views are temporary, so they last only as long as the connection. + void SetupReadMode(SQLite::Connection& connection, const SQLite::DatabaseSpecifier& baseline); +} diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/Interface.h b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/Interface.h new file mode 100644 index 0000000000..5d66e4dfbc --- /dev/null +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/Interface.h @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include "Microsoft/Schema/2_0/Interface.h" + +namespace AppInstaller::Repository::Microsoft::Schema::V2_1 +{ + // The change sequence from which the next delta generated against this index should be computed. + static constexpr std::string_view s_MetadataValueName_DeltaBaselineSequence = "deltaBaselineSequence"sv; + + // Identifies this index as a baseline that deltas may be generated against. + static constexpr std::string_view s_MetadataValueName_BaselineIdentifier = "baselineIdentifier"sv; + + // Written into a delta, naming the baseline that it was generated against. A delta is only + // meaningful when paired with that exact baseline, so this is checked when the two are opened. + static constexpr std::string_view s_MetadataValueName_DeltaBaselineIdentifier = "deltaBaselineIdentifier"sv; + + // Interface to schema version 2.1 exposed through ISQLiteIndex. + // Version 2.1 adds the is_removed column to the update_tracking table, + // enabling delta index generation that can represent package removals. + struct Interface : public V2_0::Interface + { + Interface(Utility::NormalizationVersion normVersion = Utility::NormalizationVersion::Initial); + + // Version 1.0 + SQLite::Version GetVersion() const override; + + // Version 2.0 + bool MigrateFrom(SQLite::Connection& connection, const ISQLiteIndex* current) override; + + // Version 2.1 + + // Designates this index as a baseline that deltas may be generated against. + void MarkAsBaseline(SQLite::Connection& connection) override; + + // Sets this index up to read the combination of a delta and the baseline it was generated + // against. Attaches the baseline and defines the merged views, after which every inherited + // read path operates on the combination. Must be called before any read. + void SetupDeltaReadMode(SQLite::Connection& connection, const SQLite::DatabaseSpecifier& baseline) override; + + protected: + // Records the baseline sequence for this index, and generates a delta index against a previous + // baseline when the caller has supplied the paths to do so. + void CreateAdditionalPackagingOutput(const SQLiteIndexContext& context) override; + }; +} diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/Interface_2_1.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/Interface_2_1.cpp new file mode 100644 index 0000000000..34fd8969fd --- /dev/null +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/2_1/Interface_2_1.cpp @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "Interface.h" +#include "Microsoft/Schema/2_0/PackageUpdateTrackingTable.h" +#include "Microsoft/Schema/2_1/DeltaGeneration.h" +#include "Microsoft/Schema/2_1/DeltaViews.h" + +#include +#include + +namespace AppInstaller::Repository::Microsoft::Schema::V2_1 +{ + Interface::Interface(Utility::NormalizationVersion normVersion) : V2_0::Interface(normVersion) + { + // Removals are recorded rather than deleted, so that delta generation can see which + // packages have gone away. + m_trackingRemovalBehavior = V2_0::PackageUpdateTrackingTable::RemovalBehavior::Record; + } + + SQLite::Version Interface::GetVersion() const + { + return { 2, 1 }; + } + + bool Interface::MigrateFrom(SQLite::Connection& connection, const ISQLiteIndex* current) + { + THROW_HR_IF_NULL(E_POINTER, current); + + // The 2.0 migration will go to 2.1 due to the removal behavior + if (V2_0::Interface::MigrateFrom(connection, current)) + { + return true; + } + + // Migration from 2.0 → 2.1 + auto currentVersion = current->GetVersion(); + if (currentVersion.MajorVersion == 2 && currentVersion.MinorVersion == 0) + { + SQLite::Savepoint savepoint = SQLite::Savepoint::Create(connection, "migrate_from_v2_1"); + V2_0::PackageUpdateTrackingTable::MigrateToRemovalTracking(connection); + savepoint.Commit(); + return true; + } + + return false; + } + + void Interface::MarkAsBaseline(SQLite::Connection& connection) + { + // A delta is a description of change rather than a whole index, so it cannot stand as the + // baseline for another one. Both forms are refused: the delta opened on its own, which + // records the baseline it was built against, and the combined form, whose tables are views + // over a union and whose underlying database is that same delta. + // This is checked first because a prepared delta has no packages table, so the check below + // would otherwise reject it for the wrong reason. + THROW_HR_IF(E_NOT_VALID_STATE, m_isDeltaReadMode); + THROW_HR_IF(E_NOT_VALID_STATE, + !SQLite::MetadataTable::TryGetNamedValue(connection, s_MetadataValueName_DeltaBaselineIdentifier).value_or(std::string{}).empty()); + + // A baseline is the thing a delta is computed against and later merged with, and the merged + // views are defined over the 2.x tables. An index that has not been prepared does not have + // them yet -- it still holds the 1.7 tables that PrepareForPackaging reads from -- so + // designating one would produce a baseline that no delta could be built from or attached to. + EnsureInternalInterface(connection); + THROW_HR_IF(E_NOT_VALID_STATE, static_cast(m_internalInterface)); + + GUID baselineIdentifier; + THROW_IF_FAILED(CoCreateGuid(&baselineIdentifier)); + + std::ostringstream stream; + stream << baselineIdentifier; + std::string value = stream.str(); + + AICLI_LOG(Repo, Info, << "Marking index as a delta baseline with identifier [" << value << "]"); + + SQLite::MetadataTable::SetNamedValue(connection, s_MetadataValueName_BaselineIdentifier, value); + } + + void Interface::SetupDeltaReadMode(SQLite::Connection& connection, const SQLite::DatabaseSpecifier& baseline) + { + Delta::SetupReadMode(connection, baseline); + + // The merged data is presented through views rather than tables, so the checks that the + // base makes to decide whether this index has been packaged cannot see it. Record that the + // question is already settled: a delta is only ever read, and only in its packaged form. + m_isDeltaReadMode = true; + m_internalInterfaceChecked = true; + } + + void Interface::CreateAdditionalPackagingOutput(const SQLiteIndexContext& context) + { + SQLite::Connection& connection = context.Connection; + + int64_t currentSequence = V2_0::PackageUpdateTrackingTable::GetCurrentChangeSequence(connection, m_trackingRemovalBehavior); + SQLite::MetadataTable::SetNamedValue(connection, s_MetadataValueName_DeltaBaselineSequence, std::to_string(currentSequence)); + + if (!context.Data.Contains(Property::DeltaBaselineIndexPath) || + !context.Data.Contains(Property::DeltaOutputPath)) + { + return; + } + + std::filesystem::path baselinePath = context.Data.Get(); + std::filesystem::path deltaOutputPath = context.Data.Get(); + + AICLI_LOG(Repo, Info, << "Generating a delta index against baseline [" << baselinePath << "]"); + + SQLite::Connection baselineConnection = SQLite::Connection::Create(baselinePath.u8string(), SQLite::Connection::OpenDisposition::ReadOnly); + + // The baseline must be a previous version of this database. + std::string databaseIdentifier = SQLite::MetadataTable::GetNamedValue(connection, SQLite::s_MetadataValueName_DatabaseIdentifier); + std::string baselineDatabaseIdentifier = SQLite::MetadataTable::GetNamedValue(baselineConnection, SQLite::s_MetadataValueName_DatabaseIdentifier); + + THROW_HR_IF(APPINSTALLER_CLI_ERROR_INDEX_INTEGRITY_COMPROMISED, databaseIdentifier != baselineDatabaseIdentifier); + + // Ensure that the baseline is earlier in the sequence. + int64_t baselineSequence = 0; + std::optional baselineSequenceString = SQLite::MetadataTable::TryGetNamedValue(baselineConnection, s_MetadataValueName_DeltaBaselineSequence); + if (baselineSequenceString && !baselineSequenceString->empty()) + { + baselineSequence = std::stoll(baselineSequenceString.value()); + } + + THROW_HR_IF(APPINSTALLER_CLI_ERROR_INDEX_INTEGRITY_COMPROMISED, currentSequence < baselineSequence); + + auto changedPackages = V2_0::PackageUpdateTrackingTable::GetUpdatesSinceSequence(connection, baselineSequence, m_trackingRemovalBehavior); + auto removedPackages = V2_0::PackageUpdateTrackingTable::GetRemovalsSinceSequence(connection, baselineSequence, m_trackingRemovalBehavior); + + Delta::Generate( + connection, + baselineConnection, + deltaOutputPath, + GetVersion(), + changedPackages, + removedPackages); + } +} diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/ISQLiteIndex.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/ISQLiteIndex.cpp index 4e21d059ca..efa1558155 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/Schema/ISQLiteIndex.cpp +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/ISQLiteIndex.cpp @@ -12,6 +12,7 @@ #include "Microsoft/Schema/1_6/Interface.h" #include "Microsoft/Schema/1_7/Interface.h" #include "Microsoft/Schema/2_0/Interface.h" +#include "Microsoft/Schema/2_1/Interface.h" namespace AppInstaller::Repository::Microsoft::Schema { @@ -25,6 +26,16 @@ namespace AppInstaller::Repository::Microsoft::Schema THROW_WIN32(ERROR_NOT_SUPPORTED); } + void ISQLiteIndex::MarkAsBaseline(SQLite::Connection&) + { + THROW_WIN32(ERROR_NOT_SUPPORTED); + } + + void ISQLiteIndex::SetupDeltaReadMode(SQLite::Connection&, const SQLite::DatabaseSpecifier&) + { + THROW_WIN32(ERROR_NOT_SUPPORTED); + } + std::unique_ptr CreateISQLiteIndex(const SQLite::Version& version) { if (version.MajorVersion == 1 || @@ -45,13 +56,14 @@ namespace AppInstaller::Repository::Microsoft::Schema return versionCreatorMap[std::min(static_cast(version.MinorVersion), versionCreatorMap.size() - 1)](); } - // Version 2.0 is designed solely for minimizing the size of the index for transport. + // Version 2.* is designed solely for minimizing the size of the index for transport. // Unless it is prepared for packaging, it will be identical to a 1.N index. if (version.MajorVersion == 2) { - constexpr std::array(*)(), 1> versionCreatorMap = + constexpr std::array(*)(), 2> versionCreatorMap = { []() { return std::unique_ptr(std::make_unique()); }, + []() { return std::unique_ptr(std::make_unique()); }, }; return versionCreatorMap[std::min(static_cast(version.MinorVersion), versionCreatorMap.size() - 1)](); diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/ISQLiteIndex.h b/src/AppInstallerRepositoryCore/Microsoft/Schema/ISQLiteIndex.h index 12de8e786f..833a570719 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/Schema/ISQLiteIndex.h +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/ISQLiteIndex.h @@ -151,6 +151,18 @@ namespace AppInstaller::Repository::Microsoft::Schema // Set the property value. virtual void SetProperty(SQLite::Connection& connection, Property property, const std::string& value); + + // Version 2.1 + + // Designates this index as a baseline that delta indexes may be generated against, giving + // it an identity that a delta can name. + // The index must be in its prepared, shipped form, and must not itself be a delta. + virtual void MarkAsBaseline(SQLite::Connection& connection); + + // Sets this index up to read the combination of a delta and the baseline that it was + // generated against, so that every subsequent read sees the merged data. Must be called + // before any read. + virtual void SetupDeltaReadMode(SQLite::Connection& connection, const SQLite::DatabaseSpecifier& baseline); }; DEFINE_ENUM_FLAG_OPERATORS(ISQLiteIndex::CreateOptions); diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/SQLiteIndexContextData.h b/src/AppInstallerRepositoryCore/Microsoft/Schema/SQLiteIndexContextData.h index e8d5670649..039c6e5ed8 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/Schema/SQLiteIndexContextData.h +++ b/src/AppInstallerRepositoryCore/Microsoft/Schema/SQLiteIndexContextData.h @@ -13,6 +13,8 @@ namespace AppInstaller::Repository::Microsoft::Schema PackageUpdateTrackingBaseTime, IntermediateFileOutputPath, DatabaseFilePath, + DeltaBaselineIndexPath, + DeltaOutputPath, Max }; @@ -44,6 +46,20 @@ namespace AppInstaller::Repository::Microsoft::Schema using value_t = std::filesystem::path; static constexpr bool SetThroughInterface = false; }; + + template <> + struct PropertyMapping + { + using value_t = std::filesystem::path; + static constexpr bool SetThroughInterface = false; + }; + + template <> + struct PropertyMapping + { + using value_t = std::filesystem::path; + static constexpr bool SetThroughInterface = false; + }; } using SQLiteIndexContextData = EnumBasedVariantMap; diff --git a/src/AppInstallerSharedLib/Public/winget/SQLiteMetadataTable.h b/src/AppInstallerSharedLib/Public/winget/SQLiteMetadataTable.h index 5e723e44cc..1e1d21c26a 100644 --- a/src/AppInstallerSharedLib/Public/winget/SQLiteMetadataTable.h +++ b/src/AppInstallerSharedLib/Public/winget/SQLiteMetadataTable.h @@ -22,19 +22,21 @@ namespace AppInstaller::SQLite static void Create(Connection& connection); // Gets the named value from the metadata table, interpreting it as the given type. + // The database names the schema to read from, which is the alias an attached database was + // given; leaving it empty reads the primary database. template - static Value GetNamedValue(const Connection& connection, std::string_view name) + static Value GetNamedValue(const Connection& connection, std::string_view name, std::string_view database = {}) { - Statement statement = GetNamedValueStatement(connection, name); + Statement statement = GetNamedValueStatement(connection, name, database); return statement.GetColumn(0); } // Gets the named value from the metadata table, interpreting it as the given type. // Returns nullopt if the value is not present. template - static std::optional TryGetNamedValue(const Connection& connection, std::string_view name) + static std::optional TryGetNamedValue(const Connection& connection, std::string_view name, std::string_view database = {}) { - std::optional statement = TryGetNamedValueStatement(connection, name); + std::optional statement = TryGetNamedValueStatement(connection, name, database); if (statement) { return statement->GetColumn(0); @@ -56,10 +58,10 @@ namespace AppInstaller::SQLite private: // Internal function that gets the named value. - static Statement GetNamedValueStatement(const Connection& connection, std::string_view name); + static Statement GetNamedValueStatement(const Connection& connection, std::string_view name, std::string_view database); // Internal function that gets the named value, or nullopt if it is not present. - static std::optional TryGetNamedValueStatement(const Connection& connection, std::string_view name); + static std::optional TryGetNamedValueStatement(const Connection& connection, std::string_view name, std::string_view database); // Internal function that sets the named value. static Statement SetNamedValueStatement(const Connection& connection, std::string_view name); diff --git a/src/AppInstallerSharedLib/Public/winget/SQLiteStatementBuilder.h b/src/AppInstallerSharedLib/Public/winget/SQLiteStatementBuilder.h index 0ef543a87a..8d454f4454 100644 --- a/src/AppInstallerSharedLib/Public/winget/SQLiteStatementBuilder.h +++ b/src/AppInstallerSharedLib/Public/winget/SQLiteStatementBuilder.h @@ -253,6 +253,9 @@ namespace AppInstaller::SQLite::Builder StatementBuilder& Where(std::string_view column); StatementBuilder& Where(const QualifiedColumn& column); + // Begin a filter clause that is not rooted at a column, such as one using `exists`. + StatementBuilder& Where(); + // A full filter clause looking for an embedded null character. // Is extremely specific to consistency checks, and so a more detailed construct is not required. StatementBuilder& WhereValueContainsEmbeddedNullCharacter(std::string_view column); @@ -284,6 +287,16 @@ namespace AppInstaller::SQLite::Builder StatementBuilder& Equals(); StatementBuilder& Equals(const QualifiedColumn& column); + // Assigns NULL to the current column in the `set` portion of an update statement. + // Only valid in an update; `Equals(nullptr)` is intentionally blocked because + // `value = NULL` is always false when used as a filter. Use IsNull for filtering. + StatementBuilder& AssignValue(std::nullptr_t); + + // Compares the current column against the value written directly into the statement + // rather than bound to it. A view definition cannot contain bound parameters, so a + // comparison within one has to be expressed this way. + StatementBuilder& EqualsLiteral(int64_t value); + template StatementBuilder& IsGreaterThan(const ValueType& value) { @@ -311,6 +324,12 @@ namespace AppInstaller::SQLite::Builder // Appends a set of value binders for the In clause. StatementBuilder& In(size_t count); + // Begins an `exists` subquery clause; follow with a parenthetical select. + StatementBuilder& Exists(); + + // Begins a `not exists` subquery clause; follow with a parenthetical select. + StatementBuilder& NotExists(); + // IsNull(true) means the value is null; IsNull(false) means the value is not null. StatementBuilder& IsNull(bool isNull = true); StatementBuilder& IsNotNull() { return IsNull(false); } @@ -320,6 +339,10 @@ namespace AppInstaller::SQLite::Builder StatementBuilder& And(const QualifiedColumn& column); StatementBuilder& Or(const QualifiedColumn& column); + // Continues a filter clause with a condition that is not introduced by a column, + // such as a further `not exists` subquery. + StatementBuilder& And(); + // Begin a join clause. // The initializer_list form enables the table name to be constructed from multiple parts. StatementBuilder& Join(std::string_view table); @@ -423,7 +446,10 @@ namespace AppInstaller::SQLite::Builder StatementBuilder& AlterTable(std::initializer_list table); // Complete an alter table statement by adding a column. + // The SubBuilder form allows for constraints such as `not null` and a default value; + // SQLite requires a non-null default when adding a column declared as not null. StatementBuilder& Add(std::string_view column, Type type); + StatementBuilder& Add(const details::SubBuilder& column); // Begin a table deletion statement. // The initializer_list form enables the table name to be constructed from multiple parts. @@ -437,6 +463,16 @@ namespace AppInstaller::SQLite::Builder StatementBuilder& DropTableIfExists(QualifiedTable table); StatementBuilder& DropTableIfExists(std::initializer_list table); + // Begin a temporary view creation statement, terminated by the `as` keyword. + // Temporary views live only for the duration of the connection, and are the only + // form of view that can reference tables in an attached database. + // The initializer_list form enables the view name to be constructed from multiple parts. + StatementBuilder& CreateTempView(std::string_view view); + StatementBuilder& CreateTempView(std::initializer_list view); + + // Combine the preceding select statement with the one that follows, retaining duplicate rows. + StatementBuilder& UnionAll(); + // Begin an index creation statement. // The initializer_list form enables the index name to be constructed from multiple parts. StatementBuilder& CreateIndex(std::string_view table); @@ -483,6 +519,15 @@ namespace AppInstaller::SQLite::Builder // Output the set portion of an update statement. StatementBuilder& Vacuum(); + // Attaches another database to the connection under the given alias. + // The target is bound as a parameter rather than embedded in the statement text. + // The alias remains valid only for the connection that executes the statement, and + // the attachment is released when that connection is closed. + StatementBuilder& Attach(const DatabaseSpecifier& specifier, std::string_view alias); + + // Releases a database previously attached under the given alias. + StatementBuilder& Detach(std::string_view alias); + // General purpose functions to begin and end a parenthetical expression. StatementBuilder& BeginParenthetical(); StatementBuilder& EndParenthetical(); diff --git a/src/AppInstallerSharedLib/Public/winget/SQLiteStorageBase.h b/src/AppInstallerSharedLib/Public/winget/SQLiteStorageBase.h index 8b4958343d..4b8a659d2d 100644 --- a/src/AppInstallerSharedLib/Public/winget/SQLiteStorageBase.h +++ b/src/AppInstallerSharedLib/Public/winget/SQLiteStorageBase.h @@ -14,15 +14,7 @@ namespace AppInstaller::SQLite struct SQLiteStorageBase { // The disposition for opening the database. - enum class OpenDisposition - { - // Open for read only. - Read, - // Open for read and write. - ReadWrite, - // The database will not change while in use; open for immutable read. - Immutable, - }; + using OpenDisposition = DatabaseDisposition; // Gets the last write time for the database. std::chrono::system_clock::time_point GetLastWriteTime() const; @@ -43,6 +35,8 @@ namespace AppInstaller::SQLite SQLiteStorageBase(const std::string& filePath, SQLiteStorageBase::OpenDisposition disposition, Utility::ManagedFile&& indexFile); + SQLiteStorageBase(const DatabaseSpecifier& specifier, Utility::ManagedFile&& indexFile); + SQLiteStorageBase(const std::string& target, SQLiteStorageBase& source); // Sets the last write time metadata value in the database. diff --git a/src/AppInstallerSharedLib/Public/winget/SQLiteVersion.h b/src/AppInstallerSharedLib/Public/winget/SQLiteVersion.h index 706c451157..57e3434b22 100644 --- a/src/AppInstallerSharedLib/Public/winget/SQLiteVersion.h +++ b/src/AppInstallerSharedLib/Public/winget/SQLiteVersion.h @@ -3,6 +3,7 @@ #pragma once #include #include +#include namespace AppInstaller::SQLite { @@ -53,7 +54,9 @@ namespace AppInstaller::SQLite bool IsLatestForMajor(uint32_t majorVersion) const; // Determines the schema version of the opened database. - static Version GetSchemaVersion(Connection& connection); + // The database names the schema to read from, which is the alias an attached database was + // given; leaving it empty reads the primary database. + static Version GetSchemaVersion(const Connection& connection, std::string_view database = {}); // Writes the current version to the given database. void SetSchemaVersion(Connection& connection) const; diff --git a/src/AppInstallerSharedLib/Public/winget/SQLiteWrapper.h b/src/AppInstallerSharedLib/Public/winget/SQLiteWrapper.h index 0f100b4394..5b7bbb73c4 100644 --- a/src/AppInstallerSharedLib/Public/winget/SQLiteWrapper.h +++ b/src/AppInstallerSharedLib/Public/winget/SQLiteWrapper.h @@ -206,6 +206,7 @@ namespace AppInstaller::SQLite }; struct Statement; + struct DatabaseSpecifier; // The connection to a database. struct Connection @@ -228,12 +229,13 @@ namespace AppInstaller::SQLite { // No flags specified. None = 0, - // Indicate that the target can be a URI. - Uri = SQLITE_OPEN_URI, }; static Connection Create(const std::string& target, OpenDisposition disposition, OpenFlags flags = OpenFlags::None); + // Creates a connection to the database that the specifier identifies. + static Connection Create(const DatabaseSpecifier& specifier); + Connection() = default; Connection(const Connection&) = delete; @@ -282,6 +284,47 @@ namespace AppInstaller::SQLite std::shared_ptr m_dbconn; }; + // How a database is intended to be used. + enum class DatabaseDisposition + { + // Open for read only. + Read, + // Open for read and write. + ReadWrite, + // The database will not change while in use; open for immutable read. + Immutable, + }; + + // Identifies a database and how it is to be used, translating that into the target string that + // SQLite requires. + // + // The translation cannot live inside the act of opening a connection, because `ATTACH` takes + // the same target string as `sqlite3_open_v2` and has to be given the identical value. Anything + // that needs to name a database therefore takes one of these rather than a bare path. + struct DatabaseSpecifier + { + DatabaseSpecifier(std::string path, DatabaseDisposition disposition); + + // The path to the database file, as given. + const std::string& Path() const { return m_path; } + + DatabaseDisposition Disposition() const { return m_disposition; } + + // The value to hand to SQLite, which is always a URI so that the disposition travels with + // the name. `ATTACH` takes no flags of its own, so a database named by a plain path would + // inherit whatever access the connection it is attached to was opened with. + const std::string& Target() const { return m_target; } + + // The connection level disposition that agrees with the target. SQLite validates the mode + // named in the URI against the flags it is opened with, so the two cannot disagree. + Connection::OpenDisposition ConnectionDisposition() const; + + private: + std::string m_path; + std::string m_target; + DatabaseDisposition m_disposition; + }; + // A SQL statement. struct Statement { diff --git a/src/AppInstallerSharedLib/SQLiteMetadataTable.cpp b/src/AppInstallerSharedLib/SQLiteMetadataTable.cpp index 80accd310b..f3aaaf9abe 100644 --- a/src/AppInstallerSharedLib/SQLiteMetadataTable.cpp +++ b/src/AppInstallerSharedLib/SQLiteMetadataTable.cpp @@ -3,7 +3,6 @@ #include "pch.h" #include "Public/winget/SQLiteMetadataTable.h" - using namespace std::literals; namespace AppInstaller::SQLite @@ -23,24 +22,42 @@ CREATE TABLE [metadata]( static constexpr std::string_view s_MetadataTableStmt_GetNamedValue = "select [value] from [metadata] where [name] = ?"sv; static constexpr std::string_view s_MetadataTableStmt_SetNamedValue = "insert or replace into [metadata] ([name], [value]) values (?, ?)"sv; + namespace + { + // The statement to read a named value from the given database, which is the alias an + // attached database was given. The unqualified form is used for the primary database so + // that the overwhelmingly common path stays the fixed string it has always been. + std::string GetNamedValueSql(std::string_view database) + { + if (database.empty()) + { + return std::string{ s_MetadataTableStmt_GetNamedValue }; + } + + std::ostringstream result; + result << "select [value] from [" << database << "].[metadata] where [name] = ?"; + return std::move(result).str(); + } + } + void MetadataTable::Create(Connection& connection) { Statement create = Statement::Create(connection, s_MetadataTable_Table_Create); create.Execute(); } - Statement MetadataTable::GetNamedValueStatement(const Connection& connection, std::string_view name) + Statement MetadataTable::GetNamedValueStatement(const Connection& connection, std::string_view name, std::string_view database) { - std::optional result = TryGetNamedValueStatement(connection, name); + std::optional result = TryGetNamedValueStatement(connection, name, database); THROW_HR_IF(E_NOT_SET, !result); return std::move(result).value(); } - std::optional MetadataTable::TryGetNamedValueStatement(const Connection& connection, std::string_view name) + std::optional MetadataTable::TryGetNamedValueStatement(const Connection& connection, std::string_view name, std::string_view database) { THROW_HR_IF(E_INVALIDARG, name.empty()); - Statement result = Statement::Create(connection, s_MetadataTableStmt_GetNamedValue); + Statement result = Statement::Create(connection, GetNamedValueSql(database)); result.Bind(1, name); if (result.Step()) diff --git a/src/AppInstallerSharedLib/SQLiteStatementBuilder.cpp b/src/AppInstallerSharedLib/SQLiteStatementBuilder.cpp index 4277ebf1b5..44c0a0b23e 100644 --- a/src/AppInstallerSharedLib/SQLiteStatementBuilder.cpp +++ b/src/AppInstallerSharedLib/SQLiteStatementBuilder.cpp @@ -324,6 +324,12 @@ namespace AppInstaller::SQLite::Builder return *this; } + StatementBuilder& StatementBuilder::Where() + { + m_stream << " WHERE"; + return *this; + } + StatementBuilder& StatementBuilder::WhereValueContainsEmbeddedNullCharacter(std::string_view column) { OutputColumns(m_stream, " WHERE instr(", column); @@ -348,10 +354,23 @@ namespace AppInstaller::SQLite::Builder { // This is almost certainly not what you want. // In SQL, value = NULL is always false. - // Use StatementBuilder::IsNull instead. + // Use StatementBuilder::IsNull instead, or StatementBuilder::AssignValue + // to assign NULL in the set portion of an update statement. THROW_HR(E_NOTIMPL); } + StatementBuilder& StatementBuilder::AssignValue(std::nullptr_t) + { + m_stream << " = NULL"; + return *this; + } + + StatementBuilder& StatementBuilder::EqualsLiteral(int64_t value) + { + m_stream << " = " << value; + return *this; + } + StatementBuilder& StatementBuilder::Equals() { m_stream << " ="; @@ -420,6 +439,18 @@ namespace AppInstaller::SQLite::Builder return *this; } + StatementBuilder& StatementBuilder::Exists() + { + m_stream << " EXISTS"; + return *this; + } + + StatementBuilder& StatementBuilder::NotExists() + { + m_stream << " NOT EXISTS"; + return *this; + } + StatementBuilder& StatementBuilder::IsNull(bool isNull) { m_stream << " IS " << (isNull ? "" : "NOT ") << "NULL"; @@ -444,6 +475,12 @@ namespace AppInstaller::SQLite::Builder return *this; } + StatementBuilder& StatementBuilder::And() + { + m_stream << " AND"; + return *this; + } + StatementBuilder& StatementBuilder::Join(std::string_view table) { OutputOperationAndTable(m_stream, " JOIN", table); @@ -740,6 +777,12 @@ namespace AppInstaller::SQLite::Builder return *this; } + StatementBuilder& StatementBuilder::Add(const details::SubBuilder& column) + { + m_stream << " ADD " << column; + return *this; + } + StatementBuilder& StatementBuilder::DropTable(std::string_view table) { OutputOperationAndTable(m_stream, "DROP TABLE", table); @@ -776,6 +819,26 @@ namespace AppInstaller::SQLite::Builder return *this; } + StatementBuilder& StatementBuilder::CreateTempView(std::string_view view) + { + OutputOperationAndTable(m_stream, "CREATE TEMP VIEW", view); + m_stream << " AS "; + return *this; + } + + StatementBuilder& StatementBuilder::CreateTempView(std::initializer_list view) + { + OutputOperationAndTable(m_stream, "CREATE TEMP VIEW", view); + m_stream << " AS "; + return *this; + } + + StatementBuilder& StatementBuilder::UnionAll() + { + m_stream << " UNION ALL "; + return *this; + } + StatementBuilder& StatementBuilder::CreateIndex(std::string_view table) { OutputOperationAndTable(m_stream, "CREATE INDEX", table); @@ -909,6 +972,21 @@ namespace AppInstaller::SQLite::Builder return *this; } + StatementBuilder& StatementBuilder::Attach(const DatabaseSpecifier& specifier, std::string_view alias) + { + m_stream << "ATTACH DATABASE ?"; + AddBindFunctor(m_bindIndex++, specifier.Target()); + OutputOperationAndTable(m_stream, " AS", alias); + return *this; + } + + StatementBuilder& StatementBuilder::Detach(std::string_view alias) + { + m_stream << "DETACH DATABASE"; + OutputOperationAndTable(m_stream, "", alias); + return *this; + } + StatementBuilder& StatementBuilder::BeginParenthetical() { m_stream << '('; diff --git a/src/AppInstallerSharedLib/SQLiteStorageBase.cpp b/src/AppInstallerSharedLib/SQLiteStorageBase.cpp index 61d50dfb78..dff989519f 100644 --- a/src/AppInstallerSharedLib/SQLiteStorageBase.cpp +++ b/src/AppInstallerSharedLib/SQLiteStorageBase.cpp @@ -39,8 +39,6 @@ namespace AppInstaller::SQLite } } - // One method for converting open disposition to proper open disposition - // another method for obtaining the right flags void SQLiteStorageBase::SetLastWriteTime() { MetadataTable::SetNamedValue(m_dbconn, s_MetadataValueName_LastWriteTime, Utility::GetCurrentUnixEpoch()); @@ -91,67 +89,16 @@ namespace AppInstaller::SQLite } SQLiteStorageBase::SQLiteStorageBase(const std::string& filePath, OpenDisposition disposition, Utility::ManagedFile&& file) : - m_indexFile(std::move(file)) + SQLiteStorageBase(DatabaseSpecifier{ filePath, disposition }, std::move(file)) { - AICLI_LOG(Repo, Info, << "Opening database for " << GetOpenDispositionString(disposition) << " at '" << filePath << "'"); - switch (disposition) - { - case OpenDisposition::Read: - m_dbconn = SQLite::Connection::Create(filePath, SQLite::Connection::OpenDisposition::ReadOnly, SQLite::Connection::OpenFlags::None); - break; - case OpenDisposition::ReadWrite: - m_dbconn = SQLite::Connection::Create(filePath, SQLite::Connection::OpenDisposition::ReadWrite, SQLite::Connection::OpenFlags::None); - break; - case OpenDisposition::Immutable: - { - // Following the algorithm set forth at https://sqlite.org/uri.html [3.1] to convert to a URI path - // The execution order builds out the string so that it shouldn't require any moves (other than growing) - std::string target; - // Add an 'arbitrary' growth size to prevent the majority of needing to grow (adding 'file:/' and '?immutable=1') - target.reserve(filePath.size() + 20); - - target += "file:"; - - bool wasLastCharSlash = false; - - if (filePath.size() >= 2 && filePath[1] == ':' && - ((filePath[0] >= 'a' && filePath[0] <= 'z') || - (filePath[0] >= 'A' && filePath[0] <= 'Z'))) - { - target += '/'; - wasLastCharSlash = true; - } - - for (char c : filePath) - { - bool wasThisCharSlash = false; - switch (c) - { - case '?': target += "%3f"; break; - case '#': target += "%23"; break; - case '\\': - case '/': - { - wasThisCharSlash = true; - if (!wasLastCharSlash) - { - target += '/'; - } - break; - } - default: target += c; break; - } + } - wasLastCharSlash = wasThisCharSlash; - } + SQLiteStorageBase::SQLiteStorageBase(const DatabaseSpecifier& specifier, Utility::ManagedFile&& file) : + m_indexFile(std::move(file)) + { + AICLI_LOG(Repo, Info, << "Opening database for " << GetOpenDispositionString(specifier.Disposition()) << " at '" << specifier.Path() << "'"); - target += "?immutable=1"; - m_dbconn = SQLite::Connection::Create(filePath, SQLite::Connection::OpenDisposition::ReadOnly, SQLite::Connection::OpenFlags::Uri); - break; - } - default: - THROW_HR(E_UNEXPECTED); - } + m_dbconn = SQLite::Connection::Create(specifier); m_version = Version::GetSchemaVersion(m_dbconn); } diff --git a/src/AppInstallerSharedLib/SQLiteVersion.cpp b/src/AppInstallerSharedLib/SQLiteVersion.cpp index 92f09a1b17..e1ab9478f4 100644 --- a/src/AppInstallerSharedLib/SQLiteVersion.cpp +++ b/src/AppInstallerSharedLib/SQLiteVersion.cpp @@ -8,10 +8,10 @@ namespace AppInstaller::SQLite { - Version Version::GetSchemaVersion(Connection& connection) + Version Version::GetSchemaVersion(const Connection& connection, std::string_view database) { - int major = MetadataTable::GetNamedValue(connection, s_MetadataValueName_MajorVersion); - int minor = MetadataTable::GetNamedValue(connection, s_MetadataValueName_MinorVersion); + int major = MetadataTable::GetNamedValue(connection, s_MetadataValueName_MajorVersion, database); + int minor = MetadataTable::GetNamedValue(connection, s_MetadataValueName_MinorVersion, database); return { static_cast(major), static_cast(minor) }; } diff --git a/src/AppInstallerSharedLib/SQLiteWrapper.cpp b/src/AppInstallerSharedLib/SQLiteWrapper.cpp index 78df7a5b9c..58722b76b9 100644 --- a/src/AppInstallerSharedLib/SQLiteWrapper.cpp +++ b/src/AppInstallerSharedLib/SQLiteWrapper.cpp @@ -198,8 +198,13 @@ namespace AppInstaller::SQLite m_dbconn = std::make_shared(); m_id = GetNextConnectionId(); AICLI_LOG(SQL, Info, << "Opening SQLite connection #" << m_id << ": '" << target << "' [" << std::hex << static_cast(disposition) << ", " << std::hex << static_cast(flags) << "]"); - // Always force connection serialization until we determine that there are situations where it is not needed - int resultingFlags = static_cast(disposition) | static_cast(flags) | SQLITE_OPEN_FULLMUTEX; + // Always force connection serialization until we determine that there are situations where it is not needed. + // URI handling is likewise unconditional. SQLite decides whether names are URIs when the connection is opened + // and applies that decision to every later `ATTACH` (https://sqlite.org/uri.html [2]), so a connection that did + // not ask for it cannot attach one -- including a connection that created its own database and so never named + // one through a DatabaseSpecifier. The same section notes that enabling it unconditionally is safe, since a + // name that does not begin with "file:" is always taken literally. + int resultingFlags = static_cast(disposition) | static_cast(flags) | SQLITE_OPEN_FULLMUTEX | SQLITE_OPEN_URI; THROW_IF_SQLITE_FAILED(sqlite3_open_v2(target.c_str(), m_dbconn->GetPtr(), resultingFlags, nullptr), nullptr); } @@ -213,6 +218,11 @@ namespace AppInstaller::SQLite return result; } + Connection Connection::Create(const DatabaseSpecifier& specifier) + { + return Create(specifier.Target(), specifier.ConnectionDisposition()); + } + void Connection::EnableICU() { AICLI_LOG(SQL, Verbose, << "Enabling ICU"); @@ -265,6 +275,104 @@ namespace AppInstaller::SQLite return m_dbconn; } + DatabaseSpecifier::DatabaseSpecifier(std::string path, DatabaseDisposition disposition) : + m_path(std::move(path)), m_disposition(disposition) + { + // The disposition is expressed as a query parameter rather than being left to the flags + // alone. `ATTACH` does not take flags: it starts from the ones the connection was opened + // with, so an attached database would otherwise be read/write whenever its host is. A mode + // may only narrow what the connection already permits, which is all this ever asks for. + std::string_view parameters; + + switch (m_disposition) + { + case DatabaseDisposition::Read: + parameters = "?mode=ro"; + break; + case DatabaseDisposition::ReadWrite: + parameters = "?mode=rw"; + break; + case DatabaseDisposition::Immutable: + // Immutability implies read only, and is not something `mode` can express. + parameters = "?immutable=1"; + break; + default: + THROW_HR(E_UNEXPECTED); + } + + // Following the algorithm set forth at https://sqlite.org/uri.html [3.1] to convert to a URI path. + // The execution order builds out the string so that it shouldn't require any moves (other than growing). + // Add an 'arbitrary' growth size to prevent the majority of needing to grow (adding 'file:/' and the parameters). + m_target.reserve(m_path.size() + 24); + + m_target += "file:"; + + size_t pathBegin = 0; + bool wasLastCharSlash = false; + + auto isSlash = [](char c) { return c == '\\' || c == '/'; }; + + if (m_path.size() >= 2 && isSlash(m_path[0]) && isSlash(m_path[1])) + { + // A UNC path only survives the conversion if its leading pair of slashes is preserved, + // and that requires an empty authority ahead of them. Collapsing them as the algorithm + // otherwise prescribes would turn the server name into the authority, which SQLite + // rejects for anything but localhost. + m_target += "////"; + pathBegin = 2; + wasLastCharSlash = true; + } + else if (m_path.size() >= 2 && m_path[1] == ':' && + ((m_path[0] >= 'a' && m_path[0] <= 'z') || + (m_path[0] >= 'A' && m_path[0] <= 'Z'))) + { + m_target += '/'; + wasLastCharSlash = true; + } + + for (size_t i = pathBegin; i < m_path.size(); ++i) + { + char c = m_path[i]; + bool wasThisCharSlash = false; + + switch (c) + { + case '%': m_target += "%25"; break; + case '?': m_target += "%3f"; break; + case '#': m_target += "%23"; break; + case '\\': + case '/': + { + wasThisCharSlash = true; + if (!wasLastCharSlash) + { + m_target += '/'; + } + break; + } + default: m_target += c; break; + } + + wasLastCharSlash = wasThisCharSlash; + } + + m_target += parameters; + } + + Connection::OpenDisposition DatabaseSpecifier::ConnectionDisposition() const + { + switch (m_disposition) + { + case DatabaseDisposition::Read: + case DatabaseDisposition::Immutable: + return Connection::OpenDisposition::ReadOnly; + case DatabaseDisposition::ReadWrite: + return Connection::OpenDisposition::ReadWrite; + default: + THROW_HR(E_UNEXPECTED); + } + } + Statement::Statement(const Connection& connection, std::string_view sql) { m_dbconn = connection.GetSharedConnection(); From 854ce32129ce8dfcf74f33546a3a07db3bd932ce Mon Sep 17 00:00:00 2001 From: JohnMcPMS Date: Tue, 22 Sep 2026 09:52:36 -0700 Subject: [PATCH 10/13] Move pipeline jobs to templates (#6526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📖 Description Move the pipeline jobs for building, testing and PowerShell to templates so that they can be more flexible. --- azure-pipelines.yml | 645 +----------------- .../TestData/localsource.json | 10 +- .../Fixtures/UnitTestFixture.cs | 4 +- .../WinGetUtilInterop.UnitTests.csproj | 1 + templates/README.md | 312 +++++++++ templates/e2e-test.template.yml | 7 +- templates/jobs-build.yml | 361 ++++++++++ templates/jobs-powershell-module.yml | 133 ++++ templates/jobs-test.yml | 284 ++++++++ 9 files changed, 1112 insertions(+), 645 deletions(-) create mode 100644 templates/README.md create mode 100644 templates/jobs-build.yml create mode 100644 templates/jobs-powershell-module.yml create mode 100644 templates/jobs-test.yml diff --git a/azure-pipelines.yml b/azure-pipelines.yml index aefe720813..848aed52fc 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -40,646 +40,15 @@ jobs: arguments: '-OutVar' workingDirectory: 'src' -# Build job creates artifacts for use in test jobs +- template: templates/jobs-build.yml + parameters: + # Use custom agent with additional resources. + pool: + name: 1es-pool-winget -- job: 'Build' - timeoutInMinutes: 120 - dependsOn: 'GetReleaseTag' - condition: always() - # Use custom agent with additional resources. - pool: - name: 1es-pool-winget - - strategy: - matrix: - x86_release: - buildConfiguration: 'Release' - buildPlatform: 'x86' - artifactIdentifier: 'x86release' - x64_release: - buildConfiguration: 'Release' - buildPlatform: 'x64' - artifactIdentifier: 'x64release' - - variables: - BuildVer: $[counter(dependencies.GetReleaseTag.outputs['GetTag.tag'], 1)] - buildOutDir: $(Build.SourcesDirectory)\src\$(buildPlatform)\$(buildConfiguration) - buildOutDirAnyCpu: $(Build.SourcesDirectory)\src\AnyCPU\$(buildConfiguration) - artifactsDir: $(Build.ArtifactStagingDirectory)\$(buildPlatform) - appxPackageDir: $(Build.ArtifactStagingDirectory)\$(buildPlatform)\AppxPackages - - steps: - - task: PowerShell@2 - displayName: Report Initial Disk Space - inputs: - targetType: 'inline' - script: 'Get-PSDrive -PSProvider FileSystem | Format-Table Name, @{Name="UsedGB";Expression={[math]::Round($_.Used/1GB,2)}}, @{Name="FreeGB";Expression={[math]::Round($_.Free/1GB,2)}}' - - - task: NuGetToolInstaller@1 - displayName: Install Nuget - - # Restores all projects, including native (vcxproj) projects - - task: NuGetCommand@2 - displayName: Restore Solution - inputs: - restoreSolution: '$(solution)' - feedsToUse: 'config' - nugetConfigPath: '$(nugetConfig)' - - # Restore these UAP packages as https://github.com/NuGet/Home/issues/7796 leads to all UAP packages being skipped for restore. - # Even though they don't need any actual restore action, they need the project.assets.json file to be created and a direct restore does that. - - task: NuGetCommand@2 - displayName: Restore AppInstallerCLIPackage - inputs: - restoreSolution: 'src\AppInstallerCLIPackage\AppInstallerCLIPackage.wapproj' - feedsToUse: 'config' - nugetConfigPath: '$(nugetConfig)' - - - task: NuGetCommand@2 - displayName: Restore AppInstallerTestMsixInstaller - inputs: - restoreSolution: 'src\AppInstallerTestMsixInstaller\AppInstallerTestMsixInstaller.wapproj' - feedsToUse: 'config' - nugetConfigPath: '$(nugetConfig)' - - # Restores only .NET core projects, but is still necessary, as without this the IndexCreationTool and LocalhostWebServer projects fail to build - - task: DotNetCoreCLI@2 - displayName: DotNet Restore - inputs: - command: 'restore' - projects: '**/*.csproj' - feedsToUse: 'config' - nugetConfigPath: '$(nugetConfig)' - - - task: CmdLine@2 - displayName: Enable Vcpkg Install - inputs: - script: | - $(VCPKG_INSTALLATION_ROOT)\vcpkg.exe integrate install - workingDirectory: '$(VCPKG_INSTALLATION_ROOT)' - - - task: PowerShell@2 - displayName: Update Binary Version - condition: not(eq(variables['Build.Reason'], 'PullRequest')) - inputs: - filePath: 'src\binver\Update-BinVer.ps1' - arguments: '-TargetFile binver\binver\version.h -BuildVersion $(BuildVer)' - workingDirectory: 'src' - - # Build all solutions in the root directory. - - task: VSBuild@1 - displayName: Build Solution - inputs: - platform: '$(buildPlatform)' - solution: '$(solution)' - configuration: '$(buildConfiguration)' - msbuildArgs: '/bl:$(artifactsDir)\msbuild.binlog - /p:AppxBundlePlatforms="$(buildPlatform)" - /p:AppxPackageDir="$(appxPackageDir)" - /p:AppxBundle=Always - /p:UapAppxPackageBuildMode=SideloadOnly - /p:PreferredToolArchitecture=x64 - /p:WingetDisableIncrementalLinkTimeCodeGeneration=true' - maximumCpuCount: true - - - task: PowerShell@2 - displayName: Report Disk Space - condition: succeededOrFailed() - inputs: - targetType: 'inline' - script: 'Get-PSDrive -PSProvider FileSystem | Format-Table Name, @{Name="UsedGB";Expression={[math]::Round($_.Used/1GB,2)}}, @{Name="FreeGB";Expression={[math]::Round($_.Free/1GB,2)}}' - - - task: MSBuild@1 - displayName: Build MSIX Test Installer File - inputs: - platform: '$(buildPlatform)' - solution: 'src\AppInstallerTestMsixInstaller\AppInstallerTestMsixInstaller.wapproj' - configuration: '$(buildConfiguration)' - msbuildArguments: '/p:AppxPackageOutput="$(Build.ArtifactStagingDirectory)\AppInstallerTestMsixInstaller.msix" - /p:AppxBundle=Never - /p:UapAppxPackageBuildMode=SideLoadOnly - /p:AppxPackageSigningEnabled=false' - maximumCpuCount: true - - - task: CopyFiles@2 - displayName: Copy vcpkg logs - inputs: - SourceFolder: $(VCPKG_INSTALLATION_ROOT)\buildtrees - Contents: '**\*.log' - TargetFolder: '$(artifactsDir)\vcpkgLogs' - condition: succeededOrFailed() - - - task: CopyFiles@2 - displayName: 'Copy specific build artifacts' - inputs: - Contents: | - $(buildOutDir)\WinGetUtil\WinGetUtil.dll - $(buildOutDir)\WinGetUtil\WinGetUtil.pdb - TargetFolder: '$(artifactsDir)' - - - task: PowerShell@2 - displayName: Create Package Layout - inputs: - filePath: 'src\AppInstallerCLIPackage\Execute-AppxRecipe.ps1' - arguments: '-AppxRecipePath AppInstallerCLIPackage\bin\$(buildPlatform)\$(buildConfiguration)\AppInstallerCLIPackage.build.appxrecipe -LayoutPath $(artifactsDir)\DevPackage -Force -Verbose' - workingDirectory: 'src' - - - task: CopyFiles@2 - displayName: 'Copy native binaries for Microsoft.WinGet.Client (net8)' - inputs: - SourceFolder: $(buildOutDir) - Contents: | - Microsoft.Management.Deployment.InProc\Microsoft.Management.Deployment.dll - Microsoft.Management.Deployment\Microsoft.Management.Deployment.winmd - WindowsPackageManager\WindowsPackageManager.dll - UndockedRegFreeWinRT\winrtact.dll - TargetFolder: $(buildOutDirAnyCpu)\PowerShell\Microsoft.WinGet.Client\net8.0-windows10.0.26100.0\SharedDependencies\$(BuildPlatform) - flattenFolders: true - - - task: CopyFiles@2 - displayName: 'Copy native binaries for Microsoft.WinGet.Client (net48)' - inputs: - SourceFolder: $(buildOutDir) - Contents: | - Microsoft.Management.Deployment.InProc\Microsoft.Management.Deployment.dll - Microsoft.Management.Deployment\Microsoft.Management.Deployment.winmd - WindowsPackageManager\WindowsPackageManager.dll - UndockedRegFreeWinRT\winrtact.dll - TargetFolder: $(buildOutDirAnyCpu)\PowerShell\Microsoft.WinGet.Client\net48\SharedDependencies\$(BuildPlatform) - flattenFolders: true - - - task: CopyFiles@2 - displayName: 'Copy native binaries for Microsoft.WinGet.Configuration' - inputs: - SourceFolder: $(buildOutDir) - Contents: | - Microsoft.Management.Configuration\Microsoft.Management.Configuration.dll - TargetFolder: $(buildOutDirAnyCpu)\PowerShell\Microsoft.WinGet.Configuration\SharedDependencies\$(BuildPlatform) - flattenFolders: true - - - task: CopyFiles@2 - displayName: 'Copy managed binaries for Microsoft.WinGet.Configuration in arch specific' - inputs: - SourceFolder: $(buildOutDirAnyCpu) - Contents: | - Microsoft.Management.Configuration.Projection\net8.0-windows10.0.26100.0\Microsoft.Management.Configuration.Projection.dll - TargetFolder: $(buildOutDirAnyCpu)\PowerShell\Microsoft.WinGet.Configuration\SharedDependencies\$(BuildPlatform) - flattenFolders: true - - - task: CopyFiles@2 - displayName: 'Copy Microsoft.WinGet.UnitTests AnyCPU Files' - inputs: - SourceFolder: '$(buildOutDirAnyCpu)\Microsoft.WinGet.UnitTests\net8.0-windows10.0.26100.0' - TargetFolder: '$(artifactsDir)\Microsoft.WinGet.UnitTests\' - CleanTargetFolder: true - OverWrite: true - - - task: CopyFiles@2 - displayName: 'Copy PowerShell AnyCPU Module Files' - inputs: - SourceFolder: '$(buildOutDirAnyCpu)\PowerShell' - TargetFolder: '$(artifactsDir)\PowerShell' - - - task: CopyFiles@2 - displayName: 'Copy binaries' - inputs: - SourceFolder: '$(buildOutDir)' - TargetFolder: '$(artifactsDir)' - Contents: | - AppInstallerCLIE2ETests\** - AppInstallerCLITests\** - ComInprocTestbed\** - Microsoft.Management.Configuration\** - Microsoft.Management.Configuration.UnitTests\** - Microsoft.Management.Configuration.OutOfProc\** - - - task: CopyFiles@2 - displayName: 'Copy Files: WinGetUtilInterop.UnitTests' - inputs: - SourceFolder: '$(Build.SourcesDirectory)\src\WinGetUtilInterop.UnitTests\bin\$(buildPlatform)\$(BuildConfiguration)\net8.0' - TargetFolder: '$(artifactsDir)\WinGetUtilInterop.UnitTests\' - CleanTargetFolder: true - OverWrite: true - - - task: CopyFiles@2 - displayName: 'Copy WinGetUtil to WinGetUtilInterop.UnitTests folder' - inputs: - Contents: | - $(buildOutDir)\WinGetUtil\WinGetUtil.dll - TargetFolder: '$(artifactsDir)\WinGetUtilInterop.UnitTests\' - flattenFolders: true - - - task: CopyFiles@2 - displayName: 'Copy LocalhostWebServer to E2ETests' - inputs: - SourceFolder: '$(buildOutDir)\LocalhostWebServer' - TargetFolder: '$(artifactsDir)\E2ETests\LocalhostWebServer' - - # Invoke E2E setup to generate the TestLocalIndex; could optimize out some of its steps if needed - - template: templates/e2e-setup.yml - parameters: - sourceDir: $(Build.SourcesDirectory) - localhostWebServerArgs: '-BuildRoot $(artifactsDir)\E2ETests\LocalhostWebServer -StaticFileRoot $(Agent.TempDirectory)\TestLocalIndex -LocalSourceJson $(Build.SourcesDirectory)\src\AppInstallerCLIE2ETests\TestData\localsource.json -TestDataPath $(Build.SourcesDirectory)\src\AppInstallerCLIE2ETests\TestData -ExitBeforeRun' - signingCertOutDir: $(artifactsDir)\E2ETests - - - task: CopyFiles@2 - displayName: 'Copy TestLocalIndex' - inputs: - SourceFolder: '$(Agent.TempDirectory)\TestLocalIndex' - TargetFolder: '$(artifactsDir)\E2ETests\TestLocalIndex' - - - task: CopyFiles@2 - displayName: 'Copy TestData' - inputs: - SourceFolder: '$(Build.SourcesDirectory)\src\AppInstallerCLIE2ETests\TestData\' - TargetFolder: '$(artifactsDir)\E2ETests\TestData' - - - task: CopyFiles@2 - displayName: 'Copy Dev Package Dependencies' - inputs: - SourceFolder: '$(appxPackageDir)\AppInstallerCLIPackage_0.0.2.0_Test\Dependencies\$(buildPlatform)\' - TargetFolder: '$(artifactsDir)\E2ETests\DevPackageDependencies' - - - task: CopyFiles@2 - displayName: 'Copy test scripts to artifacts' - inputs: - Contents: | - $(Build.SourcesDirectory)\src\PowerShell\scripts\Execute-WinGetTests.ps1 - $(Build.SourcesDirectory)\src\PowerShell\tests\** - $(Build.SourcesDirectory)\src\LocalhostWebServer\Run-LocalhostWebServer.ps1 - TargetFolder: '$(artifactsDir)\E2ETests\Scripts' - flattenFolders: true - - - task: PublishPipelineArtifact@1 - displayName: Publish Pipeline Artifacts - inputs: - targetPath: '$(artifactsDir)' - artifact: 'Build.$(artifactIdentifier)' - condition: succeededOrFailed() - - - task: ComponentGovernanceComponentDetection@0 - displayName: Component Governance - inputs: - scanType: 'Register' - verbosity: 'Verbose' - alertWarningLevel: 'High' - -# Test job runs tests using build artifacts - -- job: 'Test' - timeoutInMinutes: 120 - dependsOn: 'Build' - condition: succeeded('Build') - - strategy: - matrix: - x86_release: - buildConfiguration: 'Release' - buildPlatform: 'x86' - artifactIdentifier: 'x86release' - x64_release: - buildConfiguration: 'Release' - buildPlatform: 'x64' - artifactIdentifier: 'x64release' - - variables: - buildOutDir: $(Pipeline.Workspace)\Build.$(artifactIdentifier) - artifactsDir: $(Build.ArtifactStagingDirectory) - packageLayoutDir: $(Pipeline.Workspace)\Build.$(artifactIdentifier)\DevPackage - - steps: - - task: DownloadPipelineArtifact@2 - displayName: 'Download Build Artifacts' - inputs: - artifact: 'Build.$(artifactIdentifier)' - path: '$(buildOutDir)' - - - task: PowerShell@2 - displayName: Install Tests Dependencies - inputs: - targetType: 'inline' - script: | - Get-ChildItem $(buildOutDir)\E2ETests\DevPackageDependencies -Filter *.appx | %{ Add-AppxPackage $_.FullName } - - - task: VisualStudioTestPlatformInstaller@1 - displayName: Prepare VSTest for E2E Tests - inputs: - packageFeedSelector: 'nugetOrg' +- template: templates/jobs-test.yml - - task: CmdLine@2 - displayName: Start HAM trace - condition: and(succeededOrFailed(), eq(variables['System.debug'], true)) - inputs: - script: 'wpr -start $(Build.SourcesDirectory)\tools\HAMTrace\WER.HostActivityManager.wprp -filemode' - - - powershell: | - Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force - Install-Module Microsoft.WinGet.Client -Repository PSGallery -Force - try { Repair-WingetPackageManager -Version 1.28.240 -Verbose } catch { $_.Exception } - Install-WinGetPackage -Id Microsoft.Sysinternals.PsTools -Source winget - displayName: Install Sysinternals PsTools Using Winget - condition: succeededOrFailed() - - - task: CmdLine@2 - displayName: Complete HAM trace - condition: and(succeededOrFailed(), eq(variables['System.debug'], true)) - inputs: - script: 'wpr -stop "$(artifactsDir)\HamTrace.etl"' - - - pwsh: | - $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") - PsExec -accepteula -s -i $(buildOutDir)\AppInstallerCLITests\AppInstallerCLITests.exe -logto $(artifactsDir)\AICLI-Unpackaged-System.log -mdmpto $(artifactsDir)\AICLI-Unpackaged-System.mdmp -s -r junit -o $(artifactsDir)\TEST-AppInstallerCLI-Unpackaged-System.xml - displayName: Run Unit Tests Unpackaged Under System Context - workingDirectory: '$(buildOutDir)\AppInstallerCLITests' - condition: succeededOrFailed() - - - powershell: | - Uninstall-WinGetPackage -Id Microsoft.Sysinternals.PsTools -Source winget - displayName: Clean up Sysinternals PsTools - condition: succeededOrFailed() - - - task: PowerShell@2 - displayName: Run Unit Tests Packaged - inputs: - filePath: 'src\AppInstallerCLITests\Run-TestsInPackage.ps1' - arguments: '-Args "~[pips]" -BuildRoot $(buildOutDir) -PackageRoot $(packageLayoutDir) -LogTarget $(artifactsDir)\AICLI-Packaged.log -MdmpTarget $(artifactsDir)\AICLI-Packaged.mdmp -TestResultsTarget $(artifactsDir)\TEST-AppInstallerCLI-Packaged.xml -ScriptWait' - workingDirectory: 'src' - condition: succeededOrFailed() - - - task: PublishTestResults@2 - displayName: Publish Unit Test Results - inputs: - testResultsFormat: 'JUnit' - testResultsFiles: '$(artifactsDir)\TEST-*.xml' - failTaskOnFailedTests: true - condition: succeededOrFailed() - - - task: PowerShell@2 - displayName: 'Set program files directory' - inputs: - targetType: 'inline' - script: | - if ("$(buildPlatform)" -eq "x86") { - Write-Host "##vso[task.setvariable variable=platformProgramFiles;]${env:ProgramFiles(x86)}" - } else { - Write-Host "##vso[task.setvariable variable=platformProgramFiles;]${env:ProgramFiles}" - } - condition: succeededOrFailed() - - # Resolves resource strings utilized by InProc E2E tests. - - task: CopyFiles@2 - displayName: 'Copy resources.pri to dotnet directory' - inputs: - SourceFolder: '$(buildOutDir)\DevPackage' - TargetFolder: '$(platformProgramFiles)\dotnet' - Contents: resources.pri - condition: succeededOrFailed() - - # Winmd accessed by test runner process (dotnet.exe) - - task: CopyFiles@2 - displayName: 'Copy winmd to dotnet directory' - inputs: - SourceFolder: '$(buildOutDir)\DevPackage' - TargetFolder: '$(platformProgramFiles)\dotnet' - Contents: Microsoft.Management.Deployment.winmd - condition: succeededOrFailed() - - - template: templates/e2e-setup.yml - parameters: - sourceDir: $(Build.SourcesDirectory) - localhostWebServerArgs: '-BuildRoot $(buildOutDir)\E2ETests\LocalhostWebServer -StaticFileRoot $(buildOutDir)\E2ETests\TestLocalIndex -SourceCert $(buildOutDir)\E2ETests\TestSigningCert.cer' - - - template: templates/e2e-test.template.yml - parameters: - title: "E2E Tests Packaged" - isPackaged: true - filter: "TestCategory!=InProcess&TestCategory!=OutOfProcess" - - - template: templates/e2e-test.template.yml - parameters: - title: "Microsoft.Management.Deployment E2E Tests (In-process)" - isPackaged: false - filter: "TestCategory=InProcess" - - - template: templates/e2e-test.template.yml - parameters: - title: "Microsoft.Management.Deployment E2E Tests (Out-of-process)" - isPackaged: true - filter: "TestCategory=OutOfProcess" - - - task: CopyFiles@2 - displayName: 'Copy E2E Tests Package Log to artifacts folder' - inputs: - SourceFolder: '$(temp)\E2ETestLogs' - TargetFolder: '$(artifactsDir)\PackagedLog' - condition: succeededOrFailed() - - - task: VSTest@3 - displayName: 'Run tests: Microsoft.WinGet.UnitTests' - inputs: - testSelector: 'testAssemblies' - testAssemblyVer2: 'Microsoft.WinGet.UnitTests.dll' - searchFolder: '$(buildOutDir)\Microsoft.WinGet.UnitTests' - codeCoverageEnabled: true - platform: '$(buildPlatform)' - configuration: '$(BuildConfiguration)' - diagnosticsEnabled: true - condition: succeededOrFailed() - - - task: VSTest@3 - displayName: 'Run tests: WinGetUtilInterop.UnitTests' - inputs: - testSelector: 'testAssemblies' - testAssemblyVer2: 'WinGetUtilInterop.UnitTests.dll' - searchFolder: '$(buildOutDir)\WinGetUtilInterop.UnitTests' - codeCoverageEnabled: true - platform: '$(buildPlatform)' - configuration: '$(BuildConfiguration)' - diagnosticsEnabled: true - condition: succeededOrFailed() - - - task: VSTest@3 - displayName: 'Run tests: Microsoft.Management.Configuration.UnitTests (InProc)' - inputs: - testRunTitle: Microsoft.Management.Configuration.UnitTests (InProc) - testSelector: 'testAssemblies' - testAssemblyVer2: '**\Microsoft.Management.Configuration.UnitTests.dll' - searchFolder: '$(buildOutDir)\Microsoft.Management.Configuration.UnitTests' - testFiltercriteria: 'Category=InProc' - codeCoverageEnabled: false - platform: '$(buildPlatform)' - configuration: '$(BuildConfiguration)' - diagnosticsEnabled: true - condition: succeededOrFailed() - - - task: PowerShell@2 - displayName: Prepare for Microsoft.Management.Configuration.UnitTests (OutOfProc) - inputs: - filePath: 'src\Microsoft.Management.Configuration.OutOfProc\Prepare-ConfigurationOOPTests.ps1' - arguments: '-BuildOutputPath $(buildOutDir) -PackageLayoutPath $(packageLayoutDir)' - condition: succeededOrFailed() - - - task: VSTest@3 - displayName: 'Run tests: Microsoft.Management.Configuration.UnitTests (OutOfProc)' - inputs: - testRunTitle: Microsoft.Management.Configuration.UnitTests (OutOfProc) - testSelector: 'testAssemblies' - testAssemblyVer2: '**\Microsoft.Management.Configuration.UnitTests.dll' - searchFolder: '$(buildOutDir)\Microsoft.Management.Configuration.UnitTests' - testFiltercriteria: 'Category=OutOfProc' - codeCoverageEnabled: true - platform: '$(buildPlatform)' - configuration: '$(BuildConfiguration)' - condition: succeededOrFailed() - - - task: PowerShell@2 - displayName: Collect logs for Microsoft.Management.Configuration.UnitTests (OutOfProc) - inputs: - filePath: 'src\Microsoft.Management.Configuration.OutOfProc\Collect-ConfigurationOOPTests.ps1' - arguments: '-TargetLocation $(artifactsDir)\ConfigOOPTestsLog' - condition: succeededOrFailed() - - - powershell: Get-Process LocalhostWebServer | Stop-Process - displayName: Stop LocalhostWebServer - condition: succeededOrFailed() - - - task: PowerShell@2 - displayName: 'Copy GA WinGet Log to artifacts folder' - inputs: - targetType: 'inline' - script: | - $source = "$env:LocalAppData\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\DiagOutputDir" - $destination = "$(artifactsDir)\GA_WinGet_Logs" - if (Test-Path $source) { - Copy-Item -Path $source -Destination $destination -Recurse -Force - } else { - Write-Host "WinGet logs not found at $source" - } - condition: succeededOrFailed() - - - task: PowerShell@2 - displayName: 'Copy Application Event Logs to Artifacts' - inputs: - targetType: 'inline' - script: | - $source = "$env:SystemRoot\System32\winevt\Logs\Application.evtx" - $destination = "$(artifactsDir)\Application.evtx" - if (Test-Path $source) { - Copy-Item -Path $source -Destination $destination -Force - } else { - Write-Host "Application event log not found at $source" - } - condition: succeededOrFailed() - - - task: PublishPipelineArtifact@1 - displayName: Publish Pipeline Artifacts - inputs: - targetPath: '$(artifactsDir)' - artifact: 'Test.$(artifactIdentifier).$(System.JobAttempt)' - condition: succeededOrFailed() - -# Build and test PowerShell module - -- job: 'BuildPowerShellModule' - timeoutInMinutes: 120 - dependsOn: 'Build' - condition: succeeded('Build') - variables: - buildOutDir: $(Pipeline.Workspace)\Build.x64Release - - steps: - - task: DownloadPipelineArtifact@2 - displayName: 'Download Build Artifacts' - - - task: CopyFiles@2 - displayName: 'Copy x64 PowerShell Binaries to Output' - inputs: - SourceFolder: '$(buildOutDir)\PowerShell' - Contents: '**\*' - TargetFolder: '$(Build.ArtifactStagingDirectory)' - - - task: CopyFiles@2 - displayName: 'Copy x86 PowerShell Binaries to Output' - inputs: - SourceFolder: '$(Pipeline.Workspace)\Build.x86release\PowerShell' - Contents: '**\*' - TargetFolder: '$(Build.ArtifactStagingDirectory)' - - - task: PowerShell@2 - displayName: Generate Microsoft.WinGet.Client Help Documentation - inputs: - pwsh: true - targetType: inline - script: | - Install-Module -Name platyPS -Force - Import-Module platyPS - New-ExternalHelp -Path '$(Build.SourcesDirectory)\src\PowerShell\Help\Microsoft.WinGet.Client' -OutputPath '$(Build.ArtifactStagingDirectory)\Microsoft.WinGet.Client' - - - task: CopyFiles@2 - displayName: 'Copy Microsoft.WinGet.DSC module to staging directory' - inputs: - SourceFolder: '$(Build.SourcesDirectory)\src\PowerShell\Microsoft.WinGet.DSC' - Contents: '**\*' - TargetFolder: '$(Build.ArtifactStagingDirectory)\Microsoft.WinGet.DSC' - - - task: PowerShell@2 - displayName: Install Tests Dependencies - inputs: - targetType: 'inline' - script: | - Get-ChildItem AppxPackages\AppInstallerCLIPackage_0.0.2.0_Test\Dependencies\x64 -Filter *.appx | %{ Add-AppxPackage $_.FullName } - workingDirectory: $(buildOutDir) - - - template: templates/e2e-setup.yml - parameters: - sourceDir: $(Build.SourcesDirectory) - localhostWebServerArgs: '-BuildRoot $(buildOutDir)\E2ETests\LocalhostWebServer -StaticFileRoot $(buildOutDir)\E2ETests\TestLocalIndex -SourceCert $(buildOutDir)\E2ETests\TestSigningCert.cer' - - - pwsh: .\RunTests.ps1 -testModulesPath $(Build.ArtifactStagingDirectory) -outputPath $(Pipeline.Workspace)\PesterTest -packageLayoutPath $(buildOutDir)\DevPackage - workingDirectory: $(Build.SourcesDirectory)\src\PowerShell\tests\ - displayName: Run PowerShell 7 Tests - - - powershell: .\RunTests.ps1 -testModulesPath $(Build.ArtifactStagingDirectory) -outputPath $(Pipeline.Workspace)\WPPesterTest - workingDirectory: $(Build.SourcesDirectory)\src\PowerShell\tests\ - displayName: Run Windows PowerShell Tests - condition: succeededOrFailed() - - - powershell: Get-Process LocalhostWebServer | Stop-Process - displayName: Stop LocalhostWebServer - condition: succeededOrFailed() - - - task: PublishTestResults@2 - displayName: Publish Pester Test Results PowerShell 7 - inputs: - testResultsFormat: 'NUnit' - testResultsFiles: '$(Pipeline.Workspace)\PesterTest\Test*.xml' - failTaskOnFailedTests: true - condition: succeededOrFailed() - - - task: PublishTestResults@2 - displayName: Publish Pester Test Results Windows PowerShell - inputs: - testResultsFormat: 'NUnit' - testResultsFiles: '$(Pipeline.Workspace)\WPPesterTest\Test*.xml' - failTaskOnFailedTests: true - condition: succeededOrFailed() - - - task: PowerShell@2 - displayName: Copy WinGet Logs - inputs: - pwsh: true - targetType: inline - script: | - $sourceDir = Join-Path $env:LocalAppData Packages\WinGetDevCLI_8wekyb3d8bbwe\LocalState\DiagOutputDir - $destinationDir = Join-Path $(Build.ArtifactStagingDirectory) WinGetLogs - Copy-Item -Path $sourceDir -Destination $destinationDir -Recurse -Force - condition: succeededOrFailed() - - - task: PublishPipelineArtifact@1 - displayName: Publish PowerShell Module Artifacts - inputs: - targetPath: '$(Build.ArtifactStagingDirectory)' - condition: succeededOrFailed() +- template: templates/jobs-powershell-module.yml - job: 'Fuzzing' timeoutInMinutes: 60 diff --git a/src/AppInstallerCLIE2ETests/TestData/localsource.json b/src/AppInstallerCLIE2ETests/TestData/localsource.json index 764c430f3b..24ac9aebc8 100644 --- a/src/AppInstallerCLIE2ETests/TestData/localsource.json +++ b/src/AppInstallerCLIE2ETests/TestData/localsource.json @@ -1,8 +1,8 @@ { - "AppxManifest": "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/Package/AppxManifest.xml", + "AppxManifest": "%WINGET_SOURCE_ROOT%/src/AppInstallerCLIE2ETests/TestData/Package/AppxManifest.xml", "WorkingDirectory": "%AGENT_TEMPDIRECTORY%/TestLocalIndex", "LocalManifests": [ - "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/Manifests" + "%WINGET_SOURCE_ROOT%/src/AppInstallerCLIE2ETests/TestData/Manifests" ], "LocalInstallers": [ { @@ -14,13 +14,13 @@ { "Type": "msi", "Name": "AppInstallerTestMsiInstaller/AppInstallerTestMsiInstaller.msi", - "Input": "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestMsiInstaller.msi", + "Input": "%WINGET_SOURCE_ROOT%/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestMsiInstaller.msi", "HashToken": "" }, { "Type": "msi", "Name": "AppInstallerTestMsiInstaller/AppInstallerTestMsiInstallerV2.msi", - "Input": "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestMsiInstallerV2.msi", + "Input": "%WINGET_SOURCE_ROOT%/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestMsiInstallerV2.msi", "HashToken": "" }, { @@ -33,7 +33,7 @@ { "Type": "font", "Name": "AppInstallerTestFont/AppInstallerTestFont.ttf", - "Input": "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestFont.ttf", + "Input": "%WINGET_SOURCE_ROOT%/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestFont.ttf", "HashToken": "", "SkipSignature": true } diff --git a/src/Microsoft.Management.Configuration.UnitTests/Fixtures/UnitTestFixture.cs b/src/Microsoft.Management.Configuration.UnitTests/Fixtures/UnitTestFixture.cs index 3f9c923361..f9e2a15bee 100644 --- a/src/Microsoft.Management.Configuration.UnitTests/Fixtures/UnitTestFixture.cs +++ b/src/Microsoft.Management.Configuration.UnitTests/Fixtures/UnitTestFixture.cs @@ -38,7 +38,9 @@ public UnitTestFixture(IMessageSink messageSink) } // Use the environment variable if present, which is how ADO pipelines will find it. - string? gitSearchPath = Environment.GetEnvironmentVariable("BUILD_SOURCESDIRECTORY"); + // This is the winget-cli source root rather than the repository root, so that it is + // still correct when winget-cli is built as a subtree of another repository. + string? gitSearchPath = Environment.GetEnvironmentVariable("WINGET_SOURCE_ROOT"); if (string.IsNullOrWhiteSpace(gitSearchPath)) { diff --git a/src/WinGetUtilInterop.UnitTests/WinGetUtilInterop.UnitTests.csproj b/src/WinGetUtilInterop.UnitTests/WinGetUtilInterop.UnitTests.csproj index 2ea857a0c7..b79cb5acff 100644 --- a/src/WinGetUtilInterop.UnitTests/WinGetUtilInterop.UnitTests.csproj +++ b/src/WinGetUtilInterop.UnitTests/WinGetUtilInterop.UnitTests.csproj @@ -2,6 +2,7 @@ net8.0 + $(SolutionDir)$(Platform)\$(Configuration)\WinGetUtilInterop.UnitTests\ x64;x86 diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000000..c98213ee35 --- /dev/null +++ b/templates/README.md @@ -0,0 +1,312 @@ +# Pipeline templates + +These templates hold the Azure Pipelines jobs that build and test winget-cli. `azure-pipelines.yml` +at the repository root consumes them, and so can a pipeline in another repository that vendors +winget-cli as a subtree. + +## Layout + +| File | Kind | Purpose | +| --- | --- | --- | +| `jobs-build.yml` | jobs | Builds the solution and publishes the `Build.*` artifacts | +| `jobs-test.yml` | jobs | Unit, E2E, and configuration tests against those artifacts | +| `jobs-powershell-module.yml` | jobs | Builds and Pester-tests the PowerShell modules | +| `e2e-setup.yml` | steps | Test certificates, localhost web server, local PS repository | +| `e2e-test.template.yml` | steps | One E2E test pass, with optional COM tracing | + +The two step templates are used by the job templates; a consumer does not reference them directly. + +## `sourceRoot` + +`jobs-build.yml` and `e2e-test.template.yml` take a `sourceRoot` parameter that points at the root +of the winget-cli sources. It defaults to `$(Build.SourcesDirectory)`, so a pipeline at the +repository root needs to pass nothing. (`e2e-setup.yml` predates this and spells the same idea +`sourceDir`, which is required rather than defaulted.) + +```yaml +- template: templates/jobs-build.yml + parameters: + sourceRoot: $(Build.SourcesDirectory)\subtree +``` + +`jobs-test.yml` and `jobs-powershell-module.yml` deliberately have no such parameter: they never +check anything out and read their sources from the build artifact instead. See "The test jobs never +check out" below. + +Everything that lives in the winget-cli tree is rooted at this parameter: the solution, the wapproj +files, the PowerShell scripts invoked by `filePath`, the `workingDirectory` of those tasks, the +`.csproj` restore glob, and the `tools\` tracing profiles. + +Paths that belong to the agent or to the build output are deliberately **not** rooted at it, because +they do not move with the subtree: + +- `$(Build.ArtifactStagingDirectory)`, `$(Pipeline.Workspace)` +- `$(Agent.TempDirectory)`, `$(VCPKG_INSTALLATION_ROOT)` +- `$(buildOutDir)`, `$(buildOutDirAnyCpu)`, `$(artifactsDir)`, `$(packageLayoutDir)` + +`$(VCPKG_INSTALLATION_ROOT)` is the exception worth calling out: it is correctly *not* rooted, but a +consumer may not use the installation it points at. See `useAgentVcpkg` below. + +Nested template references need no help. Azure Pipelines resolves a `- template:` reference inside a +template file relative to *that file*, so the job templates find their siblings at any depth. Only +the entry point has to know where the subtree lives. + +## `WINGET_SOURCE_ROOT` + +Not everything that needs the source root can be handed a task input. Some winget-cli content +resolves the sources at *runtime*, from inside a process the pipeline merely launches. All three +job templates therefore export their source root as a job-scope variable named +`WINGET_SOURCE_ROOT`, which reaches every step as an environment variable. Two things read it: + +- `src\AppInstallerCLIE2ETests\TestData\localsource.json`, whose `%WINGET_SOURCE_ROOT%` tokens locate + the test installers and manifests that `LocalhostWebServer` bakes into `TestLocalIndex`. +- `Microsoft.Management.Configuration.UnitTests`, which uses it to find + `src\PowerShell\ExternalModules`. + +Nothing needs to be passed for this; in `jobs-build.yml` it follows `sourceRoot`, and in the two test +jobs it follows the artifact copy. It is listed here because it is the one part of the contract that +is neither a parameter nor a path in this directory. + +## What a consuming pipeline must provide + +**Pipeline variables.** None. `jobs-build.yml` sets what it needs at job scope — `solution`, so it +can be rooted, and `EnableDetectorVcpkg`, so Component Governance detects the vcpkg dependencies. + +**A Windows agent** with Visual Studio 2022 and vcpkg, exposing `VCPKG_INSTALLATION_ROOT`. The +Microsoft-hosted `windows-2025` image qualifies. **The `Test` and `BuildPowerShellModule` jobs need +more than the `Build` job does — see "Agent requirements" below.** + +**Outbound network access.** The test jobs reach several public endpoints, which is worth checking +before wiring this up on a restricted network: + +- PSGallery, for `Microsoft.WinGet.Client` and `platyPS` +- nuget.org, for the `VisualStudioTestPlatformInstaller` feed selector +- the `winget` source, for `Microsoft.Sysinternals.PsTools` + +## Agent requirements + +`Build` compiles and packages; it is happy on any Windows agent that meets the above. + +`Test` and `BuildPowerShellModule` additionally **deploy MSIX packages and register them for the +current user**, which is a much stronger requirement than it looks. winget's packaged sources are +MSIX packages carrying the index as an +[app extension](https://learn.microsoft.com/windows/apps/desktop/modernize/desktop-to-uwp-extensions); +adding a source deploys the package, and opening the source enumerates the extension catalog. That +catalog is **per user**, so both halves need a real, interactive user session on the agent. + +An agent whose service runs without one fails in a way that is easy to misread, because deployment +reports success: + +``` +Starting AddPackage operation #0 ... failed with error 0x80073D19 + 80073D19 An error occurred because a user was logged off. +Source add/update failed, waiting 7584 milliseconds and retrying: TestSource +Starting AddPackage operation #1 ... Successfully completed #1 +Did not find extension: PFN = WingetE2E.Tests_8wekyb3d8bbwe, ID = IndexDB +``` + +The package stages, never registers for a user, and the extension catalog comes back **empty** — no +`Examining extension` lines at all, not merely a non-matching one. Every test using a local source +then fails with `CatalogConnectException` or `APPINSTALLER_CLI_ERROR_SOURCE_DATA_MISSING` +(`0x8A15000F`). Machine-wide provisioning (`-AllUsers`) is unaffected, which can make the agent look +healthy right up until a source is added. + +Microsoft-hosted images run the agent interactively and are fine. Many self-hosted and scale-set +pools run it as a service and are not. Each job template therefore takes a `pool` parameter, so a +consumer can send these two jobs somewhere suitable while building elsewhere: + +```yaml +- template: templates/jobs-test.yml + parameters: + pool: + vmImage: windows-2025 +``` + +The parameter accepts whatever the `pool` key accepts — a `vmImage`, a `name`, `demands`. Left empty +(the default) the job inherits the pipeline's pool, so a pipeline with a single suitable pool passes +nothing and is unaffected. + +Sending these jobs to a pool a consumer's sources must not be cloned onto costs nothing, because +they are never cloned onto any pool; see the next section. + +## The test jobs never check out + +`Test` and `BuildPowerShellModule` both declare `checkout: none` and read every source file they +need from the build artifact. + +Two reasons. The jobs read scripts and test data that have to match the binaries they are testing, +and a checkout is not guaranteed to match: it resolves to whatever the consuming repository's +default branch or commit gives that job, which for a subtree consumer can drift from the sources the +artifact was built from. Taking the files from the artifact removes the question. It also means the +consuming repository is **never cloned onto these agents**, which matters when its sources are not +public and the jobs have to run on a hosted pool for the reasons above. + +What makes it work is that these two jobs only ever *read* from the source tree — they compile +nothing. `jobs-build.yml` has a `Copy sources needed by the test jobs` step that stages exactly what +they read into `Source/` inside the artifact, **preserving the directory layout**, and the jobs point +their source root at it. Layout preservation is not incidental: several of these scripts navigate +relative to their own location, so a flattened copy would break them. + +- `Microsoft.WinGet.Configuration.Tests.ps1` reaches test data through + `$PSScriptRoot\..\..\AppInstallerCLIE2ETests\TestData\Configuration` +- `Microsoft.Management.Configuration.UnitTests` expects `\src\PowerShell\ExternalModules` + +If a step in these two jobs ever comes to need another file from the tree, add it to that step's +`Contents` list; the failure mode is a missing-file error naming the path, under `…\Build.*\Source\`. + +Steps in these jobs use a `$(wingetSourceRoot)` variable rather than `${{ parameters.sourceRoot }}`, +because the artifact path is only known at run time. That variable is set by the template and is not +a consumer's to choose, which is why neither job takes a `sourceRoot`. + +## `preSteps` + +Each job template takes a `preSteps` step list, injected ahead of every winget-cli step. It exists +because a job template cannot know what a consuming repository needs done to the agent first. + +## `additionalMSBuildArgs` + +`jobs-build.yml` appends this string to both of its build tasks. + +`src\Directory.Build.props` imports any `Directory.Build.props` found above winget-cli, by design, +so a consumer's settings reach every project under `src\`. If those settings turn on the release +switches this repository defines — `WingetDisableTestHooks`, `UseProdCLSIDs`, `UseProdWingetServer`, +`WingetEnableReleaseBuild` — the result is a *product* build, not the build this pipeline's tests +expect. `WingetDisableTestHooks` compiles out members that the test projects use, so the solution +does not even build. + +```yaml +- template: subtree/templates/jobs-build.yml + parameters: + additionalMSBuildArgs: >- + /p:WingetDisableTestHooks=false + /p:UseProdCLSIDs=false +``` + +## `useAgentVcpkg` + +Defaults to `true`, matching the Microsoft-hosted images, where `VCPKG_INSTALLATION_ROOT` points at +a real vcpkg installation that this pipeline integrates and builds the native dependencies with. + +Set it to `false` when the consuming repository supplies vcpkg some other way — for example through +an MSBuild SDK that restores the ports itself. `VCPKG_INSTALLATION_ROOT` may well still be defined in +that case, since the agent image sets it, but nothing builds there. Two steps then become +meaningless and are dropped together: + +- *Enable Vcpkg Install*, which integrates an installation the build does not use. +- *Copy vcpkg logs*, which reads `$(VCPKG_INSTALLATION_ROOT)\buildtrees` — a directory that will not + exist, failing the job after a successful build. + +Dropping the log collection loses no real diagnostics: `Build Solution` writes an MSBuild binary log +to `$(artifactsDir)\msbuild.binlog`, which is published with the rest of the build artifacts and +records the vcpkg invocation and its output. + +## `releaseTagJob` + +`jobs-build.yml` stamps a build version obtained from a separate job. That job is named by the +`releaseTagJob` parameter, which defaults to `GetReleaseTag`: + +```yaml +- job: 'GetReleaseTag' + steps: + - task: PowerShell@2 + name: 'GetTag' # the job must expose a 'tag' output under this step name + ... +``` + +The name is load-bearing in two places — `dependsOn`, and the `BuildVer` counter that reads +`dependencies..outputs['GetTag.tag']`. + +Pass an empty string when the consuming pipeline has no such job: + +```yaml +- template: external/pkg/templates/jobs-build.yml + parameters: + releaseTagJob: '' +``` + +That drops the `dependsOn`, the `BuildVer` variable, and the *Update Binary Version* step together, +leaving the binaries with their checked-in version. For a validation-only pipeline that is usually +what you want, since nothing is being released. + +## Job graph + +`Build` publishes one artifact per matrix leg, named `Build.x86release` and `Build.x64release`. +`Test` and `BuildPowerShellModule` both depend on `Build` and consume those artifacts by name, so the +three jobs travel together. + +``` +[releaseTagJob] ──> Build ──┬──> Test + └──> BuildPowerShellModule +``` + +## Worked example + +A validation pipeline in a repository that carries winget-cli at `subtree`: + +```yaml +pool: + vmImage: 'windows-2025' + +jobs: +- template: subtree/templates/jobs-build.yml + parameters: + sourceRoot: $(Build.SourcesDirectory)\subtree + releaseTagJob: '' + +- template: subtree/templates/jobs-test.yml + +- template: subtree/templates/jobs-powershell-module.yml +``` + +The `- template:` paths are relative to the consuming repository's root, since that is where the +entry pipeline lives. Only the build job takes a `sourceRoot`; the test jobs read their sources from +the artifact it publishes. The value is spelled out rather than held in a variable, because a +variable referenced from a template's `variables:` block relies on nested macro expansion that is +easy to get subtly wrong. + +To stamp a version instead, add a job that produces the tag and name it in `releaseTagJob`; +`azure-pipelines.yml` in this repository is the worked example of that arrangement. + +A consumer that has to build on its own pool but can only run the test jobs on a hosted agent sends +just those two elsewhere; nothing else changes, because they are already checkout-free: + +```yaml +pool: + name: MyBuildPool + +jobs: +- template: subtree/templates/jobs-build.yml + parameters: + sourceRoot: $(Build.SourcesDirectory)\subtree + releaseTagJob: '' + +- template: subtree/templates/jobs-test.yml + parameters: + pool: + vmImage: windows-2025 + +- template: subtree/templates/jobs-powershell-module.yml + parameters: + pool: + vmImage: windows-2025 +``` + +Only the build job needs a checkout, so only it takes `sourceRoot`. + +## Editing these templates + +The failure mode to watch for is a path that works at the repository root and only breaks in the +subtree, which this repository's own pipeline will not catch. When adding a task: + +- Root anything inside the winget-cli tree at `${{ parameters.sourceRoot }}`, except in + `jobs-test.yml` and `jobs-powershell-module.yml`, which use `$(wingetSourceRoot)` because their + source comes from the build artifact. Adding a source file those two jobs read also means adding + it to the `Copy sources needed by the test jobs` step in `jobs-build.yml`. +- Never leave a `filePath`, `solution`, `restoreSolution`, `projects`, or `workingDirectory` value + as a repo-relative path such as `src\...`. Those resolve against the *consuming* repository. +- Prefer `${{ parameters.sourceRoot }}` over `$(Build.SourcesDirectory)` for source files. The latter + is the consuming repository's root, which is only the same thing at depth zero. +- Watch globs. `**/*.csproj` looks harmless but would restore the entire consuming repository. +- Keep MSBuild work inside `src\`, which is where winget-cli's `Directory.Build.props` and + `Directory.Packages.props` sit. + \ No newline at end of file diff --git a/templates/e2e-test.template.yml b/templates/e2e-test.template.yml index b62fdfba73..10959a0204 100644 --- a/templates/e2e-test.template.yml +++ b/templates/e2e-test.template.yml @@ -1,4 +1,9 @@ parameters: +# Root of the winget-cli sources. Defaults to the repository root; override when +# winget-cli is consumed as a subtree at a non-root path. +- name: sourceRoot + type: string + default: $(Build.SourcesDirectory) - name: title type: string - name: isPackaged @@ -14,7 +19,7 @@ steps: displayName: Start COM trace for ${{ parameters.title }} condition: and(succeededOrFailed(), eq(variables['System.debug'], true)) inputs: - script: 'wpr -start $(Build.SourcesDirectory)\tools\COMTrace\ComTrace.wprp -filemode' + script: 'wpr -start ${{ parameters.sourceRoot }}\tools\COMTrace\ComTrace.wprp -filemode' - task: VSTest@3 displayName: Run ${{ parameters.title }} diff --git a/templates/jobs-build.yml b/templates/jobs-build.yml new file mode 100644 index 0000000000..cf237f71c3 --- /dev/null +++ b/templates/jobs-build.yml @@ -0,0 +1,361 @@ +# Build job creates artifacts for use in test jobs + +parameters: +# Root of the winget-cli sources. Defaults to the repository root; override when +# winget-cli is consumed as a subtree at a non-root path. +- name: sourceRoot + type: string + default: $(Build.SourcesDirectory) +# Name of the job supplying the release tag, which this job depends on for the build version. +# Set to an empty string when the consuming pipeline has no such job; the binary version is then +# left unstamped. +- name: releaseTagJob + type: string + default: 'GetReleaseTag' +# Steps run before any winget-cli step, for environment preparation the consuming repository +# requires. A consumer whose repository root pins a .NET SDK in global.json needs to install it +# here, since MSBuild resolves global.json by walking up from the project, out of the subtree. +- name: preSteps + type: stepList + default: [] +# Extra MSBuild arguments for the build tasks, as a single string. A consuming repository whose +# Directory.Build.props imposes settings on winget-cli can pass /p: overrides here; command line +# properties are global and win over any assignment in a project file. +- name: additionalMSBuildArgs + type: string + default: '' +# Whether the agent's own vcpkg installation, at VCPKG_INSTALLATION_ROOT, is what builds the native +# dependencies. Set to false when a consuming repository supplies vcpkg by other means, such as an +# MSBuild SDK; the integrate step and the port log collection below are then both meaningless, +# because that variable points at an installation this build never uses. +- name: useAgentVcpkg + type: boolean + default: true +# Agent pool for this job. Leave empty to inherit the pipeline's pool, which is what a pipeline +# with a single suitable pool wants. See the "Agent requirements" section of README.md. +- name: pool + type: object + default: '' + +jobs: +- job: 'Build' + timeoutInMinutes: 120 + ${{ if ne(parameters.pool, '') }}: + pool: ${{ parameters.pool }} + ${{ if ne(parameters.releaseTagJob, '') }}: + dependsOn: ${{ parameters.releaseTagJob }} + condition: always() + + strategy: + matrix: + x86_release: + buildConfiguration: 'Release' + buildPlatform: 'x86' + artifactIdentifier: 'x86release' + x64_release: + buildConfiguration: 'Release' + buildPlatform: 'x64' + artifactIdentifier: 'x64release' + + variables: + # Overrides the pipeline level variable, which cannot be rooted at sourceRoot. + solution: ${{ parameters.sourceRoot }}\src\AppInstallerCLI.sln + # Overrides the pipeline level variable, which cannot be rooted at sourceRoot. + nugetConfig: ${{ parameters.sourceRoot }}\src\nuget.config + # Opts the Component Governance task below in to vcpkg detection, which is off by default. + # Set here rather than at pipeline level so that a consuming pipeline cannot omit it; without + # it the scan succeeds while silently ignoring the native dependencies. + EnableDetectorVcpkg: true + # Exported to every step as an environment variable, for winget-cli content that has to find + # the sources at runtime rather than through an MSBuild or task input. localsource.json and + # Microsoft.Management.Configuration.UnitTests read it; both previously read + # BUILD_SOURCESDIRECTORY, which is the repository root and so wrong in a subtree. + WINGET_SOURCE_ROOT: ${{ parameters.sourceRoot }} + ${{ if ne(parameters.releaseTagJob, '') }}: + BuildVer: $[counter(dependencies.${{ parameters.releaseTagJob }}.outputs['GetTag.tag'], 1)] + buildOutDir: ${{ parameters.sourceRoot }}\src\$(buildPlatform)\$(buildConfiguration) + buildOutDirAnyCpu: ${{ parameters.sourceRoot }}\src\AnyCPU\$(buildConfiguration) + artifactsDir: $(Build.ArtifactStagingDirectory)\$(buildPlatform) + appxPackageDir: $(Build.ArtifactStagingDirectory)\$(buildPlatform)\AppxPackages + + steps: + - ${{ each step in parameters.preSteps }}: + - ${{ step }} + + - task: PowerShell@2 + displayName: Report Initial Disk Space + inputs: + targetType: 'inline' + script: 'Get-PSDrive -PSProvider FileSystem | Format-Table Name, @{Name="UsedGB";Expression={[math]::Round($_.Used/1GB,2)}}, @{Name="FreeGB";Expression={[math]::Round($_.Free/1GB,2)}}' + + - task: NuGetToolInstaller@1 + displayName: Install Nuget + + # Restores all projects, including native (vcxproj) projects + - task: NuGetCommand@2 + displayName: Restore Solution + inputs: + restoreSolution: '$(solution)' + feedsToUse: 'config' + nugetConfigPath: '$(nugetConfig)' + + # Restore these UAP packages as https://github.com/NuGet/Home/issues/7796 leads to all UAP packages being skipped for restore. + # Even though they don't need any actual restore action, they need the project.assets.json file to be created and a direct restore does that. + - task: NuGetCommand@2 + displayName: Restore All Package Projects + inputs: + restoreSolution: '${{ parameters.sourceRoot }}\src\**\*.wapproj' + feedsToUse: 'config' + nugetConfigPath: '$(nugetConfig)' + + # Restores only .NET core projects, but is still necessary, as without this the IndexCreationTool and LocalhostWebServer projects fail to build + # Scoped to src, which is where winget-cli's Directory.Build.props / Directory.Packages.props sit. + # The projects under samples are outside that boundary and are in no solution built here, so a + # subtree consumer would restore them against its own repository's MSBuild settings for nothing. + - task: DotNetCoreCLI@2 + displayName: DotNet Restore + inputs: + command: 'restore' + projects: '${{ parameters.sourceRoot }}\src\**\*.csproj' + feedsToUse: 'config' + nugetConfigPath: '$(nugetConfig)' + + - ${{ if parameters.useAgentVcpkg }}: + - task: CmdLine@2 + displayName: Enable Vcpkg Install + inputs: + script: | + $(VCPKG_INSTALLATION_ROOT)\vcpkg.exe integrate install + workingDirectory: '$(VCPKG_INSTALLATION_ROOT)' + + - ${{ if ne(parameters.releaseTagJob, '') }}: + - task: PowerShell@2 + displayName: Update Binary Version + condition: not(eq(variables['Build.Reason'], 'PullRequest')) + inputs: + filePath: '${{ parameters.sourceRoot }}\src\binver\Update-BinVer.ps1' + arguments: '-TargetFile binver\binver\version.h -BuildVersion $(BuildVer)' + workingDirectory: '${{ parameters.sourceRoot }}\src' + + # Build all solutions in the root directory. + - task: VSBuild@1 + displayName: Build Solution + inputs: + platform: '$(buildPlatform)' + solution: '$(solution)' + configuration: '$(buildConfiguration)' + msbuildArgs: '/bl:$(artifactsDir)\msbuild.binlog + /p:AppxBundlePlatforms="$(buildPlatform)" + /p:AppxPackageDir="$(appxPackageDir)" + /p:AppxBundle=Always + /p:UapAppxPackageBuildMode=SideloadOnly + /p:PreferredToolArchitecture=x64 + /p:WingetDisableIncrementalLinkTimeCodeGeneration=true + ${{ parameters.additionalMSBuildArgs }}' + maximumCpuCount: true + + - task: PowerShell@2 + displayName: Report Disk Space + condition: succeededOrFailed() + inputs: + targetType: 'inline' + script: 'Get-PSDrive -PSProvider FileSystem | Format-Table Name, @{Name="UsedGB";Expression={[math]::Round($_.Used/1GB,2)}}, @{Name="FreeGB";Expression={[math]::Round($_.Free/1GB,2)}}' + + - task: MSBuild@1 + displayName: Build MSIX Test Installer File + inputs: + platform: '$(buildPlatform)' + solution: '${{ parameters.sourceRoot }}\src\AppInstallerTestMsixInstaller\AppInstallerTestMsixInstaller.wapproj' + configuration: '$(buildConfiguration)' + msbuildArguments: '/p:AppxPackageOutput="$(Build.ArtifactStagingDirectory)\AppInstallerTestMsixInstaller.msix" + /p:AppxBundle=Never + /p:UapAppxPackageBuildMode=SideLoadOnly + /p:AppxPackageSigningEnabled=false + ${{ parameters.additionalMSBuildArgs }}' + maximumCpuCount: true + + - ${{ if parameters.useAgentVcpkg }}: + - task: CopyFiles@2 + displayName: Copy vcpkg logs + inputs: + SourceFolder: $(VCPKG_INSTALLATION_ROOT)\buildtrees + Contents: '**\*.log' + TargetFolder: '$(artifactsDir)\vcpkgLogs' + condition: succeededOrFailed() + + - task: CopyFiles@2 + displayName: 'Copy specific build artifacts' + inputs: + Contents: | + $(buildOutDir)\WinGetUtil\WinGetUtil.dll + $(buildOutDir)\WinGetUtil\WinGetUtil.pdb + TargetFolder: '$(artifactsDir)' + + - task: PowerShell@2 + displayName: Create Package Layout + inputs: + filePath: '${{ parameters.sourceRoot }}\src\AppInstallerCLIPackage\Execute-AppxRecipe.ps1' + arguments: '-AppxRecipePath AppInstallerCLIPackage\bin\$(buildPlatform)\$(buildConfiguration)\AppInstallerCLIPackage.build.appxrecipe -LayoutPath $(artifactsDir)\DevPackage -Force -Verbose' + workingDirectory: '${{ parameters.sourceRoot }}\src' + + - task: CopyFiles@2 + displayName: 'Copy native binaries for Microsoft.WinGet.Client (net8)' + inputs: + SourceFolder: $(buildOutDir) + Contents: | + Microsoft.Management.Deployment.InProc\Microsoft.Management.Deployment.dll + Microsoft.Management.Deployment\Microsoft.Management.Deployment.winmd + WindowsPackageManager\WindowsPackageManager.dll + UndockedRegFreeWinRT\winrtact.dll + TargetFolder: $(buildOutDirAnyCpu)\PowerShell\Microsoft.WinGet.Client\net8.0-windows10.0.26100.0\SharedDependencies\$(BuildPlatform) + flattenFolders: true + + - task: CopyFiles@2 + displayName: 'Copy native binaries for Microsoft.WinGet.Client (net48)' + inputs: + SourceFolder: $(buildOutDir) + Contents: | + Microsoft.Management.Deployment.InProc\Microsoft.Management.Deployment.dll + Microsoft.Management.Deployment\Microsoft.Management.Deployment.winmd + WindowsPackageManager\WindowsPackageManager.dll + UndockedRegFreeWinRT\winrtact.dll + TargetFolder: $(buildOutDirAnyCpu)\PowerShell\Microsoft.WinGet.Client\net48\SharedDependencies\$(BuildPlatform) + flattenFolders: true + + - task: CopyFiles@2 + displayName: 'Copy native binaries for Microsoft.WinGet.Configuration' + inputs: + SourceFolder: $(buildOutDir) + Contents: | + Microsoft.Management.Configuration\Microsoft.Management.Configuration.dll + TargetFolder: $(buildOutDirAnyCpu)\PowerShell\Microsoft.WinGet.Configuration\SharedDependencies\$(BuildPlatform) + flattenFolders: true + + - task: CopyFiles@2 + displayName: 'Copy managed binaries for Microsoft.WinGet.Configuration in arch specific' + inputs: + SourceFolder: $(buildOutDirAnyCpu) + Contents: | + Microsoft.Management.Configuration.Projection\net8.0-windows10.0.26100.0\Microsoft.Management.Configuration.Projection.dll + TargetFolder: $(buildOutDirAnyCpu)\PowerShell\Microsoft.WinGet.Configuration\SharedDependencies\$(BuildPlatform) + flattenFolders: true + + - task: CopyFiles@2 + displayName: 'Copy Microsoft.WinGet.UnitTests AnyCPU Files' + inputs: + SourceFolder: '$(buildOutDirAnyCpu)\Microsoft.WinGet.UnitTests\net8.0-windows10.0.26100.0' + TargetFolder: '$(artifactsDir)\Microsoft.WinGet.UnitTests\' + CleanTargetFolder: true + OverWrite: true + + - task: CopyFiles@2 + displayName: 'Copy PowerShell AnyCPU Module Files' + inputs: + SourceFolder: '$(buildOutDirAnyCpu)\PowerShell' + TargetFolder: '$(artifactsDir)\PowerShell' + + - task: CopyFiles@2 + displayName: 'Copy binaries' + inputs: + SourceFolder: '$(buildOutDir)' + TargetFolder: '$(artifactsDir)' + Contents: | + AppInstallerCLIE2ETests\** + AppInstallerCLITests\** + ComInprocTestbed\** + Microsoft.Management.Configuration\** + Microsoft.Management.Configuration.UnitTests\** + Microsoft.Management.Configuration.OutOfProc\** + + - task: CopyFiles@2 + displayName: 'Copy Files: WinGetUtilInterop.UnitTests' + inputs: + SourceFolder: '$(buildOutDir)\WinGetUtilInterop.UnitTests' + TargetFolder: '$(artifactsDir)\WinGetUtilInterop.UnitTests\' + CleanTargetFolder: true + OverWrite: true + + - task: CopyFiles@2 + displayName: 'Copy WinGetUtil to WinGetUtilInterop.UnitTests folder' + inputs: + Contents: | + $(buildOutDir)\WinGetUtil\WinGetUtil.dll + TargetFolder: '$(artifactsDir)\WinGetUtilInterop.UnitTests\' + flattenFolders: true + + - task: CopyFiles@2 + displayName: 'Copy LocalhostWebServer to E2ETests' + inputs: + SourceFolder: '$(buildOutDir)\LocalhostWebServer' + TargetFolder: '$(artifactsDir)\E2ETests\LocalhostWebServer' + + # Invoke E2E setup to generate the TestLocalIndex; could optimize out some of its steps if needed + - template: e2e-setup.yml + parameters: + sourceDir: ${{ parameters.sourceRoot }} + localhostWebServerArgs: '-BuildRoot $(artifactsDir)\E2ETests\LocalhostWebServer -StaticFileRoot $(Agent.TempDirectory)\TestLocalIndex -LocalSourceJson ${{ parameters.sourceRoot }}\src\AppInstallerCLIE2ETests\TestData\localsource.json -TestDataPath ${{ parameters.sourceRoot }}\src\AppInstallerCLIE2ETests\TestData -ExitBeforeRun' + signingCertOutDir: $(artifactsDir)\E2ETests + + - task: CopyFiles@2 + displayName: 'Copy TestLocalIndex' + inputs: + SourceFolder: '$(Agent.TempDirectory)\TestLocalIndex' + TargetFolder: '$(artifactsDir)\E2ETests\TestLocalIndex' + + - task: CopyFiles@2 + displayName: 'Copy TestData' + inputs: + SourceFolder: '${{ parameters.sourceRoot }}\src\AppInstallerCLIE2ETests\TestData\' + TargetFolder: '$(artifactsDir)\E2ETests\TestData' + + - task: CopyFiles@2 + displayName: 'Copy Dev Package Dependencies' + inputs: + SourceFolder: '$(appxPackageDir)\AppInstallerCLIPackage_0.0.2.0_Test\Dependencies\$(buildPlatform)\' + TargetFolder: '$(artifactsDir)\E2ETests\DevPackageDependencies' + + - task: CopyFiles@2 + displayName: 'Copy test scripts to artifacts' + inputs: + Contents: | + ${{ parameters.sourceRoot }}\src\PowerShell\scripts\Execute-WinGetTests.ps1 + ${{ parameters.sourceRoot }}\src\PowerShell\tests\** + ${{ parameters.sourceRoot }}\src\LocalhostWebServer\Run-LocalhostWebServer.ps1 + TargetFolder: '$(artifactsDir)\E2ETests\Scripts' + flattenFolders: true + + # Everything under the winget-cli tree that the Test and BuildPowerShellModule jobs read at run + # time, staged with its directory structure intact so that the artifact copy can substitute for + # the sources. Those jobs check out nothing and point their source root here, so this step is not + # optional; see the "The test jobs never check out" section of README.md. + - task: CopyFiles@2 + displayName: 'Copy sources needed by the test jobs' + inputs: + SourceFolder: '${{ parameters.sourceRoot }}' + Contents: | + src\AppInstallerCLIE2ETests\TestData\** + src\AppInstallerCLITests\Run-TestsInPackage.ps1 + src\LocalhostWebServer\Run-LocalhostWebServer.ps1 + src\Microsoft.Management.Configuration.OutOfProc\*.ps1 + src\PowerShell\ExternalModules\** + src\PowerShell\Help\** + src\PowerShell\Microsoft.WinGet.DSC\** + src\PowerShell\scripts\*.ps1 + src\PowerShell\tests\** + tools\COMTrace\*.wprp + tools\HAMTrace\*.wprp + TargetFolder: '$(artifactsDir)\Source' + + - task: PublishPipelineArtifact@1 + displayName: Publish Pipeline Artifacts + inputs: + targetPath: '$(artifactsDir)' + artifact: 'Build.$(artifactIdentifier)' + condition: succeededOrFailed() + + - task: ComponentGovernanceComponentDetection@0 + displayName: Component Governance + inputs: + scanType: 'Register' + verbosity: 'Verbose' + alertWarningLevel: 'High' diff --git a/templates/jobs-powershell-module.yml b/templates/jobs-powershell-module.yml new file mode 100644 index 0000000000..b90dd8a660 --- /dev/null +++ b/templates/jobs-powershell-module.yml @@ -0,0 +1,133 @@ +# Build and test PowerShell module + +parameters: +# Steps run before any winget-cli step, for environment preparation the consuming repository +# requires. See jobs-build.yml. +- name: preSteps + type: stepList + default: [] +# Agent pool for this job. Leave empty to inherit the pipeline's pool. This job deploys MSIX +# packages, so its agent has stricter requirements than the build's; see the "Agent requirements" +# section of README.md. +- name: pool + type: object + default: '' + +jobs: +- job: 'BuildPowerShellModule' + timeoutInMinutes: 120 + dependsOn: 'Build' + condition: succeeded('Build') + ${{ if ne(parameters.pool, '') }}: + pool: ${{ parameters.pool }} + variables: + # Root of the winget-cli sources this job reads at run time. This job compiles nothing, so it + # takes those sources from the copy staged into the build artifact rather than from a checkout: + # the files are then guaranteed to be the ones the artifact was built from, and no checkout of + # the consuming repository is needed on this agent. See README.md. + wingetSourceRoot: $(Pipeline.Workspace)\Build.x64Release\Source + # Exported to every step as an environment variable. winget-cli content that has to find the + # sources at runtime, rather than through an MSBuild or task input, reads it: localsource.json + # and Microsoft.Management.Configuration.UnitTests both used to read BUILD_SOURCESDIRECTORY, + # which is the repository root and therefore wrong in a subtree. + WINGET_SOURCE_ROOT: $(wingetSourceRoot) + buildOutDir: $(Pipeline.Workspace)\Build.x64Release + + steps: + - checkout: none + + - ${{ each step in parameters.preSteps }}: + - ${{ step }} + + - task: DownloadPipelineArtifact@2 + displayName: 'Download Build Artifacts' + + - task: CopyFiles@2 + displayName: 'Copy x64 PowerShell Binaries to Output' + inputs: + SourceFolder: '$(buildOutDir)\PowerShell' + Contents: '**\*' + TargetFolder: '$(Build.ArtifactStagingDirectory)' + + - task: CopyFiles@2 + displayName: 'Copy x86 PowerShell Binaries to Output' + inputs: + SourceFolder: '$(Pipeline.Workspace)\Build.x86release\PowerShell' + Contents: '**\*' + TargetFolder: '$(Build.ArtifactStagingDirectory)' + + - task: PowerShell@2 + displayName: Generate Microsoft.WinGet.Client Help Documentation + inputs: + pwsh: true + targetType: inline + script: | + Install-Module -Name platyPS -Force + Import-Module platyPS + New-ExternalHelp -Path '$(wingetSourceRoot)\src\PowerShell\Help\Microsoft.WinGet.Client' -OutputPath '$(Build.ArtifactStagingDirectory)\Microsoft.WinGet.Client' + + - task: CopyFiles@2 + displayName: 'Copy Microsoft.WinGet.DSC module to staging directory' + inputs: + SourceFolder: '$(wingetSourceRoot)\src\PowerShell\Microsoft.WinGet.DSC' + Contents: '**\*' + TargetFolder: '$(Build.ArtifactStagingDirectory)\Microsoft.WinGet.DSC' + + - task: PowerShell@2 + displayName: Install Tests Dependencies + inputs: + targetType: 'inline' + script: | + Get-ChildItem AppxPackages\AppInstallerCLIPackage_0.0.2.0_Test\Dependencies\x64 -Filter *.appx | %{ Add-AppxPackage $_.FullName } + workingDirectory: $(buildOutDir) + + - template: e2e-setup.yml + parameters: + sourceDir: $(wingetSourceRoot) + localhostWebServerArgs: '-BuildRoot $(buildOutDir)\E2ETests\LocalhostWebServer -StaticFileRoot $(buildOutDir)\E2ETests\TestLocalIndex -SourceCert $(buildOutDir)\E2ETests\TestSigningCert.cer' + + - pwsh: .\RunTests.ps1 -testModulesPath $(Build.ArtifactStagingDirectory) -outputPath $(Pipeline.Workspace)\PesterTest -packageLayoutPath $(buildOutDir)\DevPackage + workingDirectory: $(wingetSourceRoot)\src\PowerShell\tests\ + displayName: Run PowerShell 7 Tests + + - powershell: .\RunTests.ps1 -testModulesPath $(Build.ArtifactStagingDirectory) -outputPath $(Pipeline.Workspace)\WPPesterTest + workingDirectory: $(wingetSourceRoot)\src\PowerShell\tests\ + displayName: Run Windows PowerShell Tests + condition: succeededOrFailed() + + - powershell: Get-Process LocalhostWebServer | Stop-Process + displayName: Stop LocalhostWebServer + condition: succeededOrFailed() + + - task: PublishTestResults@2 + displayName: Publish Pester Test Results PowerShell 7 + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '$(Pipeline.Workspace)\PesterTest\Test*.xml' + failTaskOnFailedTests: true + condition: succeededOrFailed() + + - task: PublishTestResults@2 + displayName: Publish Pester Test Results Windows PowerShell + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '$(Pipeline.Workspace)\WPPesterTest\Test*.xml' + failTaskOnFailedTests: true + condition: succeededOrFailed() + + - task: PowerShell@2 + displayName: Copy WinGet Logs + inputs: + pwsh: true + targetType: inline + script: | + $sourceDir = Join-Path $env:LocalAppData Packages\WinGetDevCLI_8wekyb3d8bbwe\LocalState\DiagOutputDir + $destinationDir = Join-Path $(Build.ArtifactStagingDirectory) WinGetLogs + Copy-Item -Path $sourceDir -Destination $destinationDir -Recurse -Force + condition: succeededOrFailed() + + - task: PublishPipelineArtifact@1 + displayName: Publish PowerShell Module Artifacts + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)' + condition: succeededOrFailed() diff --git a/templates/jobs-test.yml b/templates/jobs-test.yml new file mode 100644 index 0000000000..1f76455136 --- /dev/null +++ b/templates/jobs-test.yml @@ -0,0 +1,284 @@ +# Test job runs tests using build artifacts + +parameters: +# Steps run before any winget-cli step, for environment preparation the consuming repository +# requires. See jobs-build.yml. +- name: preSteps + type: stepList + default: [] +# Agent pool for this job. Leave empty to inherit the pipeline's pool. This job deploys MSIX +# packages, so its agent has stricter requirements than the build's; see the "Agent requirements" +# section of README.md. +- name: pool + type: object + default: '' + +jobs: +- job: 'Test' + timeoutInMinutes: 120 + dependsOn: 'Build' + condition: succeeded('Build') + ${{ if ne(parameters.pool, '') }}: + pool: ${{ parameters.pool }} + + strategy: + matrix: + x86_release: + buildConfiguration: 'Release' + buildPlatform: 'x86' + artifactIdentifier: 'x86release' + x64_release: + buildConfiguration: 'Release' + buildPlatform: 'x64' + artifactIdentifier: 'x64release' + + variables: + # Root of the winget-cli sources this job reads at run time. This job compiles nothing, so it + # takes those sources from the copy staged into the build artifact rather than from a checkout: + # the files are then guaranteed to be the ones the artifact was built from, and no checkout of + # the consuming repository is needed on this agent. See README.md. + wingetSourceRoot: $(Pipeline.Workspace)\Build.$(artifactIdentifier)\Source + # See jobs-build.yml. + WINGET_SOURCE_ROOT: $(wingetSourceRoot) + buildOutDir: $(Pipeline.Workspace)\Build.$(artifactIdentifier) + artifactsDir: $(Build.ArtifactStagingDirectory) + packageLayoutDir: $(Pipeline.Workspace)\Build.$(artifactIdentifier)\DevPackage + + steps: + - checkout: none + + - ${{ each step in parameters.preSteps }}: + - ${{ step }} + + - task: DownloadPipelineArtifact@2 + displayName: 'Download Build Artifacts' + inputs: + artifact: 'Build.$(artifactIdentifier)' + path: '$(buildOutDir)' + + - task: PowerShell@2 + displayName: Install Tests Dependencies + inputs: + targetType: 'inline' + script: | + Get-ChildItem $(buildOutDir)\E2ETests\DevPackageDependencies -Filter *.appx | %{ Add-AppxPackage $_.FullName } + + - task: VisualStudioTestPlatformInstaller@1 + displayName: Prepare VSTest for E2E Tests + inputs: + packageFeedSelector: 'nugetOrg' + + - task: CmdLine@2 + displayName: Start HAM trace + condition: and(succeededOrFailed(), eq(variables['System.debug'], true)) + inputs: + script: 'wpr -start $(wingetSourceRoot)\tools\HAMTrace\WER.HostActivityManager.wprp -filemode' + + - powershell: | + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force + Install-Module Microsoft.WinGet.Client -Repository PSGallery -Force + try { Repair-WingetPackageManager -Version 1.28.240 -Verbose } catch { $_.Exception } + Install-WinGetPackage -Id Microsoft.Sysinternals.PsTools -Source winget + displayName: Install Sysinternals PsTools Using Winget + condition: succeededOrFailed() + + - task: CmdLine@2 + displayName: Complete HAM trace + condition: and(succeededOrFailed(), eq(variables['System.debug'], true)) + inputs: + script: 'wpr -stop "$(artifactsDir)\HamTrace.etl"' + + - pwsh: | + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + PsExec -accepteula -s -i $(buildOutDir)\AppInstallerCLITests\AppInstallerCLITests.exe -logto $(artifactsDir)\AICLI-Unpackaged-System.log -mdmpto $(artifactsDir)\AICLI-Unpackaged-System.mdmp -s -r junit -o $(artifactsDir)\TEST-AppInstallerCLI-Unpackaged-System.xml + displayName: Run Unit Tests Unpackaged Under System Context + workingDirectory: '$(buildOutDir)\AppInstallerCLITests' + condition: succeededOrFailed() + + - powershell: | + Uninstall-WinGetPackage -Id Microsoft.Sysinternals.PsTools -Source winget + displayName: Clean up Sysinternals PsTools + condition: succeededOrFailed() + + - task: PowerShell@2 + displayName: Run Unit Tests Packaged + inputs: + filePath: '$(wingetSourceRoot)\src\AppInstallerCLITests\Run-TestsInPackage.ps1' + arguments: '-Args "~[pips]" -BuildRoot $(buildOutDir) -PackageRoot $(packageLayoutDir) -LogTarget $(artifactsDir)\AICLI-Packaged.log -MdmpTarget $(artifactsDir)\AICLI-Packaged.mdmp -TestResultsTarget $(artifactsDir)\TEST-AppInstallerCLI-Packaged.xml -ScriptWait' + workingDirectory: '$(wingetSourceRoot)\src' + condition: succeededOrFailed() + + - task: PublishTestResults@2 + displayName: Publish Unit Test Results + inputs: + testResultsFormat: 'JUnit' + testResultsFiles: '$(artifactsDir)\TEST-*.xml' + failTaskOnFailedTests: true + condition: succeededOrFailed() + + - task: PowerShell@2 + displayName: 'Set program files directory' + inputs: + targetType: 'inline' + script: | + if ("$(buildPlatform)" -eq "x86") { + Write-Host "##vso[task.setvariable variable=platformProgramFiles;]${env:ProgramFiles(x86)}" + } else { + Write-Host "##vso[task.setvariable variable=platformProgramFiles;]${env:ProgramFiles}" + } + condition: succeededOrFailed() + + # Resolves resource strings utilized by InProc E2E tests. + - task: CopyFiles@2 + displayName: 'Copy resources.pri to dotnet directory' + inputs: + SourceFolder: '$(buildOutDir)\DevPackage' + TargetFolder: '$(platformProgramFiles)\dotnet' + Contents: resources.pri + condition: succeededOrFailed() + + # Winmd accessed by test runner process (dotnet.exe) + - task: CopyFiles@2 + displayName: 'Copy winmd to dotnet directory' + inputs: + SourceFolder: '$(buildOutDir)\DevPackage' + TargetFolder: '$(platformProgramFiles)\dotnet' + Contents: Microsoft.Management.Deployment.winmd + condition: succeededOrFailed() + + - template: e2e-setup.yml + parameters: + sourceDir: $(wingetSourceRoot) + localhostWebServerArgs: '-BuildRoot $(buildOutDir)\E2ETests\LocalhostWebServer -StaticFileRoot $(buildOutDir)\E2ETests\TestLocalIndex -SourceCert $(buildOutDir)\E2ETests\TestSigningCert.cer' + + - template: e2e-test.template.yml + parameters: + sourceRoot: $(wingetSourceRoot) + title: "E2E Tests Packaged" + isPackaged: true + filter: "TestCategory!=InProcess&TestCategory!=OutOfProcess" + + - template: e2e-test.template.yml + parameters: + sourceRoot: $(wingetSourceRoot) + title: "Microsoft.Management.Deployment E2E Tests (In-process)" + isPackaged: false + filter: "TestCategory=InProcess" + + - template: e2e-test.template.yml + parameters: + sourceRoot: $(wingetSourceRoot) + title: "Microsoft.Management.Deployment E2E Tests (Out-of-process)" + isPackaged: true + filter: "TestCategory=OutOfProcess" + + - task: CopyFiles@2 + displayName: 'Copy E2E Tests Package Log to artifacts folder' + inputs: + SourceFolder: '$(temp)\E2ETestLogs' + TargetFolder: '$(artifactsDir)\PackagedLog' + condition: succeededOrFailed() + + - task: VSTest@3 + displayName: 'Run tests: Microsoft.WinGet.UnitTests' + inputs: + testSelector: 'testAssemblies' + testAssemblyVer2: 'Microsoft.WinGet.UnitTests.dll' + searchFolder: '$(buildOutDir)\Microsoft.WinGet.UnitTests' + codeCoverageEnabled: true + platform: '$(buildPlatform)' + configuration: '$(BuildConfiguration)' + diagnosticsEnabled: true + condition: succeededOrFailed() + + - task: VSTest@3 + displayName: 'Run tests: WinGetUtilInterop.UnitTests' + inputs: + testSelector: 'testAssemblies' + testAssemblyVer2: 'WinGetUtilInterop.UnitTests.dll' + searchFolder: '$(buildOutDir)\WinGetUtilInterop.UnitTests' + codeCoverageEnabled: true + platform: '$(buildPlatform)' + configuration: '$(BuildConfiguration)' + diagnosticsEnabled: true + condition: succeededOrFailed() + + - task: VSTest@3 + displayName: 'Run tests: Microsoft.Management.Configuration.UnitTests (InProc)' + inputs: + testRunTitle: Microsoft.Management.Configuration.UnitTests (InProc) + testSelector: 'testAssemblies' + testAssemblyVer2: '**\Microsoft.Management.Configuration.UnitTests.dll' + searchFolder: '$(buildOutDir)\Microsoft.Management.Configuration.UnitTests' + testFiltercriteria: 'Category=InProc' + codeCoverageEnabled: false + platform: '$(buildPlatform)' + configuration: '$(BuildConfiguration)' + diagnosticsEnabled: true + condition: succeededOrFailed() + + - task: PowerShell@2 + displayName: Prepare for Microsoft.Management.Configuration.UnitTests (OutOfProc) + inputs: + filePath: '$(wingetSourceRoot)\src\Microsoft.Management.Configuration.OutOfProc\Prepare-ConfigurationOOPTests.ps1' + arguments: '-BuildOutputPath $(buildOutDir) -PackageLayoutPath $(packageLayoutDir)' + condition: succeededOrFailed() + + - task: VSTest@3 + displayName: 'Run tests: Microsoft.Management.Configuration.UnitTests (OutOfProc)' + inputs: + testRunTitle: Microsoft.Management.Configuration.UnitTests (OutOfProc) + testSelector: 'testAssemblies' + testAssemblyVer2: '**\Microsoft.Management.Configuration.UnitTests.dll' + searchFolder: '$(buildOutDir)\Microsoft.Management.Configuration.UnitTests' + testFiltercriteria: 'Category=OutOfProc' + codeCoverageEnabled: true + platform: '$(buildPlatform)' + configuration: '$(BuildConfiguration)' + condition: succeededOrFailed() + + - task: PowerShell@2 + displayName: Collect logs for Microsoft.Management.Configuration.UnitTests (OutOfProc) + inputs: + filePath: '$(wingetSourceRoot)\src\Microsoft.Management.Configuration.OutOfProc\Collect-ConfigurationOOPTests.ps1' + arguments: '-TargetLocation $(artifactsDir)\ConfigOOPTestsLog' + condition: succeededOrFailed() + + - powershell: Get-Process LocalhostWebServer | Stop-Process + displayName: Stop LocalhostWebServer + condition: succeededOrFailed() + + - task: PowerShell@2 + displayName: 'Copy GA WinGet Log to artifacts folder' + inputs: + targetType: 'inline' + script: | + $source = "$env:LocalAppData\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\DiagOutputDir" + $destination = "$(artifactsDir)\GA_WinGet_Logs" + if (Test-Path $source) { + Copy-Item -Path $source -Destination $destination -Recurse -Force + } else { + Write-Host "WinGet logs not found at $source" + } + condition: succeededOrFailed() + + - task: PowerShell@2 + displayName: 'Copy Application Event Logs to Artifacts' + inputs: + targetType: 'inline' + script: | + $source = "$env:SystemRoot\System32\winevt\Logs\Application.evtx" + $destination = "$(artifactsDir)\Application.evtx" + if (Test-Path $source) { + Copy-Item -Path $source -Destination $destination -Force + } else { + Write-Host "Application event log not found at $source" + } + condition: succeededOrFailed() + + - task: PublishPipelineArtifact@1 + displayName: Publish Pipeline Artifacts + inputs: + targetPath: '$(artifactsDir)' + artifact: 'Test.$(artifactIdentifier).$(System.JobAttempt)' + condition: succeededOrFailed() From 9f3476831b8026797ca454841b96202a2c99a07b Mon Sep 17 00:00:00 2001 From: JohnMcPMS Date: Tue, 22 Sep 2026 14:05:01 -0700 Subject: [PATCH 11/13] Use latest version again (#6541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📖 Description Go back to using the latest version of the package. --- templates/jobs-powershell-module.yml | 1 + templates/jobs-test.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/jobs-powershell-module.yml b/templates/jobs-powershell-module.yml index b90dd8a660..cf5af9df7d 100644 --- a/templates/jobs-powershell-module.yml +++ b/templates/jobs-powershell-module.yml @@ -130,4 +130,5 @@ jobs: displayName: Publish PowerShell Module Artifacts inputs: targetPath: '$(Build.ArtifactStagingDirectory)' + artifact: 'PowerShell.$(System.JobAttempt)' condition: succeededOrFailed() diff --git a/templates/jobs-test.yml b/templates/jobs-test.yml index 1f76455136..12828489fb 100644 --- a/templates/jobs-test.yml +++ b/templates/jobs-test.yml @@ -77,7 +77,7 @@ jobs: - powershell: | Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force Install-Module Microsoft.WinGet.Client -Repository PSGallery -Force - try { Repair-WingetPackageManager -Version 1.28.240 -Verbose } catch { $_.Exception } + try { Repair-WingetPackageManager -Latest -Verbose } catch { $_.Exception } Install-WinGetPackage -Id Microsoft.Sysinternals.PsTools -Source winget displayName: Install Sysinternals PsTools Using Winget condition: succeededOrFailed() From 5e96f4fa76840778c756162326fb65ce70623a24 Mon Sep 17 00:00:00 2001 From: JohnMcPMS Date: Wed, 23 Sep 2026 09:53:35 -0700 Subject: [PATCH 12/13] Fix source priority in GP (#6557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📖 Description Source priority from group policy was not making it into the actual source details. --- src/AppInstallerCLITests/GroupPolicy.cpp | 55 +++++++++++++++++- src/AppInstallerCLITests/Sources.cpp | 56 +++++++++++++++++++ src/AppInstallerRepositoryCore/SourceList.cpp | 1 + 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/AppInstallerCLITests/GroupPolicy.cpp b/src/AppInstallerCLITests/GroupPolicy.cpp index f0b9038621..0d4301d363 100644 --- a/src/AppInstallerCLITests/GroupPolicy.cpp +++ b/src/AppInstallerCLITests/GroupPolicy.cpp @@ -13,10 +13,14 @@ using namespace std::string_view_literals; namespace { - std::wstring GetSourceJson(std::wstring_view name, std::wstring_view arg, std::wstring_view type, std::wstring_view data, std::wstring_view identifier, std::wstring_view trustLevel, std::wstring_view isExplicit, std::wstring_view pinningConfig = {}) + std::wstring GetSourceJson(std::wstring_view name, std::wstring_view arg, std::wstring_view type, std::wstring_view data, std::wstring_view identifier, std::wstring_view trustLevel, std::wstring_view isExplicit, std::wstring_view pinningConfig = {}, std::wstring_view priority = {}) { std::wstringstream json; json << L"{ \"Name\":\"" << name << L"\", \"Arg\":\"" << arg << L"\", \"Type\":\"" << type << L"\", \"Data\":\"" << data << L"\", \"Identifier\":\"" << identifier << L"\", \"TrustLevel\":" << trustLevel << L", \"Explicit\":" << isExplicit; + if (!priority.empty()) + { + json << L", \"Priority\":" << priority; + } if (!pinningConfig.empty()) { json << L", \"CertificatePinning\":" << pinningConfig; @@ -139,6 +143,44 @@ TEST_CASE("GroupPolicy_Sources", "[groupPolicy][uses-test-certificates]") REQUIRE(policy.value()[0].TrustLevel[0] == "Trusted"); REQUIRE(policy.value()[0].TrustLevel[1] == "StoreOrigin"); REQUIRE(policy.value()[0].Explicit == true); + REQUIRE_FALSE(policy.value()[0].Priority.has_value()); + } + SECTION("Source with priority") + { + // Priority is an optional field that is read when present. + auto additionalSourcesKey = RegCreateVolatileSubKey(policiesKey.get(), AdditionalSourcesPolicyKeyName); + SetRegistryValue(additionalSourcesKey.get(), L"0", GetSourceJson(L"source-name", L"source-arg", L"source-type", L"source-data", L"source-identifier", L"[\"Trusted\"]", L"true", {}, L"7"), REG_SZ); + SetRegistryValue(additionalSourcesKey.get(), L"1", GetSourceJson(L"source-name2", L"source-arg2", L"source-type2", L"source-data2", L"source-identifier2", L"[\"None\"]", L"false", {}, L"-3"), REG_SZ); + GroupPolicy groupPolicy{ policiesKey.get() }; + + auto policy = groupPolicy.GetValue(); + REQUIRE(policy.has_value()); + REQUIRE(policy->size() == 2); + + auto sourceWithName = [&](std::string_view name) -> const SourceFromPolicy& + { + auto itr = std::find_if(policy->begin(), policy->end(), [&](const SourceFromPolicy& source) { return source.Name == name; }); + REQUIRE(itr != policy->end()); + return *itr; + }; + + REQUIRE(sourceWithName("source-name").Priority.has_value()); + REQUIRE(sourceWithName("source-name").Priority.value() == 7); + REQUIRE(sourceWithName("source-name2").Priority.has_value()); + REQUIRE(sourceWithName("source-name2").Priority.value() == -3); + } + SECTION("Source with invalid priority") + { + // An invalid priority is ignored; the rest of the source is still read. + auto additionalSourcesKey = RegCreateVolatileSubKey(policiesKey.get(), AdditionalSourcesPolicyKeyName); + SetRegistryValue(additionalSourcesKey.get(), L"0", GetSourceJson(L"source-name", L"source-arg", L"source-type", L"source-data", L"source-identifier", L"[\"Trusted\"]", L"true", {}, L"\"not a number\""), REG_SZ); + GroupPolicy groupPolicy{ policiesKey.get() }; + + auto policy = groupPolicy.GetValue(); + REQUIRE(policy.has_value()); + REQUIRE(policy->size() == 1); + REQUIRE(policy.value()[0].Name == "source-name"); + REQUIRE_FALSE(policy.value()[0].Priority.has_value()); } SECTION("Missing field") { @@ -201,9 +243,9 @@ TEST_CASE("GroupPolicy_Sources", "[groupPolicy][uses-test-certificates]") // We should be able to read multiple values. // No specific order is required, but it will likely be the same. auto additionalSourcesKey = RegCreateVolatileSubKey(policiesKey.get(), AdditionalSourcesPolicyKeyName); - SetRegistryValue(additionalSourcesKey.get(), L"0", GetSourceJson(L"s0-name", L"s0-arg", L"s0-type", L"s0-data", L"s0-identifier", L"[\"None\"]", L"true"), REG_SZ); + SetRegistryValue(additionalSourcesKey.get(), L"0", GetSourceJson(L"s0-name", L"s0-arg", L"s0-type", L"s0-data", L"s0-identifier", L"[\"None\"]", L"true", {}, L"10"), REG_SZ); SetRegistryValue(additionalSourcesKey.get(), L"1", GetSourceJson(L"s1-name", L"s1-arg", L"s1-type", L"s1-data", L"s1-identifier", L"[\"Trusted\", \"StoreOrigin\"]", L"false"), REG_SZ); - SetRegistryValue(additionalSourcesKey.get(), L"2", GetSourceJson(L"s2-name", L"s2-arg", L"s2-type", L"s2-data", L"s2-identifier", L"[\"StoreOrigin\", \"Trusted\"]", L"true"), REG_SZ); + SetRegistryValue(additionalSourcesKey.get(), L"2", GetSourceJson(L"s2-name", L"s2-arg", L"s2-type", L"s2-data", L"s2-identifier", L"[\"StoreOrigin\", \"Trusted\"]", L"true", {}, L"20"), REG_SZ); GroupPolicy groupPolicy{ policiesKey.get() }; auto policy = groupPolicy.GetValue(); @@ -217,6 +259,8 @@ TEST_CASE("GroupPolicy_Sources", "[groupPolicy][uses-test-certificates]") REQUIRE(policy.value()[0].Identifier == "s0-identifier"); REQUIRE(policy.value()[0].TrustLevel[0] == "None"); REQUIRE(policy.value()[0].Explicit == true); + REQUIRE(policy.value()[0].Priority.has_value()); + REQUIRE(policy.value()[0].Priority.value() == 10); REQUIRE(policy.value()[1].Name == "s1-name"); REQUIRE(policy.value()[1].Arg == "s1-arg"); @@ -226,6 +270,7 @@ TEST_CASE("GroupPolicy_Sources", "[groupPolicy][uses-test-certificates]") REQUIRE(policy.value()[1].TrustLevel[0] == "Trusted"); REQUIRE(policy.value()[1].TrustLevel[1] == "StoreOrigin"); REQUIRE(policy.value()[1].Explicit == false); + REQUIRE_FALSE(policy.value()[1].Priority.has_value()); REQUIRE(policy.value()[2].Name == "s2-name"); REQUIRE(policy.value()[2].Arg == "s2-arg"); @@ -235,6 +280,8 @@ TEST_CASE("GroupPolicy_Sources", "[groupPolicy][uses-test-certificates]") REQUIRE(policy.value()[2].TrustLevel[0] == "StoreOrigin"); REQUIRE(policy.value()[2].TrustLevel[1] == "Trusted"); REQUIRE(policy.value()[2].Explicit == true); + REQUIRE(policy.value()[2].Priority.has_value()); + REQUIRE(policy.value()[2].Priority.value() == 20); } SECTION("Invalid source in list") { @@ -278,6 +325,7 @@ TEST_CASE("GroupPolicy_Sources", "[groupPolicy][uses-test-certificates]") source.Identifier = "json-id"; source.TrustLevel = {"Trusted", "StoreOrigin"}; source.Explicit = false; + source.Priority = 42; auto additionalSourcesKey = RegCreateVolatileSubKey(policiesKey.get(), AllowedSourcesPolicyKeyName); SetRegistryValue(additionalSourcesKey.get(), L"0", AppInstaller::Utility::ConvertToUTF16(source.ToJsonString())); @@ -294,6 +342,7 @@ TEST_CASE("GroupPolicy_Sources", "[groupPolicy][uses-test-certificates]") REQUIRE(policy.value()[0].TrustLevel[0] == source.TrustLevel[0]); // Trusted REQUIRE(policy.value()[0].TrustLevel[1] == source.TrustLevel[1]); // StoreOrigin REQUIRE(policy.value()[0].Explicit == source.Explicit); + REQUIRE(policy.value()[0].Priority == source.Priority); } SECTION("Source with PinningConfiguration") { diff --git a/src/AppInstallerCLITests/Sources.cpp b/src/AppInstallerCLITests/Sources.cpp index 730dfa02f2..2ca8987633 100644 --- a/src/AppInstallerCLITests/Sources.cpp +++ b/src/AppInstallerCLITests/Sources.cpp @@ -1032,6 +1032,15 @@ TEST_CASE("RepoSources_GroupPolicy_AdditionalSources", "[sources][groupPolicy]") source.Arg = "arg" + suffix[i]; source.Data = "data" + suffix[i]; source.Identifier = "id" + suffix[i]; + source.TrustLevel = { "Trusted" }; + source.Explicit = (i % 2 == 0); + + // Leave the first source without a priority to verify the default value. + if (i != 0) + { + source.Priority = static_cast(i); + } + policySources.emplace_back(std::move(source)); } @@ -1046,12 +1055,59 @@ TEST_CASE("RepoSources_GroupPolicy_AdditionalSources", "[sources][groupPolicy]") for (size_t i = 0; i < policySources.size(); ++i) { + INFO("Source #" << i); REQUIRE(sources[i].Name == policySources[i].Name); REQUIRE(sources[i].Type == policySources[i].Type); REQUIRE(sources[i].Arg == policySources[i].Arg); REQUIRE(sources[i].Data == policySources[i].Data); REQUIRE(sources[i].Identifier == policySources[i].Identifier); REQUIRE(sources[i].Origin == SourceOrigin::GroupPolicy); + REQUIRE(sources[i].Explicit == policySources[i].Explicit); + REQUIRE(WI_IsFlagSet(sources[i].TrustLevel, SourceTrustLevel::Trusted)); + REQUIRE(sources[i].Priority == policySources[i].Priority.value_or(0)); + } + } + SECTION("Priority sorts additional sources") + { + TestHook::SetSingleExperimentalFeature_Override prioritySortEnabled{ ExperimentalFeature::Feature::SourcePriority }; + + // The sources are given in increasing priority order; the result should be the reverse. + std::vector policySources; + const std::string suffix[3] = { "", "2", "3" }; + for (size_t i = 0; i < 3; ++i) + { + SourceFromPolicy source; + source.Name = "name" + suffix[i]; + source.Type = "type" + suffix[i]; + source.Arg = "arg" + suffix[i]; + source.Data = "data" + suffix[i]; + source.Identifier = "id" + suffix[i]; + source.Priority = static_cast(i) + 1; + policySources.emplace_back(std::move(source)); + } + + policies.SetValue(policySources); + SetSetting(Stream::UserSources, s_EmptySources); + + auto sources = GetSources(); + + REQUIRE(sources.size() == policySources.size() + c_DefaultSourceCount); + + for (size_t i = 0; i < policySources.size(); ++i) + { + size_t policyIndex = policySources.size() - 1 - i; + INFO("Source #" << i << " [" << policyIndex << "]"); + REQUIRE(sources[i].Name == policySources[policyIndex].Name); + REQUIRE(sources[i].Origin == SourceOrigin::GroupPolicy); + REQUIRE(sources[i].Priority == policySources[policyIndex].Priority.value()); + } + + // The default sources have a priority of 0, so they come after all of the policy sources. + for (size_t i = policySources.size(); i < sources.size(); ++i) + { + INFO("Source #" << i); + REQUIRE(sources[i].Origin == SourceOrigin::Default); + REQUIRE(sources[i].Priority == 0); } } SECTION("Same-name user source is overridden") diff --git a/src/AppInstallerRepositoryCore/SourceList.cpp b/src/AppInstallerRepositoryCore/SourceList.cpp index 6462aeb98e..52f1e55d1c 100644 --- a/src/AppInstallerRepositoryCore/SourceList.cpp +++ b/src/AppInstallerRepositoryCore/SourceList.cpp @@ -890,6 +890,7 @@ namespace AppInstaller::Repository details.Identifier = additionalSource.Identifier; details.Origin = SourceOrigin::GroupPolicy; details.Explicit = additionalSource.Explicit; + details.Priority = additionalSource.Priority.value_or(0); #ifndef AICLI_DISABLE_TEST_HOOKS details.CertificatePinningConfiguration = additionalSource.PinningConfiguration; #endif From b5856413ccde5644a565713ed912612c727a5754 Mon Sep 17 00:00:00 2001 From: Kaleb Luedtke Date: Wed, 23 Sep 2026 15:14:03 -0500 Subject: [PATCH 13/13] Skip Signing TTF file in Test Source (#6542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📖 Description Skips signing the TTF file for the local test source. #6261 only fixed the JSON version of this same entry. --- src/AppInstallerCLIE2ETests/Helpers/TestIndex.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/AppInstallerCLIE2ETests/Helpers/TestIndex.cs b/src/AppInstallerCLIE2ETests/Helpers/TestIndex.cs index b8022e6021..8468725bd2 100644 --- a/src/AppInstallerCLIE2ETests/Helpers/TestIndex.cs +++ b/src/AppInstallerCLIE2ETests/Helpers/TestIndex.cs @@ -170,6 +170,7 @@ public static void GenerateE2ESource() Name = Path.Combine(Constants.FontFileName, Constants.FontFileName), Input = testParams.FontPath, HashToken = "", + SkipSignature = true, // same fix as microsoft/winget-cli#6261 where TTF is not a valid type to be signed }, }, DynamicInstallers = new ()