diff --git a/CHANGELOG.md b/CHANGELOG.md index ad1e857..15fdbed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,99 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] - 2026-09-10 + +### Fixed + +- **An import could finish with every credential file in place and not one + secret in it, and exit 0.** A real move of 64 keys onto a second Mac wrote all + 53 credential files, then had every one of the 64 keychain writes refused + (macOS will not let a session with no desktop login write to a keychain), and + reported success. The reason was recorded per key and then thrown away by the + printer, so what you saw was a column of bare `skip` and no fact that + explained it. + + Three changes, because the failure had three halves. `pb import` now probes + whether the keystore will take a write **before** it copies a file, and + refuses the whole import if it will not — 53 files followed by 64 dropped + secrets was the worst reachable outcome and is no longer reachable. A key that + is refused prints its reason like a file always did, plus one summary line + saying the bundle still holds the values, so nothing is lost. And an import + that dropped secrets exits 1: `pb import && ./something` was the one caller + that could not tell. + +- **The install instructions could not reach the machine that needed them.** A + bundle's `SETUP.md` explains how to install patchbay on a machine that does + not have it, and lived inside the encrypted payload — so reading the + instructions for installing `pb` required a `pb`. On the move above the + receiving Mac had neither and the command was looked up on the releases page + by hand. `pb export` now also writes a cleartext `-SETUP.md` beside + the bundle: the install step and the `pb import` line, and nothing else. It + takes no manifest at all, which is the structural reason no inventory can + leak into it. + +- **A Cloudflare token that works was reported `invalid`.** + `/user/tokens/verify` only answers for user-owned tokens; an account-owned or + scoped token gets the same "Invalid API Token" rejection whether it is live or + revoked. patchbay believed the rejection and told you to replace working + keys — the exact mistake the verdict enum's own doc comment warns about. A + rejection now falls back to listing accounts with the token: if that works the + token demonstrably does, and if it does not, the answer is the new + `inconclusive` verdict rather than a death sentence, because a revoked token + and a narrowly scoped one are the same HTTP response. Nine keys in a real + vault moved from red to honest. + +- **A GitHub App private key was reported `invalid` for the same reason.** A PEM + is not a token `GET /user` can ever authenticate, so the request could only + ever say "Bad credentials". A PEM-shaped value is now `unsupported` before any + request is made, with the note that an App key is verified by minting a JWT + and patchbay has no app id to mint one with. + +- **The panel's refresh button did nothing on two of its three pages.** It only + ever re-fetched the tool board, so on the key vault and the MCP matrix it + spun, moved the timestamp, and left the table that was loaded on mount. A + header that already names the view you are in has to refresh that view. + Alongside it: a failed re-read of the MCP matrix now leaves the matrix on + screen with the error above it, instead of replacing a working board with a + banner. + +- **`pb plan` lost the one fix an import knew.** Several kubeconfigs land in one + directory and the import tells you the `KUBECONFIG` line to set; by the time + you ran `pb plan` the item said only "nothing is logged in". The item now + carries the real `export KUBECONFIG=…` line, re-derived from the files + themselves so nothing has to be persisted to stay true. + +- **`pb key verify` offered Grafana's `--endpoint` advice to every unsupported + provider**, including the 52 in a real vault that have no endpoint to set. + +### Added + +- **`--passphrase-file ` and `--passphrase-fd ` on `pb export` and + `pb import`.** The old rule refused a passphrase from anything but a + terminal. Its reason — argv is visible to `ps` and lands in shell history — is + right and is kept, but a TTY test is the wrong instrument for it: it stops no + attacker and it stopped every legitimate automation, including a real move + that worked around it with a pty wrapper whose echo wrote the passphrase into + a log. That is the leak the rule exists to prevent, caused by the rule. So + patchbay now lets automation name its source the way gpg, restic, borg and age + do. A passphrase file that the group or the world can read is refused, because + with that flag the file *is* the secret. Still no passphrase as an argument. + +- **`pb import --keys-only`** — restore just the vault half, for + finishing a move whose keychain writes were refused. + +- **`pb key verify ...` and `pb key verify --all`.** It took exactly one id, + which in a vault of 64 keys means nobody checks. A sweep runs the network + calls bounded-concurrent and prints a tally. Exit codes are now three: 1 if a + provider says a key is dead, 2 if a provider could not be reached, 0 + otherwise — and `inconclusive` and `unsupported` are in that 0, because + neither is a fact about the key. + +- **Frontend tests.** The panel had none, and the refresh bug above is exactly + the kind nothing else could catch. vitest + Testing Library, mocked at + `invoke` — the one thing genuinely absent from a test process — with the four + regressions that fail if the fix is reverted. + ## [0.7.0] - 2026-09-08 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cfcfb84..d0debb6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,7 @@ cd app bun install bunx tauri dev # panel + hot-reloading front end bunx tauri build # bundle a .app / .dmg +bun run test # the front end's vitest suite (`--run` for one pass) ``` ## Before you open a PR diff --git a/app/bun.lock b/app/bun.lock index 14078bd..d3e342d 100644 --- a/app/bun.lock +++ b/app/bun.lock @@ -12,15 +12,26 @@ }, "devDependencies": { "@tauri-apps/cli": "^2", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^30.0.1", "typescript": "~5.8.3", "vite": "^7.0.4", + "vitest": "^5.0.0", }, }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@6.0.7", "", { "dependencies": { "@csstools/css-calc": "^3.3.0", "@csstools/css-color-parser": "^4.1.10", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.5.2" } }, "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@8.3.2", "", { "dependencies": { "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2" } }, "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -53,12 +64,28 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.1", "", {}, "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.2", "", { "dependencies": { "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.12", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], @@ -111,13 +138,15 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -205,6 +234,14 @@ "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11", "vitest": ">= 0.32" }, "optionalPeers": ["vitest"] }, "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw=="], + + "@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -213,6 +250,10 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], @@ -221,37 +262,89 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitest/mocker": ["@vitest/mocker@5.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.0", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA=="], + + "@vitest/spy": ["@vitest/spy@5.0.0", "", {}, "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.13", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ=="], + "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], + "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.405", "", {}, "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew=="], + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "jsdom": ["jsdom@30.0.1", "", { "dependencies": { "@asamuzakjp/css-color": "^6.0.5", "@asamuzakjp/dom-selector": "^8.3.0", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.7", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2", "undici": "^8.9.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.2.3" }, "optionalPeers": ["canvas"] }, "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@1.3.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg=="], + + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -259,34 +352,104 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tinybench": ["tinybench@6.1.4", "", {}, "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ=="], + + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tldts": ["tldts@7.4.12", "", { "dependencies": { "tldts-core": "^7.4.12" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA=="], + + "tldts-core": ["tldts-core@7.4.12", "", {}, "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ=="], + + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], + "update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="], "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "vitest": ["vitest@5.0.0", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.0", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.0", "@vitest/browser-preview": "5.0.0", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.0", "@vitest/coverage-v8": "5.0.0", "@vitest/ui": "5.0.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@17.1.1", "", { "dependencies": { "@exodus/bytes": "^1.15.1", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + + "vitest/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], } } diff --git a/app/package.json b/app/package.json index 16051d2..a77b98b 100644 --- a/app/package.json +++ b/app/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", + "test": "vitest", "tauri": "tauri" }, "dependencies": { @@ -18,10 +19,15 @@ }, "devDependencies": { "@tauri-apps/cli": "^2", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^30.0.1", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "^5.0.0" } } diff --git a/app/src/App.tsx b/app/src/App.tsx index e63fc48..120b27e 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -39,6 +39,17 @@ export default function App() { const [version, setVersion] = useState(""); const [filters, setFilters] = useState(NO_FILTERS); const [view, setView] = useState("board"); + /** + * Bumped by every refresh, and read by the views that own their own data. + * + * The board is the only thing `statusAll` answers, so a refresh used to + * leave the key vault and the MCP matrix exactly as they were loaded on + * mount: the button spun, the stamp moved, and the table on screen was the + * one from ten minutes ago. A header that already names the view you are in + * has to refresh *that* view, so the poll and the button both bump this and + * every view re-reads. + */ + const [reload, setReload] = useState(0); const [detail, setDetail] = useState<{ tool: string; permissions: boolean } | null>(null); const [verdicts, setVerdicts] = useState>({}); @@ -53,6 +64,7 @@ export default function App() { const refresh = useCallback(async () => { setRefreshing(true); + setReload((n) => n + 1); try { setStatuses(await statusAll()); setError(null); @@ -233,8 +245,8 @@ export default function App() { and never worth covering the thing the window is for. */} - {view === "keys" && } - {view === "mcp" && } + {view === "keys" && } + {view === "mcp" && } {view === "board" && ( /* Same wrapper the other two views use: one idiom for "a view", diff --git a/app/src/components/KeysView.test.tsx b/app/src/components/KeysView.test.tsx new file mode 100644 index 0000000..a9fc95c --- /dev/null +++ b/app/src/components/KeysView.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { KeysView } from "./KeysView"; +import type { KeyRow } from "../types"; + +/** + * The mock sits on `invoke`, not on `../api`. + * + * `invoke` is the only thing genuinely missing from a test process — there is + * no Tauri IPC here — so faking it fakes exactly the absent thing and leaves + * every line of our own code running, `api.ts` included. Mocking `../api` + * would stub out code we ship, and it would count calls to a wrapper rather + * than trips to the backend; here a re-read is a second `keys_list`, spelled + * the same way the Rust shell registers the handler. + */ +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +const invoked = vi.mocked(invoke); + +const timesAsked = (command: string) => + invoked.mock.calls.filter(([cmd]) => cmd === command).length; + +const keyRow = (id: string, over: Partial = {}): KeyRow => ({ + id, + provider: "cloudflare", + // Deliberately not the id: the id and the label are different columns, and a + // query for the id should land on one cell rather than two. + label: `${id} token`, + purpose: null, + scopes: [], + created_at: "2026-01-01T00:00:00Z", + expires_at: null, + last4: "9f2c", + source: "cli", + env: null, + expiry_state: "no_expiry", + ...over, +}); + +beforeEach(() => { + invoked.mockReset(); +}); + +describe("KeysView", () => { + it("re-reads the vault when reload changes and draws the rows that came back", async () => { + // The bug this suite exists for: the header's refresh only re-fetched the + // board, so the vault went on showing whatever it read on mount. Bumping + // `reload` is all that button does to this view, so it has to reach the + // backend a second time and repaint from the answer. + invoked + .mockResolvedValueOnce([keyRow("cf-deploy")]) + .mockResolvedValueOnce([keyRow("cf-deploy"), keyRow("neon-api", { provider: "neon" })]); + + const { rerender } = render(); + expect(await screen.findByText("cf-deploy")).toBeInTheDocument(); + expect(timesAsked("keys_list")).toBe(1); + + rerender(); + + expect(await screen.findByText("neon-api")).toBeInTheDocument(); + expect(timesAsked("keys_list")).toBe(2); + expect(screen.getByText("2 keys · metadata only")).toBeInTheDocument(); + }); + + it("keeps the rows it already drew when a re-read fails", async () => { + // A refresh that lands while `keys.json` is half-rewritten must not cost + // the user the table they were already reading. + invoked + .mockResolvedValueOnce([keyRow("cf-deploy")]) + .mockRejectedValueOnce("keys.json: unexpected end of input"); + + const { rerender } = render(); + expect(await screen.findByText("cf-deploy")).toBeInTheDocument(); + + rerender(); + + expect(await screen.findByText("keys.json: unexpected end of input")).toBeInTheDocument(); + expect(screen.getByText("cf-deploy")).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/KeysView.tsx b/app/src/components/KeysView.tsx index bebb65d..e078124 100644 --- a/app/src/components/KeysView.tsx +++ b/app/src/components/KeysView.tsx @@ -20,7 +20,7 @@ import { KEY_EXPIRY_LABEL, KEY_EXPIRY_LEVEL, type KeyRow } from "../types"; * so the rule was protecting nothing here and cost the panel the one action a * key vault is for. */ -export function KeysView() { +export function KeysView({ reload }: Readonly<{ reload: number }>) { const [rows, setRows] = useState(null); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); @@ -39,9 +39,13 @@ export function KeysView() { } }, []); + // `reload` is the header's refresh (and its poll). A vault registered from a + // terminal a moment ago is the ordinary case, so re-reading is the point. + // `load` keeps the rows it has if the read fails, so a transient error can + // never blank a table that was fine. useEffect(() => { void load(); - }, [load]); + }, [load, reload]); const remove = async (id: string) => { setRemoving(id); diff --git a/app/src/components/McpView.test.tsx b/app/src/components/McpView.test.tsx new file mode 100644 index 0000000..b170891 --- /dev/null +++ b/app/src/components/McpView.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { McpView } from "./McpView"; +import type { McpClient, McpServerEntry } from "../types"; + +/** Same boundary and the same reason as `KeysView.test.tsx` — see the note there. */ +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +const invoked = vi.mocked(invoke); + +const timesAsked = (command: string) => + invoked.mock.calls.filter(([cmd]) => cmd === command).length; + +const server = (name: string): McpServerEntry => ({ + name, + scope: null, + env_keys: [], + header_keys: [], + transport: "stdio", + command: "/opt/homebrew/bin/pb", + args_len: 1, +}); + +const client = (id: string, label: string, servers: McpServerEntry[]): McpClient => ({ + client: id, + label, + config_path: `/Users/dev/.config/${id}/config.json`, + present: true, + servers, + notes: [], +}); + +beforeEach(() => { + invoked.mockReset(); +}); + +describe("McpView", () => { + it("re-reads every client config when reload changes and draws the servers that came back", async () => { + // Six config files edited by six other programs is the state that goes + // stale while you look at it, and the header's refresh is the only thing + // the user has to say so. Before the fix it moved the timestamp and left + // this matrix exactly as it was on mount. + invoked + .mockResolvedValueOnce([client("claude-code", "Claude Code", [server("patchbay")])]) + .mockResolvedValueOnce([ + client("claude-code", "Claude Code", [server("patchbay"), server("grafana")]), + ]); + + const { rerender } = render(); + expect(await screen.findByText("patchbay")).toBeInTheDocument(); + expect(timesAsked("mcp_list")).toBe(1); + + rerender(); + + expect(await screen.findByText("grafana")).toBeInTheDocument(); + expect(timesAsked("mcp_list")).toBe(2); + expect(screen.getByText("2 servers across 1 of 1 clients")).toBeInTheDocument(); + }); + + it("leaves the matrix it already drew on screen and adds the error banner when a re-read fails", async () => { + // The cold-start branch renders the banner *instead of* the view, which is + // right when there is nothing to show and wrong on a refresh: one + // unreadable config would replace a matrix that was fine a second ago. + invoked + .mockResolvedValueOnce([client("claude-code", "Claude Code", [server("patchbay")])]) + .mockRejectedValueOnce("mcp.json: permission denied"); + + const { rerender } = render(); + expect(await screen.findByText("patchbay")).toBeInTheDocument(); + + rerender(); + + expect(await screen.findByText("mcp.json: permission denied")).toBeInTheDocument(); + expect(screen.getByRole("table")).toBeInTheDocument(); + expect(screen.getByText("patchbay")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "MCP clients" })).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/McpView.tsx b/app/src/components/McpView.tsx index 19e3073..0995a4a 100644 --- a/app/src/components/McpView.tsx +++ b/app/src/components/McpView.tsx @@ -34,7 +34,7 @@ type Open = { mode: "add" } | { mode: "edit"; name: string }; * edit that client's copy, copy it to the clients that are missing it, or take * it out. `pb mcp add/copy/rm` still does the same work from a terminal. */ -export function McpView() { +export function McpView({ reload }: Readonly<{ reload: number }>) { const [clients, setClients] = useState(null); const [error, setError] = useState(null); const [open, setOpen] = useState(null); @@ -56,15 +56,23 @@ export function McpView() { setOpen(next); }; + // `reload` is the header's refresh (and its poll): six config files edited by + // six other programs is exactly the state that goes stale while you look at + // it. A failed re-read leaves `clients` alone, so the matrix you had stays on + // screen with the error above it rather than vanishing. useEffect(() => { let live = true; mcpList() - .then((c) => live && setClients(c)) + .then((c) => { + if (!live) return; + setClients(c); + setError(null); + }) .catch((e) => live && setError(String(e))); return () => { live = false; }; - }, []); + }, [reload]); const servers = useMemo(() => { if (!clients) return []; @@ -73,7 +81,7 @@ export function McpView() { return [...names].sort((a, b) => a.localeCompare(b)); }, [clients]); - if (error) { + if (error && !clients) { return (
@@ -99,6 +107,13 @@ export function McpView() {
+ {error && ( +
+ + {error} +
+ )} + {note && (
{note} diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts new file mode 100644 index 0000000..e77be45 --- /dev/null +++ b/app/src/test/setup.ts @@ -0,0 +1,10 @@ +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; +// Teaches `expect` the DOM matchers (`toBeInTheDocument` and friends). +import "@testing-library/jest-dom/vitest"; + +// Testing Library only installs its own auto-cleanup when the runner's hooks +// are globals, and this suite imports them instead. Without this every test +// would render into the document the previous one left behind, and a query for +// a row would find the stale copy. +afterEach(cleanup); diff --git a/app/src/types.ts b/app/src/types.ts index f947abc..6e1206a 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -203,6 +203,30 @@ export const KEY_EXPIRY_LEVEL: Record = { no_expiry: "unknown", }; +/** Mirrors `patchbay_core::KeyVerifyStatus` — what the *issuer* said, as + * opposed to what the registry recorded. There is no label map because the + * status strings are already the labels core prints. */ +export type KeyVerifyStatus = + | "valid" + | "invalid" + | "expired" + | "inconclusive" + | "unsupported" + | "unreachable"; + +/** The chip class each verify verdict draws with. `inconclusive`, + * `unsupported` and `unreachable` share the plainest one on purpose: they are + * patchbay failing to get an answer, and drawing them as warnings would put a + * working key on the same footing as a revoked one. */ +export const KEY_VERIFY_LEVEL: Record = { + valid: "ok", + invalid: "expired", + expired: "expired", + inconclusive: "unknown", + unsupported: "unknown", + unreachable: "unknown", +}; + /** * A vault key as it rides along on a tool's status — metadata only. There is no * secret value in this shape and there must never be one: the vault's values diff --git a/app/tsconfig.node.json b/app/tsconfig.node.json index 8bfb1d4..96763eb 100644 --- a/app/tsconfig.node.json +++ b/app/tsconfig.node.json @@ -8,5 +8,5 @@ "strict": true, "types": ["vite/client"] }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } diff --git a/app/vitest.config.ts b/app/vitest.config.ts new file mode 100644 index 0000000..e7a3e15 --- /dev/null +++ b/app/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import viteConfig from "./vite.config"; + +// Merged onto the real vite config rather than written standalone: the React +// plugin is declared once, so a test can never compile JSX under different +// settings than the app it is testing. +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + environment: "jsdom", + // Registers the DOM matchers and tears the tree down between tests. The + // suite imports `describe`/`it`/`expect` explicitly, so nothing here + // depends on vitest globals. + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.test.{ts,tsx}"], + }, + }), +); diff --git a/crates/patchbay-cli/src/keys.rs b/crates/patchbay-cli/src/keys.rs index ea6fc5b..944b96e 100644 --- a/crates/patchbay-cli/src/keys.rs +++ b/crates/patchbay-cli/src/keys.rs @@ -21,6 +21,7 @@ use patchbay_core::keys::{ NewKey, }; use patchbay_core::keys_verify::{verify_key, KeyVerifyOutcome, KeyVerifyStatus}; +use patchbay_core::run_bounded; use crate::render::{self, Styles}; @@ -37,8 +38,17 @@ const COL_ENV_MAX: usize = 22; const COL_LABEL_MAX: usize = 18; /// Width of the field-name column in `pb key edit`'s report of what changed. const FIELD_COL: usize = 9; +/// Width of the verdict column in a `pb key verify` sweep: `inconclusive`, the +/// longest label there is. +const COL_VERDICT: usize = 12; const DASH: &str = "—"; +/// How many issuers a sweep talks to at once. Sixty round trips one after +/// another is a minute of watching a cursor; the ceiling is there because a +/// burst of parallel requests from one machine is what rate limiters exist to +/// notice, and it matches the one the version check already settled on. +const VERIFY_THREADS: usize = 8; + #[derive(Subcommand, Debug)] pub enum Command { /// Register a key patchbay should know about. @@ -75,19 +85,16 @@ pub enum Command { #[arg(last = true, required = true, value_name = "CMD")] command: Vec, }, - /// Ask the issuer whether a key still works. + /// Ask the issuers whether keys still work. /// - /// Exit codes: 0 verified (or nothing patchbay can check), 1 the provider - /// says the key is dead, 2 the provider could not be reached. - Verify { - id: String, - #[arg(long)] - json: bool, - /// Report only: do not write the issuer's expiry and scopes back into - /// the registry. - #[arg(long)] - no_update: bool, - }, + /// Exit codes: 1 a provider says one of them is dead, 2 nothing is dead but + /// a provider could not be reached, 0 everything else — including the keys + /// patchbay has no way to check. + #[command(after_help = "Examples:\n \ + pb key verify cf-r2-token-sonarqube-backups\n \ + pb key verify cf-api gh-pat neon-api-key\n \ + pb key verify --all")] + Verify(VerifyArgs), /// Unregister a key: metadata entry and keychain item both. Rm { id: String, @@ -97,6 +104,28 @@ pub enum Command { }, } +/// Everything `pb key verify` takes. +/// +/// Ids are plural because they have to be: a vault of sixty keys and a checker +/// that answers one question per invocation is a checker nobody runs. +#[derive(Args, Debug)] +pub struct VerifyArgs { + /// Keys to check. Leave empty only with `--all`. + #[arg(value_name = "ID", required_unless_present = "all")] + ids: Vec, + /// Check every registered key. + #[arg(long, conflicts_with = "ids")] + all: bool, + /// The verdicts as JSON: a list when several keys were asked about, the + /// bare object when it was one. + #[arg(long)] + json: bool, + /// Report only: do not write the issuer's expiry and scopes back into + /// the registry. + #[arg(long)] + no_update: bool, +} + /// Everything `pb key add` takes. The secret is deliberately absent: it is /// read from stdin or a hidden prompt, never from argv. #[derive(Args, Debug)] @@ -213,11 +242,7 @@ pub fn run(command: Command, styles: &Styles) -> Result { aliases, command, } => run_command(®istry, &keys, &aliases, &command), - Command::Verify { - id, - json, - no_update, - } => verify(®istry, &id, json, no_update, styles), + Command::Verify(args) => verify(®istry, args, styles), Command::Rm { id, yes } => rm(®istry, &id, yes), } } @@ -370,46 +395,248 @@ fn rm(registry: &KeyRegistry, id: &str, yes: bool) -> Result { // verify // --------------------------------------------------------------------------- -/// `pb key verify` — ask the issuer, then report (and usually record) what it -/// said. Returns the exit code the subcommand's own docs promise. -fn verify( +/// `pb key verify` — ask the issuers, then report (and usually record) what +/// they said. +fn verify(registry: &KeyRegistry, args: VerifyArgs, styles: &Styles) -> Result { + let VerifyArgs { + ids, + all, + json, + no_update, + } = args; + + let entries = verify_targets(registry, &ids, all)?; + if entries.is_empty() { + // Only reachable through `--all`. An id nobody registered is an error; + // an empty vault is not. + println!("no keys registered yet"); + return Ok(0); + } + + let outcomes = verify_outcomes(registry, &entries)?; + let updated = absorb_all(registry, &entries, &outcomes, no_update)?; + + // One key asked about by name keeps the answer it has always had: a full + // block, and a bare object under `--json` that existing readers can still + // index into. `--all` is a sweep and always answers as a list. + let sweep = all || entries.len() > 1; + if json { + print_verify_json(&entries, &outcomes, &updated, sweep)?; + } else if sweep { + print_verify_sweep(&entries, &outcomes, &updated, styles); + } else { + print_verify(&entries[0], &outcomes[0], &updated[0], styles); + } + Ok(verify_exit_code(&outcomes)) +} + +/// The keys a run is about: everything registered under `--all`, otherwise the +/// ones named on the command line. +/// +/// A named id nobody registered stops the run here rather than being filed as a +/// verdict, because it is a question patchbay cannot answer, not an answer. +fn verify_targets(registry: &KeyRegistry, ids: &[String], all: bool) -> Result> { + if all { + return registry.list(); + } + ids.iter() + .map(|id| { + registry + .get(id)? + .ok_or_else(|| anyhow::anyhow!("no key registered as `{id}`")) + }) + .collect() +} + +/// Ask every issuer, a bounded number of them at a time. +/// +/// Each secret is read inside the closure and dropped there, so even a sweep of +/// the whole vault never holds more than VERIFY_THREADS of them at once, and +/// none of them outlives its own request. +/// +/// A value patchbay registered but cannot read back is a broken vault, not a +/// verdict about a key, so it stops the sweep instead of being filed as one. +fn verify_outcomes(registry: &KeyRegistry, entries: &[KeyEntry]) -> Result> { + run_bounded(entries, VERIFY_THREADS, |entry| { + let secret = registry.get_secret(&entry.id)?; + let outcome = verify_key(entry, &secret); + drop(secret); + Ok(outcome) + }) + .into_iter() + .collect() +} + +/// What [`absorb`] wrote back for each key, in step with `entries`. `--no-update` +/// is a report-only run, so nothing is written and every key reports nothing. +/// +/// Write-backs stay on this thread: they rewrite one metadata file, and eight +/// threads doing that would be eight chances to lose an entry. +fn absorb_all( registry: &KeyRegistry, - id: &str, - json: bool, + entries: &[KeyEntry], + outcomes: &[KeyVerifyOutcome], no_update: bool, - styles: &Styles, -) -> Result { - let entry = registry - .get(id)? - .ok_or_else(|| anyhow::anyhow!("no key registered as `{id}`"))?; - // The secret lives for exactly this call and is never printed. - let secret = registry.get_secret(id)?; - let outcome = verify_key(&entry, &secret); - drop(secret); +) -> Result>> { + let mut updated: Vec> = Vec::new(); + for (entry, outcome) in entries.iter().zip(outcomes) { + updated.push(if no_update { + Vec::new() + } else { + absorb(registry, entry, outcome)? + }); + } + Ok(updated) +} - let updated = if no_update { - Vec::new() +/// The verdicts as `--json`: a list for a sweep, the bare object for the one key +/// that was asked about by name. +fn print_verify_json( + entries: &[KeyEntry], + outcomes: &[KeyVerifyOutcome], + updated: &[Vec], + sweep: bool, +) -> Result<()> { + let mut values = Vec::new(); + for ((entry, outcome), updated) in entries.iter().zip(outcomes).zip(updated) { + values.push(verify_json(entry, outcome, updated)?); + } + let value = if sweep { + serde_json::Value::Array(values) } else { - absorb(registry, &entry, &outcome)? + values.remove(0) }; + println!("{}", serde_json::to_string_pretty(&value)?); + Ok(()) +} - if json { - let mut value = serde_json::to_value(&outcome)?; - if let Some(map) = value.as_object_mut() { - map.insert("id".into(), entry.id.clone().into()); - map.insert("provider".into(), entry.provider.clone().into()); - map.insert("metadata_updated".into(), updated.clone().into()); +/// A sweep: one line per key, then the tally. +fn print_verify_sweep( + entries: &[KeyEntry], + outcomes: &[KeyVerifyOutcome], + updated: &[Vec], + styles: &Styles, +) { + let id_w = entries + .iter() + .map(|e| e.id.chars().count()) + .max() + .unwrap_or_default(); + for ((entry, outcome), updated) in entries.iter().zip(outcomes).zip(updated) { + println!("{}", verify_line(entry, outcome, id_w, styles)); + // The one thing a sweep must not do quietly. Rare, because it only + // fires when the issuer knows something the registry did not. + if !updated.is_empty() { + println!( + " updated the registry from the provider: {}", + updated.join(", ") + ); } - println!("{}", serde_json::to_string_pretty(&value)?); + } + println!("{}", verify_summary(outcomes)); +} + +/// Three answers, because a script gating on this needs three. +/// +/// `1` is the only one that means a key is dead, and it outranks everything: a +/// sweep that found one revoked token and lost the wifi halfway through is +/// still a sweep that found a revoked token. `2` is the honest answer when the +/// worst thing that happened was not being able to ask, which is neither a +/// clean bill of health nor a reason to rotate anything. +/// +/// `inconclusive` and `unsupported` leave `0`, because both are patchbay +/// declining to answer about a key that gave it no reason for concern — and a +/// vault that is mostly providers patchbay cannot interrogate would otherwise +/// never exit clean. +fn verify_exit_code(outcomes: &[KeyVerifyOutcome]) -> i32 { + if outcomes.iter().any(|o| o.status.is_bad_news()) { + 1 + } else if outcomes + .iter() + .any(|o| o.status == KeyVerifyStatus::Unreachable) + { + 2 } else { - print_verify(&entry, &outcome, &updated, styles); + 0 + } +} + +/// One verdict as `--json`: the outcome, plus who it is about. +fn verify_json( + entry: &KeyEntry, + outcome: &KeyVerifyOutcome, + updated: &[String], +) -> Result { + let mut value = serde_json::to_value(outcome)?; + if let Some(map) = value.as_object_mut() { + map.insert("id".into(), entry.id.clone().into()); + map.insert("provider".into(), entry.provider.clone().into()); + map.insert("metadata_updated".into(), updated.to_vec().into()); + } + Ok(value) +} + +/// One key's row in a sweep: the same verdict voice as the single-key report, +/// with the provider's message trimmed to whatever the line has left. +/// +/// The id is never truncated, however wide it makes the column: the next thing +/// anyone does with a bad row is paste that id into another `pb key` command. +fn verify_line( + entry: &KeyEntry, + outcome: &KeyVerifyOutcome, + id_w: usize, + styles: &Styles, +) -> String { + let verdict = styles.paint( + verdict_style(outcome.status), + &pad(outcome.status.label(), COL_VERDICT), + ); + let detail_w = TABLE_WIDTH + .saturating_sub(id_w + COL_VERDICT + GAP * 2) + .max(20); + let detail = render::truncate(&one_line(&outcome.detail), detail_w); + let gap = " ".repeat(GAP); + format!("{}{gap}{verdict}{gap}{detail}", pad(&entry.id, id_w)) + .trim_end() + .to_string() +} + +/// The tally that closes a sweep. Worst news first, and silent about the +/// verdicts nothing came back as. +fn verify_summary(outcomes: &[KeyVerifyOutcome]) -> String { + const WORST_FIRST: [KeyVerifyStatus; 6] = [ + KeyVerifyStatus::Invalid, + KeyVerifyStatus::Expired, + KeyVerifyStatus::Inconclusive, + KeyVerifyStatus::Unreachable, + KeyVerifyStatus::Unsupported, + KeyVerifyStatus::Valid, + ]; + let tally: Vec = WORST_FIRST + .iter() + .filter_map(|status| { + let n = outcomes.iter().filter(|o| o.status == *status).count(); + (n > 0).then(|| format!("{n} {}", status.label())) + }) + .collect(); + format!( + "checked {} {}: {}", + outcomes.len(), + if outcomes.len() == 1 { "key" } else { "keys" }, + tally.join(", ") + ) +} + +/// The colour a verdict draws in: green for good news, red for bad, and dim +/// for the answers that are not about the key at all. +fn verdict_style(status: KeyVerifyStatus) -> anstyle::Style { + match status { + KeyVerifyStatus::Valid => green(), + KeyVerifyStatus::Invalid | KeyVerifyStatus::Expired => red(), + KeyVerifyStatus::Inconclusive + | KeyVerifyStatus::Unsupported + | KeyVerifyStatus::Unreachable => dim(), } - Ok(match outcome.status { - KeyVerifyStatus::Valid | KeyVerifyStatus::Unsupported => 0, - KeyVerifyStatus::Invalid | KeyVerifyStatus::Expired => 1, - // Distinct from 1: nothing was learned about the key. - KeyVerifyStatus::Unreachable => 2, - }) } /// Write back what the issuer just told us, and report what changed. @@ -446,16 +673,11 @@ fn absorb( } fn print_verify(entry: &KeyEntry, outcome: &KeyVerifyOutcome, updated: &[String], styles: &Styles) { - let style = match outcome.status { - KeyVerifyStatus::Valid => green(), - KeyVerifyStatus::Invalid | KeyVerifyStatus::Expired => red(), - KeyVerifyStatus::Unsupported | KeyVerifyStatus::Unreachable => dim(), - }; println!( "{} (…{}) — {}", entry.id, entry.last4, - styles.paint(style, outcome.status.label()) + styles.paint(verdict_style(outcome.status), outcome.status.label()) ); println!(" {}", one_line(&outcome.detail)); @@ -475,7 +697,14 @@ fn print_verify(entry: &KeyEntry, outcome: &KeyVerifyOutcome, updated: &[String] updated.join(", ") ); } - if outcome.status == KeyVerifyStatus::Unsupported && entry.endpoint.is_none() { + // `unsupported` covers two different situations: an issuer patchbay cannot + // interrogate at all, and one it could if it had an address. Only the + // second wants an endpoint, and the way to tell them apart is that the + // provider's own message asked for one. + if outcome.status == KeyVerifyStatus::Unsupported + && entry.endpoint.is_none() + && outcome.detail.contains("--endpoint") + { println!( " set one with: pb key add {} --provider {} --endpoint --overwrite", entry.id, entry.provider @@ -484,6 +713,9 @@ fn print_verify(entry: &KeyEntry, outcome: &KeyVerifyOutcome, updated: &[String] if outcome.status == KeyVerifyStatus::Unreachable { println!(" the key was not tested — this is a connection problem, not a verdict"); } + if outcome.status == KeyVerifyStatus::Inconclusive { + println!(" patchbay is not saying the key is dead — it is saying it cannot tell"); + } } // --------------------------------------------------------------------------- @@ -1166,6 +1398,7 @@ mod tests { for status in [ KeyVerifyStatus::Unreachable, KeyVerifyStatus::Unsupported, + KeyVerifyStatus::Inconclusive, KeyVerifyStatus::Invalid, KeyVerifyStatus::Expired, ] { @@ -1205,6 +1438,138 @@ mod tests { ); } + /// `pb key …` as clap sees it, without standing up the whole `pb` parser. + #[derive(clap::Parser, Debug)] + struct KeyCli { + #[command(subcommand)] + command: Command, + } + + fn parse_verify(argv: &[&str]) -> Result { + use clap::Parser; + match KeyCli::try_parse_from(argv)?.command { + Command::Verify(args) => Ok(args), + other => panic!("expected verify, parsed {other:?}"), + } + } + + #[test] + fn test_verify_takes_a_list_of_ids_and_all_takes_none() { + // What this replaces: `pb key verify a b c` was "unexpected argument + // 'b' found", which made a 64-key vault 64 invocations. + let args = parse_verify(&["pb", "verify", "a", "b", "c"]).unwrap(); + assert_eq!(args.ids, vec!["a", "b", "c"]); + assert!(!args.all); + + let args = parse_verify(&["pb", "verify", "--all"]).unwrap(); + assert!(args.all && args.ids.is_empty()); + + // Naming nothing is a mistake, not a sweep, and naming ids alongside + // `--all` is two different questions at once. + assert!(parse_verify(&["pb", "verify"]).is_err()); + assert!(parse_verify(&["pb", "verify", "--all", "a"]).is_err()); + } + + #[test] + fn test_a_sweep_line_keeps_the_whole_id_and_spends_what_is_left_on_the_detail() { + let long = "cf-r2-token-sonarqube-backups"; + let id_w = long.chars().count(); + let mut wordy = outcome(KeyVerifyStatus::Inconclusive); + wordy.detail = "Cloudflare said `Invalid API Token` to both checks patchbay can make, \ + and a token scoped to one product answers exactly like a revoked one" + .to_string(); + + let line = verify_line(&entry(long, None), &wordy, id_w, &Styles::new(false)); + assert!(line.starts_with(long), "the id must survive intact: {line}"); + assert!(line.contains("inconclusive"), "{line}"); + assert!(line.ends_with('…'), "a long detail is trimmed, not wrapped"); + assert!(line.chars().count() <= TABLE_WIDTH, "{line}"); + + // A shorter id in the same sweep still lines its verdict up with the + // widest one's. + let short = verify_line( + &entry("cf-api", None), + &outcome(KeyVerifyStatus::Valid), + id_w, + &Styles::new(false), + ); + assert_eq!( + short.find("valid").unwrap(), + line.find("inconclusive").unwrap(), + "{short}\n{line}" + ); + } + + #[test] + fn test_the_sweep_summary_leads_with_the_bad_news_and_names_only_what_came_back() { + let outcomes = vec![ + outcome(KeyVerifyStatus::Valid), + outcome(KeyVerifyStatus::Valid), + outcome(KeyVerifyStatus::Unsupported), + outcome(KeyVerifyStatus::Inconclusive), + outcome(KeyVerifyStatus::Invalid), + ]; + assert_eq!( + verify_summary(&outcomes), + "checked 5 keys: 1 invalid, 1 inconclusive, 1 unsupported, 2 valid" + ); + assert_eq!( + verify_summary(&[outcome(KeyVerifyStatus::Valid)]), + "checked 1 key: 1 valid" + ); + } + + #[test] + fn test_only_a_provider_saying_a_key_is_dead_fails_the_command() { + for status in [ + KeyVerifyStatus::Valid, + KeyVerifyStatus::Unsupported, + KeyVerifyStatus::Inconclusive, + ] { + assert_eq!(verify_exit_code(&[outcome(status)]), 0, "{status:?}"); + } + for status in [KeyVerifyStatus::Invalid, KeyVerifyStatus::Expired] { + assert_eq!(verify_exit_code(&[outcome(status)]), 1, "{status:?}"); + } + // One dead key does not get lost in a sweep of good ones. + assert_eq!( + verify_exit_code(&[ + outcome(KeyVerifyStatus::Valid), + outcome(KeyVerifyStatus::Expired), + outcome(KeyVerifyStatus::Unsupported), + ]), + 1 + ); + } + + #[test] + fn test_a_provider_that_could_not_be_reached_is_its_own_exit_code() { + // Neither a clean bill of health nor a reason to rotate: a script that + // gates on this needs to be able to tell "nothing wrong" from "could + // not ask". + assert_eq!( + verify_exit_code(&[outcome(KeyVerifyStatus::Unreachable)]), + 2 + ); + assert_eq!( + verify_exit_code(&[ + outcome(KeyVerifyStatus::Valid), + outcome(KeyVerifyStatus::Unreachable), + outcome(KeyVerifyStatus::Inconclusive), + ]), + 2 + ); + // Bad news outranks it. A sweep that found a revoked token and then + // lost the wifi still found a revoked token. + assert_eq!( + verify_exit_code(&[ + outcome(KeyVerifyStatus::Unreachable), + outcome(KeyVerifyStatus::Invalid), + ]), + 1 + ); + } + #[test] fn test_parse_aliases_splits_on_the_first_equals_and_validates_the_name() { let raw = |v: &[&str]| -> Vec { v.iter().map(|s| s.to_string()).collect() }; diff --git a/crates/patchbay-cli/src/main.rs b/crates/patchbay-cli/src/main.rs index 13cb4b6..1813e14 100644 --- a/crates/patchbay-cli/src/main.rs +++ b/crates/patchbay-cli/src/main.rs @@ -123,6 +123,13 @@ enum Command { /// Write into a cloud-sync folder anyway. #[arg(long)] force: bool, + /// Take the passphrase from this file's first line (mode 0600 only), + /// not from a prompt. Never as an argument: argv is visible to `ps`. + #[arg(long, value_name = "PATH", conflicts_with = "passphrase_fd")] + passphrase_file: Option, + /// Take the passphrase from an already-open descriptor's first line. + #[arg(long, value_name = "N", conflicts_with = "passphrase_file")] + passphrase_fd: Option, #[arg(long)] json: bool, }, @@ -138,6 +145,17 @@ enum Command { /// Print the plan and write nothing. #[arg(long)] dry_run: bool, + /// Restore the key vault half only, skipping files, MCP servers and env + /// projects. The file half is idempotent anyway. + #[arg(long)] + keys_only: bool, + /// Take the passphrase from this file's first line (mode 0600 only), + /// not from a prompt. Never as an argument: argv is visible to `ps`. + #[arg(long, value_name = "PATH", conflicts_with = "passphrase_fd")] + passphrase_file: Option, + /// Take the passphrase from an already-open descriptor's first line. + #[arg(long, value_name = "N", conflicts_with = "passphrase_file")] + passphrase_fd: Option, #[arg(long)] json: bool, }, @@ -297,12 +315,16 @@ fn run() -> Result { out, keys, force, + passphrase_file, + passphrase_fd, json, } => migrate::run( migrate::Command::Export { out, keys, force, + passphrase_file, + passphrase_fd, json, }, &styles(), @@ -311,11 +333,17 @@ fn run() -> Result { Command::Import { bundle, dry_run, + keys_only, + passphrase_file, + passphrase_fd, json, } => migrate::run( migrate::Command::Import { bundle, dry_run, + keys_only, + passphrase_file, + passphrase_fd, json, }, &styles(), diff --git a/crates/patchbay-cli/src/migrate.rs b/crates/patchbay-cli/src/migrate.rs index 62175bf..c26bc55 100644 --- a/crates/patchbay-cli/src/migrate.rs +++ b/crates/patchbay-cli/src/migrate.rs @@ -6,6 +6,15 @@ //! On export it is asked for twice, because a bundle nobody can decrypt is a //! bundle that has to be made again from a machine you may have already wiped. //! +//! Automation names its source instead — `--passphrase-file` or +//! `--passphrase-fd`, the way gpg, restic, borg and age do it — which keeps the +//! value out of argv and out of a history file just as well. The refusal that +//! used to stand here instead ("stdin is not a terminal") stopped none of that +//! and was worked around with a pty wrapper, which echoed the passphrase into a +//! log: exactly the leak the rule exists to prevent. A file or fd source also +//! skips the confirmation read on export — nothing was typed, so there is +//! nothing to have mistyped. +//! //! Nothing here reads a credential file itself: [`patchbay_core::migrate`] does //! that. This module decides what to print. @@ -37,6 +46,17 @@ pub enum Command { /// Write into a cloud-sync folder anyway. #[arg(long)] force: bool, + /// Read the passphrase from the first line of this file instead of + /// prompting. The file must not be group- or world-readable. patchbay + /// will not take a passphrase as an argument: argv is visible to `ps` + /// and is written to your shell history. + #[arg(long, value_name = "PATH", conflicts_with = "passphrase_fd")] + passphrase_file: Option, + /// Read the passphrase from the first line of an already-open file + /// descriptor (`--passphrase-fd 3`), for automation that will not put + /// it in a file either. + #[arg(long, value_name = "N", conflicts_with = "passphrase_file")] + passphrase_fd: Option, #[arg(long)] json: bool, }, @@ -52,6 +72,23 @@ pub enum Command { /// Print the plan and write nothing. #[arg(long)] dry_run: bool, + /// Restore the key vault half only, skipping the credential files, the + /// MCP registrations and the env projects. For finishing a move whose + /// keychain writes were refused; the file half is idempotent anyway, so + /// a plain re-run is equally safe and reports `unchanged`. + #[arg(long)] + keys_only: bool, + /// Read the passphrase from the first line of this file instead of + /// prompting. The file must not be group- or world-readable. patchbay + /// will not take a passphrase as an argument: argv is visible to `ps` + /// and is written to your shell history. + #[arg(long, value_name = "PATH", conflicts_with = "passphrase_fd")] + passphrase_file: Option, + /// Read the passphrase from the first line of an already-open file + /// descriptor (`--passphrase-fd 3`), for automation that will not put + /// it in a file either. + #[arg(long, value_name = "N", conflicts_with = "passphrase_file")] + passphrase_fd: Option, #[arg(long)] json: bool, }, @@ -82,8 +119,11 @@ pub fn run(command: Command, styles: &Styles) -> Result { out, keys, force, + passphrase_file, + passphrase_fd, json, } => { + let source = PassphraseSource::from_flags(passphrase_file, passphrase_fd)?; let path = out.unwrap_or_else(|| PathBuf::from(export::default_file_name(Utc::now()))); // Refuse a bad destination BEFORE asking for a passphrase: being // made to type one twice and then told no is a small cruelty. @@ -99,7 +139,7 @@ pub fn run(command: Command, styles: &Styles) -> Result { } .payload(&selection, Utc::now())?; - let passphrase = ask_passphrase_twice()?; + let passphrase = ask_passphrase_twice(&source)?; let report = export::write(&path, &payload, &passphrase, force, None)?; drop(passphrase); @@ -140,12 +180,16 @@ pub fn run(command: Command, styles: &Styles) -> Result { Command::Import { bundle, dry_run, + keys_only, + passphrase_file, + passphrase_fd, json, } => { + let source = PassphraseSource::from_flags(passphrase_file, passphrase_fd)?; // Version first, so a bundle from a newer patchbay is refused // before the user types anything. migrate::peek_version(&bundle)?; - let passphrase = ask_passphrase("passphrase: ")?; + let passphrase = read_passphrase(&source, "passphrase: ")?; let payload = migrate::bundle::read(&bundle, &passphrase)?; drop(passphrase); @@ -156,14 +200,18 @@ pub fn run(command: Command, styles: &Styles) -> Result { clients: &clients, envs: &envs, } - .run(&payload, &ImportOptions { dry_run })?; + .run(&payload, &ImportOptions { dry_run, keys_only })?; if json { println!("{}", serde_json::to_string_pretty(&import_json(&report))?); } else { print_import(&report, styles); } - Ok(0) + // A keychain that refused the secrets leaves the vault half of this + // machine incomplete however well the files went, so + // `pb import && ./something` must not read as success — the same + // contract `pb plan` keeps below. + Ok(i32::from(report.keys_refused() > 0)) } Command::Plan { @@ -250,25 +298,151 @@ fn key_selection(keys: Option>) -> KeySelection { // passphrase // --------------------------------------------------------------------------- -fn ask_passphrase(prompt: &str) -> Result { - if !std::io::stdin().is_terminal() { - anyhow::bail!( - "a passphrase is required and stdin is not a terminal; run this from a shell rather \ - than a pipe (patchbay will not take a passphrase as an argument — argv is visible to \ - `ps` and is written to your shell history)" - ); +/// Where the passphrase comes from. None of the three is argv. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PassphraseSource { + /// A hidden prompt on the terminal. The default, and the only one that can + /// be mistyped. + Prompt, + /// The first line of a file that nobody else may read. + File(PathBuf), + /// The first line of a descriptor the caller already opened. + Fd(i32), +} + +impl PassphraseSource { + /// `clap` refuses the two flags together as well. The rule is repeated here + /// because it belongs to the source rather than to one command's argument + /// table, and both commands take the pair. + fn from_flags(file: Option, fd: Option) -> Result { + match (file, fd) { + (Some(_), Some(_)) => anyhow::bail!( + "--passphrase-file and --passphrase-fd both name a source for the same \ + passphrase; pass one of them" + ), + (Some(path), None) => Ok(Self::File(path)), + (None, Some(fd)) => Ok(Self::Fd(fd)), + (None, None) => Ok(Self::Prompt), + } } - let value = rpassword::prompt_password(prompt) - .map_err(|e| anyhow::anyhow!("could not read the passphrase: {e}"))?; + + fn is_typed(&self) -> bool { + *self == Self::Prompt + } +} + +fn read_passphrase(source: &PassphraseSource, prompt: &str) -> Result { + let value = match source { + PassphraseSource::Prompt => prompt_passphrase(prompt)?, + PassphraseSource::File(path) => { + let file = std::fs::File::open(path) + .with_context(|| format!("could not open {}", path.display()))?; + refuse_a_readable_file(path, &file)?; + first_line( + &mut std::io::BufReader::new(file), + &path.display().to_string(), + )? + } + PassphraseSource::Fd(fd) => read_fd(*fd)?, + }; if value.is_empty() { anyhow::bail!("an empty passphrase would leave the bundle effectively unencrypted"); } Ok(value) } -fn ask_passphrase_twice() -> Result { - let first = ask_passphrase("passphrase for the bundle: ")?; - let second = ask_passphrase("again: ")?; +fn prompt_passphrase(prompt: &str) -> Result { + if !std::io::stdin().is_terminal() { + anyhow::bail!( + "a passphrase is required and stdin is not a terminal; name a source explicitly with \ + --passphrase-file (mode 0600) or --passphrase-fd . patchbay will not take \ + a passphrase as an argument — argv is visible to `ps` and is written to your shell \ + history — and it will not read one from a stdin nobody named, which is how a pty \ + wrapper ends up echoing it into a log." + ); + } + rpassword::prompt_password(prompt) + .map_err(|e| anyhow::anyhow!("could not read the passphrase: {e}")) +} + +/// The first line, with one trailing newline removed. +/// +/// A trailing `\r` goes with it: a file written on Windows would otherwise +/// carry one into the key derivation, and what comes back then looks exactly +/// like a wrong passphrase rather than like a stray byte. +fn first_line(reader: &mut dyn std::io::BufRead, origin: &str) -> Result { + let mut line = String::new(); + reader + .read_line(&mut line) + .with_context(|| format!("could not read a passphrase from {origin}"))?; + let line = line.strip_suffix('\n').unwrap_or(&line); + Ok(line.strip_suffix('\r').unwrap_or(line).to_string()) +} + +/// Refuse a passphrase file that anybody else on the machine can read. +/// +/// With this flag the file *is* the secret, so it gets the rule ssh gives a +/// private key. Only the mode is checked: it is the mistake that actually +/// happens — a passphrase written with a default umask — and it is the one the +/// user can fix in one command. +#[cfg(unix)] +fn refuse_a_readable_file(path: &std::path::Path, file: &std::fs::File) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mode = file + .metadata() + .with_context(|| format!("could not stat {}", path.display()))? + .permissions() + .mode() + & 0o777; + if mode & 0o077 != 0 { + anyhow::bail!( + "{} is mode {mode:o}: its group or every user on this machine can read it, and with \ + --passphrase-file that file is the secret. Run `chmod 600 {}` and try again.", + path.display(), + path.display() + ); + } + Ok(()) +} + +/// No file mode to read, so nothing to check. +#[cfg(not(unix))] +fn refuse_a_readable_file(_path: &std::path::Path, _file: &std::fs::File) -> Result<()> { + Ok(()) +} + +/// The first line of a descriptor the caller opened for us — `--passphrase-fd 3` +/// with a `3< …` redirect or a process substitution. +/// +/// The descriptor belongs to that caller, so the `File` wrapper is leaked +/// rather than dropped: dropping it would close somebody else's fd. +#[cfg(unix)] +fn read_fd(fd: i32) -> Result { + use std::os::fd::FromRawFd; + if fd < 0 { + anyhow::bail!("--passphrase-fd {fd} is not a file descriptor"); + } + let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) }); + first_line( + &mut std::io::BufReader::new(&*file), + &format!("file descriptor {fd}"), + ) +} + +#[cfg(not(unix))] +fn read_fd(fd: i32) -> Result { + anyhow::bail!("--passphrase-fd {fd} needs unix file descriptors; use --passphrase-file") +} + +/// Twice for a typed passphrase, once for a file or a descriptor: a value +/// nobody typed cannot have been mistyped, and reading the same file twice +/// would only look like the tool did not believe the first read. +fn ask_passphrase_twice(source: &PassphraseSource) -> Result { + let first = read_passphrase(source, "passphrase for the bundle: ")?; + if !source.is_typed() { + return Ok(first); + } + let second = read_passphrase(source, "again: ")?; if first != second { anyhow::bail!("the two passphrases do not match; nothing was written"); } @@ -329,6 +503,15 @@ fn print_export(report: &export::ExportReport, styles: &Styles) { " {} item(s) will need doing on the new machine", report.gaps ); + if let Some(sidecar) = &report.sidecar { + // Named because it is the one file here that is safe to send ahead of + // the bundle, and because the machine that needs it cannot read the + // copy inside the bundle without the `pb` it explains how to install. + println!( + " also wrote {} — install instructions in the clear, no inventory in it", + file_name(sidecar) + ); + } println!(); for warning in &report.warnings { println!("{}", styles.paint(warn_style(), &format!("! {warning}"))); @@ -337,6 +520,12 @@ fn print_export(report: &export::ExportReport, styles: &Styles) { "\nnext: copy the file across, then `pb import {}`", file_name(&report.path) ); + if let Some(sidecar) = &report.sidecar { + println!( + " on a machine with no `pb` yet, start from {}", + file_name(sidecar) + ); + } } /// Written-to-a-file summary. Deliberately counts rather than lists: the file @@ -373,13 +562,34 @@ fn file_name(path: &std::path::Path) -> String { .unwrap_or_else(|| path.display().to_string()) } +/// An import report is a run of independent sections, so this is a run of +/// independent printers. Nothing passes between them except the number of keys +/// the keychain refused, which the section that prints the keys is the only one +/// in a position to count and so is the one that returns it — the alternative, +/// a counter declared up here and mutated further down, is a variable whose +/// value depends on how far the reader has got. fn print_import(report: &import::ImportReport, styles: &Styles) { + print_dry_run_banner(report, styles); + print_file_results(report); + let keys_refused = print_key_results(report); + print_mcp_results(report); + print_env_project_results(report); + println!(); + print_refused_key_warning(keys_refused, report.keys.len(), styles); + print_notes(report, styles); + print_remaining_plan(report, styles); +} + +fn print_dry_run_banner(report: &import::ImportReport, styles: &Styles) { if report.dry_run { println!( "{}\n", styles.paint(warn_style(), "DRY RUN — nothing was written") ); } +} + +fn print_file_results(report: &import::ImportReport) { for file in &report.files { println!(" {:<10} {}", file.outcome.label(), file.path.display()); if let import::FileOutcome::Replaced { backup: Some(at) } = &file.outcome { @@ -389,9 +599,27 @@ fn print_import(report: &import::ImportReport, styles: &Styles) { println!(" {reason}"); } } +} + +/// Returns how many key values the keystore refused, for the warning below. +/// +/// A key carries its reason the same way a file does. `restore_keys` puts +/// the keystore's error in there and this loop used to drop it, which turns +/// "every secret in the bundle was refused" into a column of bare `skip` +/// and leaves the one fact that explains it unprinted. +fn print_key_results(report: &import::ImportReport) -> usize { + let mut keys_refused = 0usize; for key in &report.keys { println!(" {:<10} key {}", key.outcome.label(), key.id); + if let import::FileOutcome::Skipped { reason } = &key.outcome { + println!(" {reason}"); + keys_refused += 1; + } } + keys_refused +} + +fn print_mcp_results(report: &import::ImportReport) { for server in &report.mcp { println!( " {:<10} mcp {}/{}", @@ -400,6 +628,9 @@ fn print_import(report: &import::ImportReport, styles: &Styles) { server.name ); } +} + +fn print_env_project_results(report: &import::ImportReport) { for project in &report.env_projects { println!( " {:<10} env project {}", @@ -410,11 +641,39 @@ fn print_import(report: &import::ImportReport, styles: &Styles) { println!(" {reason}"); } } - println!(); +} + +/// The file half of an import can succeed while every secret in it is +/// refused, and per-key lines scroll away. A keychain that will not take a +/// write is nearly always a session without a desktop login — over ssh, in +/// a cron job — which is a property of how the command was started and not +/// of the bundle, so it says how to start it differently. +fn print_refused_key_warning(keys_refused: usize, keys_total: usize, styles: &Styles) { + if keys_refused > 0 { + println!( + "{}", + styles.paint( + warn_style(), + &format!( + "! {keys_refused} of {keys_total} key value(s) never reached the keychain — \ + the bundle still holds them, so nothing is lost, but the vault on this \ + machine is incomplete. On macOS a keychain refuses every write from a \ + session with no desktop login (ssh, cron): re-run this import from a \ + Terminal in your own desktop session, and the file half above is idempotent \ + — it will report `unchanged` rather than write anything twice." + ) + ) + ); + } +} + +fn print_notes(report: &import::ImportReport, styles: &Styles) { for note in &report.notes { println!("{}", styles.paint(warn_style(), &format!("! {note}"))); } +} +fn print_remaining_plan(report: &import::ImportReport, styles: &Styles) { let open: Vec<&SetupItem> = report.open_items().collect(); if open.is_empty() { println!("\nnothing left to do."); @@ -486,6 +745,7 @@ fn dim_style() -> anstyle::Style { fn export_json(report: &export::ExportReport) -> serde_json::Value { serde_json::json!({ "path": report.path, + "setup_sidecar": report.sidecar, "files": report.files, "bytes": report.bytes, "tools_carried": report.tools_carried, @@ -515,6 +775,7 @@ fn import_json(report: &import::ImportReport) -> serde_json::Value { .collect(); serde_json::json!({ "dry_run": report.dry_run, + "keys_refused": report.keys_refused(), "files": files, "keys": report.keys.iter().map(|k| serde_json::json!({ "id": k.id, "action": k.outcome.label(), @@ -556,6 +817,104 @@ mod tests { assert_eq!(human_bytes(3 * 1024 * 1024), "3.0 MB"); } + /// A passphrase file with the mode the flag insists on. + #[cfg(unix)] + fn passphrase_file(dir: &tempfile::TempDir, body: &str, mode: u32) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = dir.path().join("pass"); + std::fs::write(&path, body).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + path + } + + #[cfg(unix)] + #[test] + fn test_a_passphrase_file_is_read_as_its_first_line() { + let dir = tempfile::tempdir().unwrap(); + // A second line, and a trailing newline on the first: both are the + // shape `echo … > pass` produces. + let path = passphrase_file(&dir, "hunter2\nignored\n", 0o600); + let source = PassphraseSource::File(path); + assert_eq!(read_passphrase(&source, "p: ").unwrap(), "hunter2"); + // A file cannot be mistyped, so it is not read twice. + assert_eq!(ask_passphrase_twice(&source).unwrap(), "hunter2"); + } + + #[cfg(unix)] + #[test] + fn test_a_passphrase_file_others_can_read_is_refused() { + let dir = tempfile::tempdir().unwrap(); + for mode in [0o640, 0o604, 0o666] { + let path = passphrase_file(&dir, "hunter2\n", mode); + let err = read_passphrase(&PassphraseSource::File(path), "p: ") + .unwrap_err() + .to_string(); + assert!(err.contains("chmod 600"), "mode {mode:o}: {err}"); + // The error may name the file; it may never name what is in it. + assert!(!err.contains("hunter2"), "{err}"); + } + } + + #[cfg(unix)] + #[test] + fn test_a_passphrase_arrives_on_a_file_descriptor_the_caller_opened() { + use std::os::fd::AsRawFd; + let dir = tempfile::tempdir().unwrap(); + let path = passphrase_file(&dir, "hunter2\n", 0o600); + let handle = std::fs::File::open(&path).unwrap(); + + let read = read_passphrase(&PassphraseSource::Fd(handle.as_raw_fd()), "p: ").unwrap(); + assert_eq!(read, "hunter2"); + // The fd is still ours: reading it did not close it, which is what + // `--passphrase-fd 3` depends on. + assert!(handle.metadata().is_ok()); + } + + #[cfg(unix)] + #[test] + fn test_an_empty_passphrase_is_refused_whatever_the_source() { + let dir = tempfile::tempdir().unwrap(); + for body in ["", "\n", "\r\n"] { + let path = passphrase_file(&dir, body, 0o600); + let err = read_passphrase(&PassphraseSource::File(path), "p: ") + .unwrap_err() + .to_string(); + assert!(err.contains("empty passphrase"), "{body:?}: {err}"); + } + } + + #[test] + fn test_the_two_passphrase_flags_cannot_both_be_given() { + let err = PassphraseSource::from_flags(Some(PathBuf::from("pass")), Some(3)) + .unwrap_err() + .to_string(); + assert!(err.contains("--passphrase-file"), "{err}"); + assert!(err.contains("--passphrase-fd"), "{err}"); + + // One of each is fine, and neither is the interactive default. + assert_eq!( + PassphraseSource::from_flags(Some(PathBuf::from("pass")), None).unwrap(), + PassphraseSource::File(PathBuf::from("pass")) + ); + assert_eq!( + PassphraseSource::from_flags(None, Some(3)).unwrap(), + PassphraseSource::Fd(3) + ); + let default = PassphraseSource::from_flags(None, None).unwrap(); + assert_eq!(default, PassphraseSource::Prompt); + assert!(default.is_typed()); + } + + #[test] + fn test_a_first_line_loses_one_newline_and_nothing_else() { + let mut line = std::io::Cursor::new(b" hunter2 \r\nrest\n".to_vec()); + // Leading and trailing spaces are part of a passphrase; the line + // ending is not. + assert_eq!(first_line(&mut line, "test").unwrap(), " hunter2 "); + let mut no_newline = std::io::Cursor::new(b"hunter2".to_vec()); + assert_eq!(first_line(&mut no_newline, "test").unwrap(), "hunter2"); + } + #[test] fn test_file_name_survives_a_bare_name() { assert_eq!(file_name(std::path::Path::new("a/b.pbx")), "b.pbx"); diff --git a/crates/patchbay-core/src/keys.rs b/crates/patchbay-core/src/keys.rs index 4b277e3..221b227 100644 --- a/crates/patchbay-core/src/keys.rs +++ b/crates/patchbay-core/src/keys.rs @@ -408,6 +408,28 @@ impl KeyRegistry { // --- writes ------------------------------------------------------------- + /// Whether the keystore would accept a write at all, right now. + /// + /// There is no way to ask a keychain this except by trying, so a throwaway + /// item is stored and deleted again. It is filed under an id + /// [`validate_id`] refuses, so it can never collide with a registered key, + /// and it carries no credential material. + /// + /// [`crate::migrate::import`] asks before it restores anything: on macOS a + /// keychain refuses *every* write from a session with no desktop login + /// (ssh, cron), and discovering that one key at a time — after the + /// credential files have already landed — is how a machine ends up half + /// migrated. + pub fn probe_writable(&self) -> anyhow::Result<()> { + const PROBE_ID: &str = ".patchbay-write-probe"; + self.store.put(PROBE_ID, "probe")?; + // The question has been answered by the line above. A delete that then + // fails leaves one valueless item behind, which is not worth turning a + // successful probe into a refusal. + let _ = self.store.delete(PROBE_ID); + Ok(()) + } + /// Register a key: metadata to disk, value to the keystore. /// /// Both or neither. The metadata file is written first; if the keystore @@ -971,6 +993,20 @@ mod tests { assert_eq!(v.store.get("dup").unwrap().as_deref(), Some("second-2222")); } + #[test] + fn test_a_write_probe_leaves_neither_a_key_nor_a_stored_value() { + let v = vault(); + v.registry.probe_writable().unwrap(); + assert!(v.registry.list().unwrap().is_empty()); + assert!(!v.registry.path().exists(), "the probe wrote metadata"); + assert!(v.store.is_empty(), "the probe left its throwaway item"); + + // And a store that refuses writes says so, which is the answer an + // import needs before it touches a single file. + let refusing = vault_with(MemoryKeystore::failing_put()); + assert!(refusing.registry.probe_writable().is_err()); + } + #[test] fn test_keystore_failure_rolls_the_metadata_back() { let v = vault_with(MemoryKeystore::failing_put()); diff --git a/crates/patchbay-core/src/keys_verify.rs b/crates/patchbay-core/src/keys_verify.rs index 02aac75..f31f882 100644 --- a/crates/patchbay-core/src/keys_verify.rs +++ b/crates/patchbay-core/src/keys_verify.rs @@ -6,9 +6,9 @@ //! updates itself from it. //! //! **Secret handling.** The value is pulled from the keystore, put in exactly -//! one place — the `Authorization` header of one outbound request — and -//! dropped. It is never in a URL, a query string, a log line, an error, or the -//! returned outcome. [`KeyVerifyOutcome`] carries a verdict and nothing else, +//! one place — the `Authorization` header of the outbound request, or of the +//! second one where a provider needs asking twice — and dropped. It is never +//! in a URL, a query string, a log line, an error, or the returned outcome. [`KeyVerifyOutcome`] carries a verdict and nothing else, //! which is why the MCP `verify_key` tool is *not* gated behind //! `PATCHBAY_ALLOW_SECRET_READ`: there is nothing in it to leak. //! @@ -48,6 +48,10 @@ pub enum KeyVerifyStatus { Invalid, /// The issuer knows the key but its lifetime is over. Expired, + /// The provider answered, and its answer does not separate a dead key from + /// a live one whose permissions are too narrow to prove it. Every check + /// patchbay could make came back the same for both. + Inconclusive, /// patchbay has no verification path for this provider. Unsupported, /// The provider could not be reached. Says nothing about the key. @@ -55,9 +59,9 @@ pub enum KeyVerifyStatus { } impl KeyVerifyStatus { - /// Whether this verdict is bad news about the key itself. `Unsupported` and - /// `Unreachable` are not: they are patchbay failing to answer, not the key - /// failing to work. + /// Whether this verdict is bad news about the key itself. `Unsupported`, + /// `Inconclusive` and `Unreachable` are not: they are patchbay failing to + /// answer, not the key failing to work. pub fn is_bad_news(&self) -> bool { matches!(self, Self::Invalid | Self::Expired) } @@ -67,6 +71,7 @@ impl KeyVerifyStatus { Self::Valid => "valid", Self::Invalid => "invalid", Self::Expired => "expired", + Self::Inconclusive => "inconclusive", Self::Unsupported => "unsupported", Self::Unreachable => "unreachable", } @@ -211,6 +216,7 @@ type SeenRequest = (String, Vec<(String, String)>); #[derive(Debug, Default)] pub struct StubHttp { response: Option, + queue: std::sync::Mutex>, failure: Option, seen: std::sync::Mutex>, } @@ -223,6 +229,17 @@ impl StubHttp { } } + /// Answer each call from a queue, in order. For the verifiers that ask a + /// second question when the first answer is not a verdict: the responses + /// have to differ per call, and by URL is not enough — the same URL can be + /// asked twice. + pub fn responding_in_turn(responses: Vec) -> Self { + Self { + queue: std::sync::Mutex::new(responses.into()), + ..Self::default() + } + } + pub fn failing(detail: impl Into) -> Self { Self { failure: Some(detail.into()), @@ -235,6 +252,16 @@ impl StubHttp { self.seen.lock().unwrap().last().map(|(u, _)| u.clone()) } + /// Every URL requested, in order. + pub fn urls(&self) -> Vec { + self.seen + .lock() + .unwrap() + .iter() + .map(|(u, _)| u.clone()) + .collect() + } + /// The last request's headers. pub fn last_headers(&self) -> Vec<(String, String)> { self.seen @@ -259,6 +286,9 @@ impl HttpClient for StubHttp { .map(|(k, v)| (k.to_string(), v.to_string())) .collect(), )); + if let Some(response) = self.queue.lock().unwrap().pop_front() { + return Ok(response); + } match (&self.failure, &self.response) { (Some(detail), _) => Err(detail.clone()), (None, Some(response)) => Ok(response.clone()), @@ -317,6 +347,11 @@ fn normalize_provider(provider: &str) -> Option { const CLOUDFLARE_VERIFY: &str = "https://api.cloudflare.com/client/v4/user/tokens/verify"; +/// The second question, asked when the first one refuses to answer. One page of +/// one account is the smallest authenticated read on the API, and the point is +/// the HTTP status, not the list. +const CLOUDFLARE_ACCOUNTS: &str = "https://api.cloudflare.com/client/v4/accounts?per_page=1"; + #[derive(Debug, Deserialize)] struct CfEnvelope { #[serde(default)] @@ -343,6 +378,14 @@ struct CfMessage { message: String, } +/// The envelope reduced to its verdict flag, for the calls where the payload +/// shape is beside the point. +#[derive(Debug, Deserialize)] +struct CfSuccess { + #[serde(default)] + success: bool, +} + fn cloudflare(secret: &str, http: &dyn HttpClient) -> KeyVerifyOutcome { let bearer = format!("Bearer {secret}"); let response = match http.get( @@ -393,9 +436,10 @@ fn cloudflare(secret: &str, http: &dyn HttpClient) -> KeyVerifyOutcome { let detail = first(&envelope.errors) .unwrap_or_else(|| format!("Cloudflare rejected the token (HTTP {})", response.status)); match response.status { - // 400 and 401 are both how Cloudflare says "no": a bad token is a - // malformed request to it. - 400 | 401 | 403 => KeyVerifyOutcome::new(KeyVerifyStatus::Invalid, detail), + // 400 and 401 are both how Cloudflare says "no" — a bad token is a + // malformed request to it — but "no" here is not the same as "dead": + // the endpoint only speaks for user-owned tokens, so ask again. + 400 | 401 | 403 => cloudflare_scoped(secret, http, &detail), 429 => KeyVerifyOutcome::new( KeyVerifyStatus::Unreachable, format!("Cloudflare rate-limited the check ({detail}); try again shortly"), @@ -408,6 +452,72 @@ fn cloudflare(secret: &str, http: &dyn HttpClient) -> KeyVerifyOutcome { } } +/// Re-ask the question a token-verify rejection cannot answer. +/// +/// `/user/tokens/verify` only speaks for tokens owned by a *user*. Hand it an +/// account-owned or scoped token — which is what the Cloudflare dashboard +/// mostly issues now, and what an R2 or Workers token is — and it returns +/// `success: false` with error 1000 "Invalid API Token" while the token is +/// happily deploying in production. Byte for byte the same body a revoked token +/// gets, so the first answer has to be re-asked as something a scoped token can +/// answer: can it read anything at all? +/// +/// A 200 is proof of life. Anything else leaves the two cases genuinely +/// indistinguishable — a token scoped to, say, R2 alone cannot list accounts +/// either — and `Inconclusive` says that instead of picking the answer that +/// gets a working key rotated. +fn cloudflare_scoped(secret: &str, http: &dyn HttpClient, rejection: &str) -> KeyVerifyOutcome { + let bearer = format!("Bearer {secret}"); + let response = match http.get( + CLOUDFLARE_ACCOUNTS, + &[("Authorization", &bearer), ("Accept", "application/json")], + ) { + Ok(response) => response, + Err(detail) => return unreachable_outcome("Cloudflare", &detail), + }; + + match response.status { + // Cloudflare wraps every answer of its own; a 200 carrying anything + // else came from something in the path, not from the API, and proves + // nothing about the token. + 200 if cloudflare_succeeded(&response.body) => KeyVerifyOutcome::new( + KeyVerifyStatus::Valid, + "confirmed by listing accounts with it — Cloudflare's token-verify endpoint \ + only answers for user-owned tokens, and this one is account-owned or scoped" + .to_string(), + ), + 200 => KeyVerifyOutcome::new( + KeyVerifyStatus::Unreachable, + "Cloudflare answered HTTP 200 with something that is not its own JSON envelope" + .to_string(), + ), + 429 => KeyVerifyOutcome::new( + KeyVerifyStatus::Unreachable, + format!("Cloudflare rate-limited the check ({rejection}); try again shortly"), + ), + 500..=599 => KeyVerifyOutcome::new( + KeyVerifyStatus::Unreachable, + format!("Cloudflare returned HTTP {} ({rejection})", response.status), + ), + _ => KeyVerifyOutcome::new( + KeyVerifyStatus::Inconclusive, + format!( + "Cloudflare said `{rejection}` to both checks patchbay can make, and a token \ + scoped to one product answers exactly like a revoked one. Open the token in \ + the dashboard, or run the command that uses it — patchbay cannot tell these \ + two apart from here" + ), + ), + } +} + +/// Whether a body is Cloudflare's own envelope reporting success. Read through +/// its own struct because `result` is a list here and an object on the verify +/// endpoint, and only the flag matters. +fn cloudflare_succeeded(body: &str) -> bool { + serde_json::from_str::(body).is_ok_and(|envelope| envelope.success) +} + // --------------------------------------------------------------------------- // github // --------------------------------------------------------------------------- @@ -427,6 +537,21 @@ struct GhError { } fn github(secret: &str, http: &dyn HttpClient) -> KeyVerifyOutcome { + // A GitHub App's private key is a PEM, not a bearer token: `/user` can only + // ever answer "Bad credentials" to it, which reads as a dead key when the + // key is fine. There is no request worth spending here — verifying an App + // means signing a JWT with this PEM and presenting it as the app, and the + // registry has no app id to sign for. + if is_pem(secret) { + return KeyVerifyOutcome::new( + KeyVerifyStatus::Unsupported, + "this is a PEM private key, not a token GitHub's API will authenticate. A GitHub \ + App key is verified by minting a JWT with it, which needs the app id patchbay \ + does not have — so patchbay has no way to ask. Check it under the App's settings." + .to_string(), + ); + } + let bearer = format!("Bearer {secret}"); let response = match http.get( GITHUB_USER, @@ -491,6 +616,15 @@ fn github(secret: &str, http: &dyn HttpClient) -> KeyVerifyOutcome { } } +/// Whether a value is PEM armour rather than a token. +/// +/// Shape only — the value is read, never reported. RFC 7468 armour always opens +/// with `-----BEGIN`, and no issuer hands out a token that starts that way, so +/// there is nothing to be clever about here. +fn is_pem(secret: &str) -> bool { + secret.trim_start().starts_with("-----BEGIN") +} + /// `"repo, workflow, read:org"` -> three scopes. An empty header is an empty /// list, not a failure. fn parse_scopes(raw: &str) -> Vec { @@ -674,12 +808,21 @@ mod tests { "messages": [{ "code": 10000, "message": "This API Token is valid and active" }] }"#; - /// What a wrong token really gets back: HTTP 400, success false. + /// What a wrong token really gets back: HTTP 400, success false. Also, + /// exactly, what a live account-owned token gets back from `/user/tokens/ + /// verify` — which is the whole reason the fallback exists. const CF_BAD: &str = r#"{ "result": null, "success": false, "errors": [{ "code": 1000, "message": "Invalid API Token" }], "messages": [] }"#; + /// One page of one account, trimmed. Only `success` is read. + const CF_ACCOUNTS: &str = r#"{ + "result": [{ "id": "01a7362d577a6c3019a474fd6f485823", "name": "Pathors" }], + "success": true, "errors": [], "messages": [], + "result_info": { "page": 1, "per_page": 1, "count": 1, "total_count": 1 } + }"#; + #[test] fn test_cloudflare_active_token_is_valid_and_carries_its_expiry() { let http = StubHttp::responding(HttpResponse::new(200, CF_ACTIVE)); @@ -695,6 +838,11 @@ mod tests { out.scopes.is_empty(), "the verify endpoint lists no policies" ); + assert_eq!( + http.call_count(), + 1, + "a definitive answer must not be second-guessed" + ); } #[test] @@ -714,12 +862,93 @@ mod tests { } #[test] - fn test_cloudflare_rejected_token_is_invalid_with_the_providers_own_message() { - let http = StubHttp::responding(HttpResponse::new(400, CF_BAD)); + fn test_cloudflare_account_scoped_token_rejected_by_verify_is_valid_if_it_can_list_accounts() { + // Observed on real keys: an R2 token and an account API token, both in + // daily use, both told "Invalid API Token" by /user/tokens/verify. + let http = StubHttp::responding_in_turn(vec![ + HttpResponse::new(400, CF_BAD), + HttpResponse::new(200, CF_ACCOUNTS), + ]); + let out = verify_key_with(&entry("cloudflare"), "cf-secret", &http); + + assert_eq!(out.status, KeyVerifyStatus::Valid); + assert!(!out.status.is_bad_news()); + assert!( + out.detail.contains("user-owned"), + "the detail has to explain why the first check said otherwise: {}", + out.detail + ); + assert_eq!(http.urls(), vec![CLOUDFLARE_VERIFY, CLOUDFLARE_ACCOUNTS]); + // Same secret, same one place, on the second request too. + let headers = http.last_headers(); + let auth = headers.iter().find(|(k, _)| k == "Authorization").unwrap(); + assert_eq!(auth.1, "Bearer cf-secret"); + assert!(!http.last_url().unwrap().contains("cf-secret")); + } + + #[test] + fn test_cloudflare_rejected_by_both_checks_is_inconclusive_because_a_revoked_token_and_a_narrowly_scoped_one_are_the_same_response( + ) { + // Deliberately undecidable: this is the response a revoked token gets + // AND the response a live R2-only token gets. Calling it `invalid` + // would tell someone to rotate a key that is working. + let http = StubHttp::responding_in_turn(vec![ + HttpResponse::new(400, CF_BAD), + HttpResponse::new(403, CF_BAD), + ]); let out = verify_key_with(&entry("cloudflare"), "nope", &http); - assert_eq!(out.status, KeyVerifyStatus::Invalid); - assert_eq!(out.detail, "Invalid API Token"); - assert!(out.status.is_bad_news()); + + assert_eq!(out.status, KeyVerifyStatus::Inconclusive); + assert!(!out.status.is_bad_news()); + assert_eq!(out.status.label(), "inconclusive"); + assert!( + out.detail.contains("cannot tell these two apart"), + "{}", + out.detail + ); + assert_eq!(http.call_count(), 2); + } + + #[test] + fn test_cloudflare_a_rate_limited_fallback_is_unreachable_not_a_verdict() { + let http = StubHttp::responding_in_turn(vec![ + HttpResponse::new(401, CF_BAD), + HttpResponse::new(429, "{}"), + ]); + let out = verify_key_with(&entry("cloudflare"), "s", &http); + assert_eq!(out.status, KeyVerifyStatus::Unreachable); + assert!(!out.status.is_bad_news()); + } + + #[test] + fn test_cloudflare_a_200_from_the_fallback_still_has_to_be_cloudflares_own_json() { + // A captive portal or a proxy answering 200 with HTML proves nothing; + // reading it as success would report a dead token as live. + let http = StubHttp::responding_in_turn(vec![ + HttpResponse::new(400, CF_BAD), + HttpResponse::new(200, "proxy says hello"), + ]); + let out = verify_key_with(&entry("cloudflare"), "s", &http); + assert_eq!(out.status, KeyVerifyStatus::Unreachable); + assert!(!out.status.is_bad_news()); + } + + #[test] + fn test_cloudflare_reports_a_user_owned_tokens_own_status_without_a_fallback() { + // The token's own reported status is definitive in both directions; + // only a rejection is ambiguous. + for (body, expected) in [ + (CF_ACTIVE, KeyVerifyStatus::Valid), + ( + r#"{"result":{"status":"disabled"},"success":true,"errors":[],"messages":[]}"#, + KeyVerifyStatus::Invalid, + ), + ] { + let http = StubHttp::responding(HttpResponse::new(200, body)); + let out = verify_key_with(&entry("cloudflare"), "s", &http); + assert_eq!(out.status, expected); + assert_eq!(http.call_count(), 1); + } } #[test] @@ -813,6 +1042,34 @@ mod tests { assert_eq!(out.expires_at, None); } + #[test] + fn test_github_app_private_key_is_unsupported_and_never_asks() { + // Observed on a real key: a GitHub App's PEM registered under + // `github`, reported `invalid` — "Bad credentials" — while the App was + // signing CI tokens all day. `/user` has no answer for a PEM, so the + // request is not worth making. + let http = StubHttp::failing("should not be called"); + let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow…\n-----END RSA PRIVATE KEY-----\n"; + let out = verify_key_with(&entry("github"), pem, &http); + + assert_eq!(out.status, KeyVerifyStatus::Unsupported); + assert!(!out.status.is_bad_news()); + assert!(out.detail.contains("JWT"), "{}", out.detail); + assert_eq!(http.call_count(), 0, "a PEM must not reach the network"); + assert!(!serde_json::to_string(&out).unwrap().contains("MIIEow")); + + // Leading whitespace from a heredoc or a copy-paste does not disguise + // it, and an ordinary token still takes the request path. + let out = verify_key_with(&entry("gh"), &format!("\n {pem}"), &http); + assert_eq!(out.status, KeyVerifyStatus::Unsupported); + assert_eq!(http.call_count(), 0); + + let http = StubHttp::responding(HttpResponse::new(200, r#"{"login":"octocat"}"#)); + let out = verify_key_with(&entry("github"), "ghp_ordinary_token", &http); + assert_eq!(out.status, KeyVerifyStatus::Valid); + assert_eq!(http.call_count(), 1); + } + #[test] fn test_github_401_is_invalid_with_githubs_message() { let http = StubHttp::responding(HttpResponse::new( @@ -1081,6 +1338,8 @@ mod tests { assert!(!KeyVerifyStatus::Valid.is_bad_news()); assert!(!KeyVerifyStatus::Unsupported.is_bad_news()); assert!(!KeyVerifyStatus::Unreachable.is_bad_news()); + assert!(!KeyVerifyStatus::Inconclusive.is_bad_news()); assert_eq!(KeyVerifyStatus::Unreachable.label(), "unreachable"); + assert_eq!(KeyVerifyStatus::Inconclusive.label(), "inconclusive"); } } diff --git a/crates/patchbay-core/src/lib.rs b/crates/patchbay-core/src/lib.rs index 7660d8c..e86eba2 100644 --- a/crates/patchbay-core/src/lib.rs +++ b/crates/patchbay-core/src/lib.rs @@ -78,4 +78,4 @@ pub use types::{ ActiveConcept, ConnectionState, Expiry, KeyRef, Note, NoteKind, PermissionScope, PermissionsReport, Profile, SwitchOutcome, ToolCategory, ToolStatus, VerifyOutcome, }; -pub use versions::{CheckOptions, CheckReport, Source, VersionCache, VersionInfo}; +pub use versions::{run_bounded, CheckOptions, CheckReport, Source, VersionCache, VersionInfo}; diff --git a/crates/patchbay-core/src/migrate/export.rs b/crates/patchbay-core/src/migrate/export.rs index 30ac553..7c3dd94 100644 --- a/crates/patchbay-core/src/migrate/export.rs +++ b/crates/patchbay-core/src/migrate/export.rs @@ -13,6 +13,11 @@ //! 4. **`SETUP.md`** — written at export time so a machine with no patchbay on //! it yet still has instructions. //! +//! One thing is written *outside* the bundle: a cleartext `-SETUP.md` sidecar +//! ([`sidecar_path`]) holding the install step and the import line, and nothing +//! else. `SETUP.md` inside the payload cannot be read without a `pb`, which +//! makes its own first step circular on the one machine that needs it. +//! //! And one thing that travels as *metadata only, by construction*: the env //! vault's project manifest ([`crate::envs`]). Ids, environments, sync pins — //! never a value, and never this machine's `attachments.json`, whose paths mean @@ -74,6 +79,9 @@ impl KeySelection { #[derive(Debug, Clone, PartialEq)] pub struct ExportReport { pub path: PathBuf, + /// The cleartext install instructions written beside the bundle. `None` + /// when that write failed — a warning says so, and the bundle is fine. + pub sidecar: Option, pub files: usize, pub bytes: usize, /// Tools with at least one file in the bundle. @@ -104,6 +112,18 @@ pub fn default_file_name(now: DateTime) -> String { ) } +/// `patchbay-2026-08-13-SETUP.md` beside `patchbay-2026-08-13.pbx`. +/// +/// Beside rather than inside, because this is the copy for a machine that has +/// nothing yet: see [`setup::sidecar`] for what may be in it. +pub fn sidecar_path(bundle: &Path) -> PathBuf { + let stem = bundle + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "patchbay".to_string()); + bundle.with_file_name(format!("{stem}-SETUP.md")) +} + /// Directory names that mean "this file is about to be uploaded". /// /// Matched on path components so `~/Documents/dropbox-migration-notes` is not @@ -629,9 +649,27 @@ pub fn write( force: bool, work_factor: Option, ) -> anyhow::Result { - let warnings = check_destination(path, force)?; + let mut warnings = check_destination(path, force)?; bundle::write(path, payload, passphrase, work_factor)?; + // The install step, in the clear, next to the file it explains. Not 0600 + // and not encrypted on purpose: it holds no inventory and no name from this + // machine, and the whole reason it exists is to be readable by a laptop + // with no `pb` on it. + let sidecar = sidecar_path(path); + let mut sidecar_written = Some(sidecar.clone()); + if let Err(e) = std::fs::write(&sidecar, setup::sidecar(&file_name(path))) { + // The bundle is already on disk and is the half that matters. Failing + // the export here would report that nothing was written when in fact + // every credential was packed. + warnings.push(format!( + "could not write the install instructions to {}: {e}. The bundle itself is fine — the \ + same instructions are in `SETUP.md` inside it, once there is a `pb` to read them", + sidecar.display() + )); + sidecar_written = None; + } + let mut tools_carried: Vec = Vec::new(); for file in &payload.files { if !tools_carried.contains(&file.tool) { @@ -649,6 +687,7 @@ pub fn write( Ok(ExportReport { path: path.to_path_buf(), + sidecar: sidecar_written, files: payload.files.len(), bytes: payload.bytes_carried(), tools_carried, @@ -675,6 +714,13 @@ pub fn write( }) } +/// The bundle's own name, for the import line in the sidecar. +fn file_name(path: &Path) -> String { + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "".to_string()) +} + #[cfg(test)] pub(crate) mod tests { use super::*; @@ -1214,6 +1260,43 @@ pub(crate) mod tests { assert!(path.is_file()); } + #[test] + fn test_the_install_instructions_land_beside_the_bundle_in_the_clear() { + let home = machine(); + let out = tempfile::tempdir().unwrap(); + let (paths, registry, vault, clients, envs) = exporter_parts(home.path()); + vault + .add( + crate::keys::NewKey::new("cf-api", "cli").provider("cloudflare"), + "cf-secret-1234", + false, + ) + .unwrap(); + let payload = Exporter { + paths: &paths, + registry: ®istry, + vault: &vault, + clients: &clients, + envs: &envs, + } + .payload(&KeySelection::All, Utc::now()) + .unwrap(); + + let path = out.path().join("patchbay-2026-08-13.pbx"); + let report = write(&path, &payload, "pass", false, Some(10)).unwrap(); + + let beside = report.sidecar.expect("the sidecar is the point of this"); + assert_eq!(beside, out.path().join("patchbay-2026-08-13-SETUP.md")); + let text = std::fs::read_to_string(&beside).unwrap(); + assert!(text.contains("releases/download"), "{text}"); + assert!(text.contains("pb import patchbay-2026-08-13.pbx"), "{text}"); + // It sits in the clear next to the bundle, so it carries nothing from + // the machine that wrote it — not a tool name, not a key id. + for forbidden in ["aws", "gcloud", "kubectl", "cf-api", "1234"] { + assert!(!text.contains(forbidden), "`{forbidden}` in:\n{text}"); + } + } + #[test] fn test_locations_used_by_the_exporter_are_the_ones_the_policy_names() { // Guards against a location that exists but no tool references, which diff --git a/crates/patchbay-core/src/migrate/import.rs b/crates/patchbay-core/src/migrate/import.rs index 71a30b3..c10a212 100644 --- a/crates/patchbay-core/src/migrate/import.rs +++ b/crates/patchbay-core/src/migrate/import.rs @@ -9,6 +9,15 @@ //! directory, so there is no window in which every credential on the machine //! sits unencrypted in `/tmp` waiting for a crash to leave it there. //! +//! # Nothing is written before the keychain has answered +//! +//! A bundle carrying key values is refused outright when the keystore will not +//! take a write ([`Importer::preflight_keystore`]). The ordering is the point: +//! the keychain half runs after the file half, and a macOS keychain refuses +//! *every* write from a session with no desktop login, so without the +//! preflight an import over ssh copies every credential file and then drops +//! every secret — a half-migrated machine that exited 0. +//! //! # Never clobber silently //! //! Every destination that already exists is copied to `.patchbay-bak` @@ -47,6 +56,10 @@ use crate::util::{backup, backup_path}; pub struct ImportOptions { /// Print the plan, change nothing. pub dry_run: bool, + /// Restore the key vault half only — no files, no MCP registrations, no + /// env projects. What finishes a move whose keychain writes were refused + /// while its files landed. + pub keys_only: bool, } /// What happened, or would happen, to one destination. @@ -134,6 +147,17 @@ impl ImportReport { pub fn open_items(&self) -> impl Iterator { self.remaining.iter().filter(|i| i.is_open()) } + + /// Key values the keystore would not take. Anything above zero means the + /// vault half of this machine is incomplete however well the file half + /// went, which is why the CLI exits non-zero on it: `pb import && ./deploy` + /// must not run against a machine whose secrets never arrived. + pub fn keys_refused(&self) -> usize { + self.keys + .iter() + .filter(|k| matches!(k.outcome, FileOutcome::Skipped { .. })) + .count() + } } /// Everything an import writes to. @@ -157,10 +181,21 @@ impl Importer<'_> { notes: Vec::new(), }; - self.restore_files(payload, options, &mut report)?; - self.restore_keys(payload, options, &mut report); - self.restore_mcp(payload, options, &mut report); - self.restore_env_projects(payload, options, &mut report); + self.preflight_keystore(payload, options, &mut report)?; + + if options.keys_only { + report.notes.push( + "--keys-only: the credential files, MCP registrations and env projects in this \ + bundle were not looked at; re-run without the flag to restore them" + .to_string(), + ); + self.restore_keys(payload, options, &mut report); + } else { + self.restore_files(payload, options, &mut report)?; + self.restore_keys(payload, options, &mut report); + self.restore_mcp(payload, options, &mut report); + self.restore_env_projects(payload, options, &mut report); + } // The gaps are recomputed here rather than copied out of the manifest: // the manifest's list is what the *source* predicted, and by now some @@ -176,6 +211,51 @@ impl Importer<'_> { Ok(report) } + /// Ask the keystore whether it will accept a write at all, before anything + /// at all is written. + /// + /// A keychain that refuses one write refuses all of them: on macOS that is + /// what a session with no desktop login looks like, and it is a property of + /// how the command was started rather than of the bundle. So the answer is + /// worth one throwaway item ([`KeyRegistry::probe_writable`]) up front: + /// every credential file restored and then every secret in the bundle + /// dropped is the worst outcome this command has, and it must not be + /// reachable. + /// + /// A dry run cannot ask, because asking is writing. It says so instead. + fn preflight_keystore( + &self, + payload: &Payload, + options: &ImportOptions, + report: &mut ImportReport, + ) -> anyhow::Result<()> { + if payload.secrets.is_empty() { + return Ok(()); + } + if options.dry_run { + report.notes.push(format!( + "the {} key value(s) in this bundle were NOT checked against the {}: the only way \ + to ask whether it will accept a write is to write, and a dry run writes nothing. \ + A real import asks before it copies a single file.", + payload.secrets.len(), + self.vault.store_name() + )); + return Ok(()); + } + self.vault.probe_writable().map_err(|e| { + anyhow::anyhow!( + "the {} refused a test write, so none of the {} key value(s) in this bundle can \ + be stored ({e:#}) — nothing was written, and the bundle still holds them. On \ + macOS a keychain refuses every write from a session with no desktop login (ssh, \ + cron, a launchd job): re-run this from a Terminal in your own desktop session. \ + If the files are already here, `pb import --keys-only` finishes just \ + the vault half.", + self.vault.store_name(), + payload.secrets.len() + ) + }) + } + fn restore_files( &self, payload: &Payload, @@ -477,6 +557,15 @@ mod tests { impl Machine { fn new(files: &[(&str, &str)]) -> Self { + Self::with_keystore(files, Box::new(MemoryKeystore::new())) + } + + /// A machine whose vault is backed by something other than a working + /// store, for the keychain-refuses-everything paths. + fn with_keystore( + files: &[(&str, &str)], + store: Box, + ) -> Self { let dir = tempfile::tempdir().unwrap(); for (rel, body) in files { let path = dir.path().join(rel); @@ -487,7 +576,7 @@ mod tests { let paths = Paths::for_test(&home); Self { registry: Registry::all(paths.clone()), - vault: KeyRegistry::new(home.join("keys.json"), Box::new(MemoryKeystore::new())), + vault: KeyRegistry::new(home.join("keys.json"), store), clients: McpClientRegistry::with_paths(paths.clone()), envs: EnvRegistry::new( home.join("projects.json"), @@ -513,6 +602,21 @@ mod tests { } fn import(&self, payload: &Payload, dry_run: bool) -> ImportReport { + self.try_import( + payload, + &ImportOptions { + dry_run, + ..Default::default() + }, + ) + .unwrap() + } + + fn try_import( + &self, + payload: &Payload, + options: &ImportOptions, + ) -> anyhow::Result { Importer { paths: &self.paths, registry: &self.registry, @@ -520,8 +624,7 @@ mod tests { clients: &self.clients, envs: &self.envs, } - .run(payload, &ImportOptions { dry_run }) - .unwrap() + .run(payload, options) } fn read(&self, rel: &str) -> String { @@ -719,6 +822,146 @@ mod tests { assert!(gap.command.contains("pb key add cf-api"), "{gap:?}"); } + /// A store that takes the write probe and then refuses every real key. + /// + /// Only the probe's id can start with a `.` — `validate_id` refuses that + /// shape for a registered key — so this is the shape of a session that + /// locks *between* the preflight and the restore, which the preflight + /// cannot promise away. + struct RefusesEveryRealKey; + + impl crate::keystore::Keystore for RefusesEveryRealKey { + fn put(&self, id: &str, _secret: &str) -> anyhow::Result<()> { + if id.starts_with('.') { + return Ok(()); + } + anyhow::bail!("User interaction is not allowed") + } + fn get(&self, _id: &str) -> anyhow::Result> { + Ok(None) + } + fn delete(&self, _id: &str) -> anyhow::Result { + Ok(true) + } + fn describe(&self) -> &'static str { + "test keystore that refuses real keys" + } + } + + /// The source machine of a bundle that carries files and one key value. + fn source_with_a_key() -> Machine { + let source = Machine::new(&source_files()); + source + .vault + .add( + NewKey::new("cf-api", "cli").provider("cloudflare"), + "cf-secret-1234", + false, + ) + .unwrap(); + source + } + + #[test] + fn test_a_keychain_that_refuses_writes_aborts_before_a_single_file_lands() { + let source = source_with_a_key(); + let payload = source.payload(KeySelection::All); + + let dest = Machine::with_keystore(&[], Box::new(MemoryKeystore::failing_put())); + let err = dest + .try_import(&payload, &ImportOptions::default()) + .unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("refused a test write"), "{msg}"); + // The cause and the fix, both named. + assert!(msg.contains("no desktop login"), "{msg}"); + assert!(msg.contains("desktop session"), "{msg}"); + assert!(msg.contains("--keys-only"), "{msg}"); + + // The whole point of the ordering: not one file was copied. + assert!(walk(&dest.home).is_empty(), "{:?}", walk(&dest.home)); + assert!(!dest.exists(".aws/config")); + } + + #[test] + fn test_a_dry_run_says_the_keychain_was_never_asked() { + let source = source_with_a_key(); + let payload = source.payload(KeySelection::All); + + // Even a store that would refuse: a dry run does not find out, because + // finding out is a write. + let dest = Machine::with_keystore(&[], Box::new(MemoryKeystore::failing_put())); + let report = dest + .try_import( + &payload, + &ImportOptions { + dry_run: true, + ..Default::default() + }, + ) + .unwrap(); + assert!( + report.notes.iter().any(|n| n.contains("NOT checked")), + "{:?}", + report.notes + ); + assert!(walk(&dest.home).is_empty()); + } + + #[test] + fn test_a_key_the_keychain_refuses_is_counted_not_hidden() { + let source = source_with_a_key(); + let payload = source.payload(KeySelection::All); + + let dest = Machine::with_keystore(&[], Box::new(RefusesEveryRealKey)); + let report = dest.import(&payload, false); + assert_eq!(report.keys_refused(), 1); + match &report.keys[0].outcome { + FileOutcome::Skipped { reason } => { + assert!(reason.contains("User interaction"), "{reason}"); + // The reason names the id and nothing else about the value. + assert!(!reason.contains("cf-secret-1234"), "{reason}"); + } + other => panic!("expected a skip, got {other:?}"), + } + // One refusal does not abort the file half — which is why the count + // has to survive into the report at all. + assert!(report.written() >= 6, "{report:?}"); + } + + #[test] + fn test_keys_only_restores_the_vault_and_touches_nothing_else() { + let source = source_with_a_key(); + crate::migrate::export::tests::seed_env_vault( + &source.envs, + &source.home.join("repos/pathors"), + ); + let payload = source.payload(KeySelection::All); + + let dest = Machine::new(&[]); + let report = dest + .try_import( + &payload, + &ImportOptions { + keys_only: true, + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(dest.vault.get_secret("cf-api").unwrap(), "cf-secret-1234"); + assert!(report.files.is_empty()); + assert!(report.mcp.is_empty()); + assert!(report.env_projects.is_empty()); + assert!(!dest.exists(".aws/config")); + assert!(dest.envs.projects().unwrap().is_empty()); + assert!( + report.notes.iter().any(|n| n.contains("--keys-only")), + "{:?}", + report.notes + ); + } + #[test] fn test_mcp_registrations_are_restored_through_the_client_writer() { let source = Machine::new(&[( diff --git a/crates/patchbay-core/src/migrate/plan.rs b/crates/patchbay-core/src/migrate/plan.rs index 1c3165f..b9d223b 100644 --- a/crates/patchbay-core/src/migrate/plan.rs +++ b/crates/patchbay-core/src/migrate/plan.rs @@ -20,7 +20,7 @@ //! does with no arguments. use super::manifest::{Manifest, SetupItem, SetupStatus, ToolRecord}; -use super::policy::{policy_for, Portability}; +use super::policy::{policy_for, Portability, ToolPolicy}; use crate::envs::EnvRegistry; use crate::keys::KeyRegistry; use crate::mcp_clients::McpClientRegistry; @@ -40,13 +40,12 @@ pub fn plan( envs: &EnvRegistry, manifest: Option<&Manifest>, ) -> Vec { - let _ = paths; let mut items = Vec::new(); let statuses = registry.status_all(); for status in &statuses { let expected = manifest.and_then(|m| m.tool(&status.tool)); - items.extend(tool_items(status, expected)); + items.extend(tool_items(paths, status, expected)); } items.extend(key_items(vault, manifest)); items.extend(mcp_items(clients, manifest)); @@ -85,7 +84,11 @@ fn source_had_something(expected: Option<&ToolRecord>, status: &ToolStatus) -> b } } -fn tool_items(status: &ToolStatus, expected: Option<&ToolRecord>) -> Vec { +/// The questions this machine still owes an answer to for one tool, in the +/// order a person would work them: get the CLI, log in, then be the right +/// person. Each one is a separate builder because each has its own reason to +/// exist, and the reasons are what the items carry. +fn tool_items(paths: &Paths, status: &ToolStatus, expected: Option<&ToolRecord>) -> Vec { let Some(policy) = policy_for(&status.tool) else { return Vec::new(); }; @@ -93,42 +96,89 @@ fn tool_items(status: &ToolStatus, expected: Option<&ToolRecord>) -> Vec Option { + if state != ConnectionState::NotInstalled { + return None; + } + Some( + SetupItem::new( + format!("install:{}", status.tool), + &status.tool, + format!("`{}` is not installed on this machine", status.tool), + ) + .command(policy.install, false), + ) +} + +/// 2. Is it logged in? +/// +/// Always an item, even when the answer is yes: a checklist people can watch +/// shrink is the point, so a closed question stays on the list as `Done`. +fn login_item( + paths: &Paths, + policy: &ToolPolicy, + status: &ToolStatus, + expected: Option<&ToolRecord>, + state: ConnectionState, +) -> SetupItem { + let (login_status, what) = login_verdict(policy, status, state); + let mut login = SetupItem::new(format!("tool:{}", status.tool), &status.tool, what) + .command(policy.fix, policy.needs_browser) + .status(login_status); + // Why the copy could not have brought this login with it: a keychain-held + // or device-identifying credential has to be re-made here, and the user + // deserves that reason next to the command. + if !matches!(policy.portability, Portability::Portable { .. }) { + login = login.detail(policy.portability.reason()); + } + if let Some(record) = expected { + login = source_history(login, record); + } + if status.tool == "kubectl" && login_status == SetupStatus::Open { + login = kubeconfig_fix(paths, login); } + login +} - // 2. Is it logged in? - // - // Two deliberate departures from the status board here, both because a - // checklist is a different thing from a warning light: - // - // * `Attention` is Open only when the credential has ACTUALLY expired. The - // board is right to flag a gcloud access token with 40 minutes left — - // but gcloud refreshes that itself, and a setup list that can never - // reach zero is a setup list people stop working. - // * a `concurrent` tool with profiles is Done. docker, rclone, ssh and npm - // have no active profile by design, so `Disconnected` there means - // "healthy", not "logged out". +/// The login verdict and the sentence that explains it. +/// +/// Two deliberate departures from the status board here, both because a +/// checklist is a different thing from a warning light: +/// +/// * `Attention` is Open only when the credential has ACTUALLY expired. The +/// board is right to flag a gcloud access token with 40 minutes left — +/// but gcloud refreshes that itself, and a setup list that can never +/// reach zero is a setup list people stop working. +/// * a `concurrent` tool with profiles is Done. docker, rclone, ssh and npm +/// have no active profile by design, so `Disconnected` there means +/// "healthy", not "logged out". +fn login_verdict( + policy: &ToolPolicy, + status: &ToolStatus, + state: ConnectionState, +) -> (SetupStatus, String) { let now = chrono::Utc::now(); let expired = status .active_expiry() .or_else(|| status.soonest_expiry()) .is_some_and(|at| at <= now); - let (login_status, what) = match state { + match state { ConnectionState::Connected => { (SetupStatus::Done, format!("`{}` is logged in", status.tool)) } @@ -162,50 +212,112 @@ fn tool_items(status: &ToolStatus, expected: Option<&ToolRecord>) -> Vec SetupItem { + if let Some(active) = &record.active { + login = login.detail(format!("the old machine was `{active}` here")); } - items.push(login); + if !record.scopes.is_empty() { + login = login.detail(format!( + "it had these scopes, which the new login has to match: {}", + record.scopes.join(", ") + )); + } + login +} - // 3. Logged in, but as somebody else. patchbay can fix this one itself. - if state != ConnectionState::NotInstalled { - if let Some(record) = expected { - if let (Some(want), here) = (record.active.as_ref(), status.active.as_ref()) { - let known = status.profiles.iter().any(|p| &p.id == want); - if here != Some(want) && known { - items.push( - SetupItem::new( - format!("switch:{}", status.tool), - &status.tool, - format!( - "`{}` is on `{}`; the old machine was on `{want}`", - status.tool, - here.map(String::as_str).unwrap_or("nothing") - ), - ) - .command(format!("pb use {} {want}", status.tool), false) - .auto(true), - ); - } - } - } +/// 3. Logged in, but as somebody else. patchbay can fix this one itself, so +/// this is the rare `auto` item. +/// +/// Only when the wanted profile is already known here — switching to a profile +/// this machine has never seen is a login, not a switch. +fn switch_item( + status: &ToolStatus, + expected: Option<&ToolRecord>, + state: ConnectionState, +) -> Option { + if state == ConnectionState::NotInstalled { + return None; } - items + let want = expected?.active.as_ref()?; + let here = status.active.as_ref(); + let known = status.profiles.iter().any(|p| &p.id == want); + if here == Some(want) || !known { + return None; + } + Some( + SetupItem::new( + format!("switch:{}", status.tool), + &status.tool, + format!( + "`{}` is on `{}`; the old machine was on `{want}`", + status.tool, + here.map(String::as_str).unwrap_or("nothing") + ), + ) + .command(format!("pb use {} {want}", status.tool), false) + .auto(true), + ) +} + +/// Replace kubectl's login command with the `KUBECONFIG` line the files on this +/// machine actually need — the one gap an import *creates*. +/// +/// Several kubeconfigs land in one directory and kubectl merges only what the +/// variable names, so the fix is a shell line rather than a login. The import +/// says so in its own output, but that has scrolled away by the time anybody +/// runs `pb plan`, which used to offer `kubectl config get-contexts` — a +/// command that shows the problem again rather than fixing it. +/// +/// Nothing has to be persisted for this: the files themselves are the record, +/// so the line is re-derived from the directory every time. +fn kubeconfig_fix(paths: &Paths, item: SetupItem) -> SetupItem { + let unmerged = unmerged_kubeconfigs(paths); + if unmerged.is_empty() { + return item; + } + let dir = super::policy::Location::KubeConfigs.destination(paths, ""); + let list: Vec = unmerged.iter().map(|p| p.display().to_string()).collect(); + item.command(format!("export KUBECONFIG={}", list.join(":")), false) + .detail(format!( + "{} kubeconfig(s) are already in {} — an import puts them there — but KUBECONFIG \ + names none of them, and kubectl merges only the files it names", + unmerged.len(), + dir.display() + )) +} + +/// Kubeconfigs in the directory an import restores them into that +/// `KUBECONFIG` does not name. +/// +/// The `.yaml`/`.yml` test is the one the kubectl probe already applies when it +/// scans a config *directory*. Everything else in `~/.kube` — `cache/`, +/// `http-cache/`, a `.patchbay-bak` this import wrote — is not a kubeconfig, +/// and a `KUBECONFIG` line naming one of those is worse than none. +fn unmerged_kubeconfigs(paths: &Paths) -> Vec { + let named = paths.kube_configs(); + let dir = super::policy::Location::KubeConfigs.destination(paths, ""); + let Ok(entries) = std::fs::read_dir(&dir) else { + return Vec::new(); + }; + let mut found: Vec = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .filter(|path| { + matches!( + path.extension().and_then(|e| e.to_str()), + Some("yaml") | Some("yml") + ) + }) + .filter(|path| !named.contains(path)) + .collect(); + found.sort(); + found } // --------------------------------------------------------------------------- @@ -853,6 +965,89 @@ mod tests { .is_none()); } + /// The state the real move ended in: kubectl installed, several + /// kubeconfigs sitting where the import put them, and `KUBECONFIG` + /// naming none of them. + fn machine_with_loose_kubeconfigs() -> (tempfile::TempDir, Paths) { + let dir = tempfile::tempdir().unwrap(); + let kube = dir.path().join(".kube"); + fs::create_dir_all(kube.join("cache")).unwrap(); + for name in ["prod.yaml", "staging.yaml"] { + fs::write(kube.join(name), "apiVersion: v1\nclusters: []\n").unwrap(); + } + // Neither of these is a kubeconfig, and neither may reach the line. + fs::write(kube.join("config.patchbay-bak"), "apiVersion: v1\n").unwrap(); + fs::write(kube.join("http-cache.json"), "{}").unwrap(); + + // `has_binary` is true under a scripted exec, which is what makes + // kubectl read as installed with nothing logged in. + let paths = Paths::for_test(dir.path()) + .with_exec(std::sync::Arc::new(crate::util::FakeExec::new())); + (dir, paths) + } + + fn plan_for(paths: &Paths) -> Vec { + let registry = Registry::all(paths.clone()); + let vault = KeyRegistry::new( + paths.home().join("keys.json"), + Box::new(MemoryKeystore::new()), + ); + let clients = McpClientRegistry::with_paths(paths.clone()); + let envs = EnvRegistry::new( + paths.home().join("projects.json"), + paths.home().join("attachments.json"), + Box::new(MemoryKeystore::new()), + ); + plan(paths, ®istry, &vault, &clients, &envs, None) + } + + #[test] + fn test_the_kubectl_item_carries_the_kubeconfig_line_an_import_needs() { + let (dir, paths) = machine_with_loose_kubeconfigs(); + let items = plan_for(&paths); + let kubectl = item(&items, "tool:kubectl"); + assert!(kubectl.is_open(), "{kubectl:?}"); + + // The fix, not another look at the problem. + assert!( + kubectl.command.starts_with("export KUBECONFIG="), + "{kubectl:?}" + ); + let kube = dir.path().join(".kube"); + assert_eq!( + kubectl.command, + format!( + "export KUBECONFIG={}:{}", + kube.join("prod.yaml").display(), + kube.join("staging.yaml").display() + ) + ); + assert!( + kubectl.detail.iter().any(|d| d.contains("merges only")), + "{kubectl:?}" + ); + } + + #[test] + fn test_kubeconfigs_the_variable_already_names_change_nothing() { + let (dir, _) = machine_with_loose_kubeconfigs(); + let kube = dir.path().join(".kube"); + let list = format!( + "{}:{}", + kube.join("prod.yaml").display(), + kube.join("staging.yaml").display() + ); + // Same files, this time merged: there is no gap to describe, so the + // item goes back to the policy's own command. + let paths = Paths::for_test(dir.path()) + .with_exec(std::sync::Arc::new(crate::util::FakeExec::new())) + .with_env("KUBECONFIG", &list); + let items = plan_for(&paths); + let kubectl = item(&items, "tool:kubectl"); + assert_eq!(kubectl.command, "kubectl config get-contexts"); + assert!(kubectl.detail.is_empty(), "{kubectl:?}"); + } + #[test] fn test_every_item_carries_a_command() { let source = Machine::new(&[ diff --git a/crates/patchbay-core/src/migrate/setup.rs b/crates/patchbay-core/src/migrate/setup.rs index b1759b6..3903e61 100644 --- a/crates/patchbay-core/src/migrate/setup.rs +++ b/crates/patchbay-core/src/migrate/setup.rs @@ -7,10 +7,64 @@ //! //! Same rule as the manifest it is generated from: **no secret values.** It is //! rendered from a [`Manifest`], which cannot contain any. +//! +//! One step of it is written twice. Everything here is inside the encrypted +//! payload, which makes the first step circular — you need `pb` to read the +//! instructions for installing `pb` — so [`sidecar`] emits that step on its own, +//! in the clear, next to the bundle. It is the only part that may travel +//! unencrypted, and it says nothing about the machine. use super::manifest::Manifest; use super::policy::{policy_for, PortabilityKind}; +/// The heading of the install step, so the bundle's `SETUP.md` and the +/// cleartext [`sidecar`] name the same thing. +const INSTALL_HEADING: &str = "Get patchbay onto this machine"; + +/// The install commands themselves, without a heading. +/// +/// A function rather than prose written twice: `pb export` emits these both +/// inside the bundle and in the clear beside it, and the copy that drifted +/// would be the one somebody was reading on a laptop with no `pb` on it. +fn install_body() -> &'static str { + "```sh\n\ + # CLI + MCP server (Apple silicon; use x86_64-apple-darwin on Intel)\n\ + tag=v0.1.0; arch=aarch64-apple-darwin; tmp=$(mktemp -d)\n\ + curl -fsSL \"https://github.com/pathorsAI/patchbay/releases/download/$tag/pb-$tag-$arch.tar.gz\" \\\n\ + \x20 | tar xz -C \"$tmp\"\n\ + sudo mv \"$tmp/pb\" /usr/local/bin/\n\ + ```\n\n\ + Or build it: `git clone https://github.com/pathorsAI/patchbay && cd patchbay && \ + cargo install --path crates/patchbay-cli`.\n\n" +} + +/// The install step, in the clear, to be written *beside* the bundle. +/// +/// `SETUP.md` is the fuller document and it lives inside the encrypted +/// payload, which makes the first step of it circular: you need `pb` to read +/// the instructions for installing `pb`. On a real move the receiving Mac had +/// neither, and the command was looked up on the releases page by hand. +/// +/// So this copy is cleartext, and therefore says **nothing about the machine** +/// — no inventory, no tool list, no key id, no variable name. It takes no +/// [`Manifest`], which is the guarantee rather than the intention: assume it +/// gets pasted into a chat. `bundle` is used for the import line and nothing +/// else. +pub fn sidecar(bundle: &str) -> String { + format!( + "# {INSTALL_HEADING}\n\n\ + {}\ + ## Then import\n\n\ + ```sh\n\ + pb import {bundle} --dry-run # see the plan, write nothing\n\ + pb import {bundle} # do it\n\ + ```\n\n\ + The passphrase is not in this file. Everything else about the move is inside the bundle, \ + as `SETUP.md`, which the import writes out.\n", + install_body() + ) +} + /// The whole document, as Markdown. pub fn render(manifest: &Manifest) -> String { let mut out = String::new(); @@ -23,18 +77,8 @@ pub fn render(manifest: &Manifest) -> String { manifest.source.patchbay_version, manifest.source.os )); - out.push_str( - "## 1. Get patchbay onto this machine\n\n\ - ```sh\n\ - # CLI + MCP server (Apple silicon; use x86_64-apple-darwin on Intel)\n\ - tag=v0.1.0; arch=aarch64-apple-darwin; tmp=$(mktemp -d)\n\ - curl -fsSL \"https://github.com/pathorsAI/patchbay/releases/download/$tag/pb-$tag-$arch.tar.gz\" \\\n\ - \x20 | tar xz -C \"$tmp\"\n\ - sudo mv \"$tmp/pb\" /usr/local/bin/\n\ - ```\n\n\ - Or build it: `git clone https://github.com/pathorsAI/patchbay && cd patchbay && \ - cargo install --path crates/patchbay-cli`.\n\n", - ); + out.push_str(&format!("## 1. {INSTALL_HEADING}\n\n")); + out.push_str(install_body()); out.push_str( "## 2. Import\n\n\ @@ -301,6 +345,45 @@ mod tests { assert!(md.contains(".patchbay-bak"), "{md}"); } + #[test] + fn test_the_cleartext_sidecar_carries_the_install_step_and_nothing_else() { + let md = sidecar("patchbay-2026-08-13.pbx"); + // The one step that cannot be read from inside the bundle. + assert!(md.contains("releases/download"), "{md}"); + assert!( + md.contains("cargo install --path crates/patchbay-cli"), + "{md}" + ); + assert!(md.contains("pb import patchbay-2026-08-13.pbx"), "{md}"); + + // Assume it is pasted into a chat. `sidecar` takes no manifest at all, + // which is the real guarantee; this is here so that a change which + // hands it one gets caught. (`gh` and `pathors` are not on the list: + // both are substrings of the patchbay repo URL.) + let manifest = manifest(); + for forbidden in [ + "aws", + "cf-api", + "9876", + "GRAFANA_TOKEN", + "cursor", + "octocat", + "me@work.com", + ] { + assert!( + !md.contains(forbidden), + "`{forbidden}` in the sidecar:\n{md}" + ); + } + // …and the inventory really is in the document it sits beside, so the + // loop above is a difference between the two rather than a vacuous + // assertion. + let inside = render(&manifest); + for present in ["aws", "cf-api", "9876", "me@work.com"] { + assert!(inside.contains(present), "`{present}` missing:\n{inside}"); + } + } + #[test] fn test_setup_md_lists_each_gap_with_its_exact_command() { let md = render(&manifest()); diff --git a/crates/patchbay-core/src/versions.rs b/crates/patchbay-core/src/versions.rs index c4fb9e9..d344d89 100644 --- a/crates/patchbay-core/src/versions.rs +++ b/crates/patchbay-core/src/versions.rs @@ -1522,8 +1522,14 @@ fn lookup_latest( /// Run `f` over `items` on at most `limit` scoped threads, preserving order. /// /// Scoped threads rather than a pool: everything borrowed here lives on the -/// stack of the caller, and the work is a fixed, small batch. -fn run_bounded(items: &[T], limit: usize, f: impl Fn(&T) -> R + Sync) -> Vec { +/// stack of the caller, and the work is a fixed, small batch. Public because +/// the version check is not the only sweep patchbay makes over a list of +/// network calls, and a second bounded runner would be a second set of bugs. +pub fn run_bounded( + items: &[T], + limit: usize, + f: impl Fn(&T) -> R + Sync, +) -> Vec { if items.is_empty() { return Vec::new(); } @@ -2656,7 +2662,7 @@ mod tests { } #[test] - fn test_github_rate_limiting_stops_after_the_first_hit_instead_of_spamming() { + fn test_a_spent_github_rate_limit_is_reported_on_every_tool_and_never_retried() { let dir = tempfile::tempdir().unwrap(); // Three GitHub-sourced tools, all installed somewhere unclassifiable so // they fall through to the GitHub route. @@ -2691,9 +2697,17 @@ mod tests { &deps(&runner, &brew, &http, &bins), ); + // Three GitHub tools, so three requests is the ceiling: what this + // asserts is that nothing retries a spent limit. It cannot assert + // fewer. The gate is read when a lookup *starts* and the run dispatches + // up to four at once, so how many requests a spent limit costs depends + // on how many were already in flight — which made the old `< 3` here + // fail 15 times in 40 local runs and go red on CI once the timing + // shifted. The guarantee the code does make has its own test below. assert!( - http.call_count() < 3, - "a spent rate limit must stop the run's other GitHub lookups, not be hit three times" + http.call_count() <= 3, + "no tool may ask GitHub twice; got {} calls for three tools", + http.call_count() ); for entry in &report.entries { assert_eq!(entry.latest, None); @@ -2706,6 +2720,57 @@ mod tests { } } + /// The promise in `lookup_latest`'s GitHub arm — one spent limit stops the + /// rest — is about lookups that have not started yet, and that is the half + /// worth pinning down: it is deterministic, and it is what keeps a second + /// `pb check-updates` from spending a budget that is already gone. + #[test] + fn test_a_github_lookup_that_starts_after_the_rate_limit_was_seen_never_asks() { + let http = StubHttp::responding( + HttpResponse::new(403, r#"{"message":"API rate limit exceeded"}"#) + .with_header("x-ratelimit-remaining", "0"), + ); + let calls = AtomicUsize::new(0); + let rate_limited = Mutex::new(false); + let spec = spec_for("doctl").expect("doctl is a GitHub-sourced tool"); + let started = Instant::now(); + + let first = lookup_latest( + spec, + None, + Source::Github, + &http, + &calls, + &rate_limited, + started, + ); + assert!(first.is_err(), "a 403 with no remaining quota is an error"); + assert!( + *rate_limited.lock().unwrap(), + "the first rate-limited answer has to arm the gate" + ); + assert_eq!(http.call_count(), 1); + + let second = lookup_latest( + spec, + None, + Source::Github, + &http, + &calls, + &rate_limited, + started, + ); + assert!( + second.unwrap_err().contains("skipped"), + "the second lookup must say it was skipped, not invent a failure" + ); + assert_eq!( + http.call_count(), + 1, + "a lookup starting after the limit was seen must not reach the network" + ); + } + #[test] fn test_run_bounded_preserves_order_and_runs_everything() { let items: Vec = (0..50).collect(); diff --git a/crates/patchbay-mcp/src/keys.rs b/crates/patchbay-mcp/src/keys.rs index fdbd261..c301e67 100644 --- a/crates/patchbay-mcp/src/keys.rs +++ b/crates/patchbay-mcp/src/keys.rs @@ -844,9 +844,10 @@ to the user about. A 'suggested' name is in neither: it is waiting on a human to ASK THE ISSUER whether a registered key still works. NOT gated: this returns a verdict, never \ the value, so it is safe to call whenever the answer would change what you do. -Makes one outbound HTTPS request to the provider using the stored secret, which patchbay reads \ -internally and never returns to you. Seconds, not milliseconds. Providers patchbay can \ -interrogate today: cloudflare and github. Anything else comes back 'unsupported'. +Makes an outbound HTTPS request to the provider using the stored secret, which patchbay reads \ +internally and never returns to you — sometimes a second one, where the first answer does not \ +settle it. Seconds, not milliseconds. Providers patchbay can interrogate today: cloudflare and \ +github. Anything else comes back 'unsupported'. Call it when the user asks whether a key is still good, before relying on a key for something \ expensive or destructive, or when an operation failed with something that smells like a bad \ @@ -862,6 +863,10 @@ expiry to the user when it is close. - 'invalid' — the issuer rejected it: revoked, deleted, disabled, or never real. Say so \ plainly; the user needs to rotate it, and then re-register with store_key + overwrite. - 'expired' — the issuer knows it, its lifetime is over. Same action: rotate and re-store. +- 'inconclusive' — the provider answered, and its answer fits a live key as well as a dead one. \ +A Cloudflare token scoped to one product is rejected by the same response a revoked token gets, \ +so patchbay declines to guess. Do NOT report this as a dead credential and do NOT advise \ +rotating; say patchbay could not tell, and that the token's own dashboard page can. - 'unsupported' — patchbay has no verification path for this provider. A normal answer, not a \ failure. Do not retry; point the user at the provider's dashboard. - 'unreachable' — the provider could not be reached (DNS, timeout, rate limit, 5xx). This says \ diff --git a/docs/key-vault.md b/docs/key-vault.md index 2bddbc9..7b72329 100644 --- a/docs/key-vault.md +++ b/docs/key-vault.md @@ -27,6 +27,8 @@ pb key edit cf-gh-actions-deploy --env CLOUDFLARE_API_TOKEN # metadata only pb key copy cf-gh-actions-deploy # to the clipboard, never to your terminal pb key run cf-gh-actions-deploy -- wrangler deploy # into a child process pb key verify cf-gh-actions-deploy # ask Cloudflare whether it still works +pb key verify cf-api gh-pat neon-api-key # several issuers, at once +pb key verify --all # sweep the whole vault pb key rm cf-gh-actions-deploy # metadata and Keychain item, both ``` @@ -166,10 +168,23 @@ back into the registry, so the vault converges on the truth instead of drifting from it. Every other provider answers `unsupported`, which is a normal answer and not a failure. +Ask about several keys, or about all of them. A sweep is one line per key and a +tally, and it runs the checks eight at a time rather than sixty round trips end +to end: + +```console +$ pb key verify --all +cf-r2-token-sonarqube-backups valid confirmed by listing accounts with it — Cloudflar… +gh-pat-release valid GitHub accepts it as YJack0000 +neon-api-key unsupported patchbay cannot verify `neon` keys yet — it knows… +openai-personal unsupported patchbay cannot verify `openai` keys yet — it know… +checked 4 keys: 2 unsupported, 2 valid +``` + | `--provider` | What patchbay asks | What comes back | |---|---|---| -| `cloudflare` (`cf`) | `GET /client/v4/user/tokens/verify` | The token's own status — `active`, `expired` or `disabled` — plus `expires_on` when the token has one, and Cloudflare's own message. The endpoint reports liveness, not policies, so scopes stay empty: an account API token's *reach* is not something this call will tell you, which is exactly why it is worth registering next to `wrangler`. | -| `github` (`gh`) | `GET /user` | The login it authenticates as, the classic-PAT scope list from `X-OAuth-Scopes`, and the expiry from `github-authentication-token-expiration`. A fine-grained PAT sends an empty scope header — that is a real answer, not a missing one; its permissions are per-repository and not enumerable here. | +| `cloudflare` (`cf`) | `GET /client/v4/user/tokens/verify`, then `GET /client/v4/accounts?per_page=1` if that rejects it | The token's own status — `active`, `expired` or `disabled` — plus `expires_on` when the token has one, and Cloudflare's own message. The endpoint reports liveness, not policies, so scopes stay empty: an account API token's *reach* is not something this call will tell you, which is exactly why it is worth registering next to `wrangler`. | +| `github` (`gh`) | `GET /user`, unless the value is a PEM | The login it authenticates as, the classic-PAT scope list from `X-OAuth-Scopes`, and the expiry from `github-authentication-token-expiration`. A fine-grained PAT sends an empty scope header — that is a real answer, not a missing one; its permissions are per-repository and not enumerable here. A GitHub **App private key** is a PEM rather than a bearer token, so it answers `unsupported` without a request: verifying an App means minting a JWT with the key, which needs an app id the registry does not hold. `/user` would say "Bad credentials" to a perfectly good App key, which is why patchbay does not ask. | | `grafana` | `GET {endpoint}/api/org` | The org the token belongs to. **Needs `--endpoint`** — a Grafana token is only meaningful against the instance that issued it, and there is no one address to ask. Service-account tokens carry a role rather than a scope list, so scopes stay empty. | ```sh @@ -183,11 +198,28 @@ URL and Grafana Cloud answers `/api/org` with its single-page app — HTML, HTTP 200 — which patchbay reports as `unreachable` rather than reading a dead token as live. +Cloudflare's token-verify endpoint only speaks for tokens owned by a *user*. +Hand it an account-owned or scoped token — an R2 token, a Workers token, most +of what the dashboard issues now — and it answers `success: false`, error 1000, +"Invalid API Token", while the token is deploying in production. That is why +patchbay asks a second question when the first one says no: can the token read +one page of one account? A `200` there is proof of life, and the verdict says +where it came from. If that is rejected too, the answer is `inconclusive` — +because a token scoped to R2 alone cannot list accounts either, and the two +cases are the same HTTP response. patchbay will not turn that into advice to +rotate a key. + The verdicts are deliberately more than a boolean. `unreachable` (DNS, timeout, -rate limit, 5xx) means patchbay could not ask; it says **nothing** about the -key, and it never overwrites what you already had. Exit codes follow: `0` -verified or unsupported, `1` the provider says the key is dead, `2` the provider -could not be reached. +rate limit, 5xx), `inconclusive` and `unsupported` all mean patchbay could not +get an answer; none of them says **anything** about the key, and none of them +overwrites what you already had. + +Exit codes keep the same three-way split, over however many keys were asked +about: `1` a provider says one of them is dead, `2` nothing is dead but a +provider could not be reached, `0` everything else — `inconclusive` and +`unsupported` included, because neither is a fact about a key. `1` outranks +`2`: a sweep that found a revoked token and then lost the wifi still found a +revoked token. Agents get the same check over MCP as `verify_key`, and it is **not** gated behind `PATCHBAY_ALLOW_SECRET_READ` — a verdict carries nothing to leak. diff --git a/docs/migration.md b/docs/migration.md index 45b5ce4..b25e5e5 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -12,6 +12,8 @@ with the exact command for every gap. ```sh # old machine pb export # -> patchbay-2026-08-13.pbx, encrypted + # + patchbay-2026-08-13-SETUP.md, in the clear: + # how to install pb on a machine with no pb # copy it across by AirDrop / USB / LAN — not by cloud sync @@ -52,6 +54,7 @@ into a chat with your agent: | encrypted | yes, passphrase required | no — there is nothing to encrypt | | safe to commit | **no** | yes | | refuses a cloud-sync folder | yes | no; that is where it belongs | +| readable without patchbay | only the `-SETUP.md` sidecar: install commands, nothing about the machine | the whole file; that is the point of it | One thing does travel verbatim that is worth knowing about: a key's `purpose` note is free text written by whoever registered it. patchbay never puts a secret @@ -85,6 +88,13 @@ One encrypted file, four parts: Parts 3 and 4 live *inside* the encrypted payload and are written out on import. +Part 4 is *also* written outside it, as a cleartext `-SETUP.md` beside +the file. Instructions for installing `pb` that need a `pb` to be read are not +instructions: on a real move the receiving Mac had neither, and the install +command was looked up on the releases page by hand. So the sidecar carries the +install step and the `pb import` line, and nothing else — no inventory, no tool +list, no key id, no variable name. Assume it will be pasted into a chat. + ## The project env vault [`pb env`](env-vault.md) rides along, as metadata. The bundle carries the @@ -172,7 +182,12 @@ reason next to it. A probe added without a policy fails the build's - **Encryption** is `age` with a passphrase (scrypt recipient) — no key files to manage or lose. Prompted twice on export, hidden, never taken as an argument: - argv is visible to `ps` and lands in your shell history. + argv is visible to `ps` and lands in your shell history. Automation names a + source instead of being blocked — `--passphrase-file `, which refuses a + file anybody else can read because with that flag the file *is* the secret, or + `--passphrase-fd `, the way gpg, restic and age do it. A documented path + matters here: the refusal these replace was worked around with a pty wrapper, + which echoed the passphrase into a log. - **The bundle is `0600`** and starts with a 19-byte cleartext header (`patchbay-bundle/1`) so a version skew is refused before you type a passphrase. Nothing else about it is readable. @@ -203,7 +218,25 @@ reason next to it. A probe added without a policy fails the build's `AWS_SHARED_CREDENTIALS_FILE` or a `[paths]` entry on the new machine decides where a file lands. - Several kubeconfigs land in one directory, with a note telling you the - `KUBECONFIG` line to set — kubectl only merges what the variable names. + `KUBECONFIG` line to set — kubectl only merges what the variable names. That + line does not live only in the import's output: `pb plan` re-derives it from + the directory, so the kubectl item carries the `export KUBECONFIG=…` itself + however long after the import you ask. +- **The keychain is asked before anything is written.** A bundle carrying key + values starts by storing and deleting one throwaway keychain item. If that is + refused — on macOS every write from a session with no desktop login is, so + ssh and cron are — the import stops with nothing written and says to re-run + from a Terminal in your own desktop session. The ordering is the whole point: + the files are restored before the secrets, so without the probe an import over + ssh copies every credential file, drops every secret and exits 0. `--dry-run` + cannot ask, because asking is a write, and says so instead of guessing. +- **An import that lost a secret exits 1**, like `pb plan`, so + `pb import x.pbx && ./something` does the obvious thing. Everything landed or + `unchanged` exits 0. +- **`pb import --keys-only`** restores the vault half alone — no files, + no MCP registrations, no env projects — which is how the move is finished once + the session is fixed. The file half is idempotent anyway, so a plain re-run is + just as safe. ## The AI-guided half