дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-13 21:25:47 +02:00
Порівняти коміти
320
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
953b51b8c6 | ||
|
|
70b953c77a | ||
|
|
cb18b88531 | ||
|
|
401d7a4105 | ||
|
|
46bd1f6fb8 | ||
|
|
85f755591b | ||
|
|
06b7513210 | ||
|
|
09aa03bb86 | ||
|
|
317988cfa7 | ||
|
|
7db2e98269 | ||
|
|
fdc8bc2d4c | ||
|
|
497d4766d7 | ||
|
|
7e11d0729b | ||
|
|
2e78c42fac | ||
|
|
1d0449d430 | ||
|
|
73f7fa0016 | ||
|
|
f3dfb384c9 | ||
|
|
b53cfe8cc2 | ||
|
|
7d146ef1b7 | ||
|
|
4bde38ef9d | ||
|
|
c6212293e1 | ||
|
|
b8ba1256d2 | ||
|
|
6fc6bf598c | ||
|
|
95235220b5 | ||
|
|
620fa247ef | ||
|
|
52e5a0c4af | ||
|
|
4acb265b90 | ||
|
|
b284c37588 | ||
|
|
771575057e | ||
|
|
563d2a75d4 | ||
|
|
3a64bba254 | ||
|
|
f4b4a0fa72 | ||
|
|
50bd134b65 | ||
|
|
8840a1e58a | ||
|
|
bf36cc0801 | ||
|
|
78c98d289f | ||
|
|
30b0fcb8fc | ||
|
|
586399deda | ||
|
|
e6e89860e6 | ||
|
|
d890c41352 | ||
|
|
c942b87abb | ||
|
|
cdecb5916c | ||
|
|
835c44615b | ||
|
|
df79c80aa8 | ||
|
|
73df48db7c | ||
|
|
6143011880 | ||
|
|
d1872c07ce | ||
|
|
cd79293fd7 | ||
|
|
86f7b747a5 | ||
|
|
2e5e9acd9f | ||
|
|
b5e9e27dad | ||
|
|
ff1bfeea9d | ||
|
|
14ee1a97f2 | ||
|
|
916c4243bf | ||
|
|
9c63f174e4 | ||
|
|
b463252bc5 | ||
|
|
2abdac4fdb | ||
|
|
721f4144c1 | ||
|
|
55057b8958 | ||
|
|
8abc8e7fbf | ||
|
|
68dd949df3 | ||
|
|
b98bddbbab | ||
|
|
21b05ab73a | ||
|
|
7d7de0b966 | ||
|
|
830f4aa802 | ||
|
|
c905f1428e | ||
|
|
a8036ce6fa | ||
|
|
28829ad756 | ||
|
|
d6c562049f | ||
|
|
100075166c | ||
|
|
d5332a2197 | ||
|
|
b0d20d7997 | ||
|
|
8df59cf5a7 | ||
|
|
3c68d2ea7f | ||
|
|
cfe1416dbd | ||
|
|
44fc0a2ebc | ||
|
|
e52cb2e426 | ||
|
|
519e90da9f | ||
|
|
9e73877f5e | ||
|
|
94a8761cc9 | ||
|
|
e66513668a | ||
|
|
565bdf170f | ||
|
|
5712a5373d | ||
|
|
2a981dd642 | ||
|
|
3580cbacd7 | ||
|
|
f91b87d790 | ||
|
|
74abf32704 | ||
|
|
4b1370742f | ||
|
|
6700f57f79 | ||
|
|
578219de3b | ||
|
|
afcdf57eb6 | ||
|
|
f145ea5d7b | ||
|
|
0c00f474e7 | ||
|
|
5cf01f682f | ||
|
|
fec4eb34b9 | ||
|
|
6454dbe169 | ||
|
|
8bdf623a28 | ||
|
|
bff6a12e2f | ||
|
|
ee1f15eba7 | ||
|
|
7ea4f36b6d | ||
|
|
ddfdbf1be6 | ||
|
|
da412f14be | ||
|
|
104986ead4 | ||
|
|
fdeaa31b7d | ||
|
|
4d5c640451 | ||
|
|
3e40295668 | ||
|
|
b43db63ff3 | ||
|
|
4dbb6920c8 | ||
|
|
10603b953b | ||
|
|
59a0a86257 | ||
|
|
f05066b18f | ||
|
|
4c15fe5a23 | ||
|
|
8f6a4b5b39 | ||
|
|
4f9dc0a56c | ||
|
|
67afcc9805 | ||
|
|
632eb9cbf7 | ||
|
|
88af3cb1eb | ||
|
|
59eb6446d4 | ||
|
|
51c14d3b03 | ||
|
|
c727c5b842 | ||
|
|
b296ac1ab7 | ||
|
|
8cce7ce390 | ||
|
|
1e41fa86ff | ||
|
|
65e0feb98f | ||
|
|
76faa39bc5 | ||
|
|
7f9bf3727f | ||
|
|
405343b7b7 | ||
|
|
cc29842e6b | ||
|
|
de0f738bb2 | ||
|
|
d781df542a | ||
|
|
abfc4cbade | ||
|
|
646e5f8364 | ||
|
|
a3b48b78fd | ||
|
|
5e4fa861bb | ||
|
|
068ce8e721 | ||
|
|
e5cc1ed712 | ||
|
|
abe6285629 | ||
|
|
66a829c24c | ||
|
|
04f3f9434f | ||
|
|
4330034403 | ||
|
|
de8afe31d2 | ||
|
|
579e5eee9b | ||
|
|
099ede3478 | ||
|
|
fb269afdfa | ||
|
|
435b00d893 | ||
|
|
14fe542617 | ||
|
|
513762147a | ||
|
|
489f55f52c | ||
|
|
55fcbf281a | ||
|
|
f8aa003b47 | ||
|
|
e27b7f2502 | ||
|
|
bc00302ef8 | ||
|
|
2e044c4a2b | ||
|
|
b1959e8fb5 | ||
|
|
ce67c7e129 | ||
|
|
3cc94e340b | ||
|
|
51793e02c5 | ||
|
|
1e0ce64b0c | ||
|
|
6e7b39023c | ||
|
|
c5d319c298 | ||
|
|
0780171704 | ||
|
|
0324a81ce4 | ||
|
|
7c51662e9e | ||
|
|
6b91fb396a | ||
|
|
4792647d06 | ||
|
|
0262812d9b | ||
|
|
33957ed34f | ||
|
|
de9c8db74e | ||
|
|
e1f41093ec | ||
|
|
53d5d66520 | ||
|
|
0eb68afd40 | ||
|
|
de2a631c17 | ||
|
|
59755adec4 | ||
|
|
5c7de8b6d7 | ||
|
|
ab41d9aa71 | ||
|
|
4ecd04af79 | ||
|
|
d09156ca8a | ||
|
|
1946dbd87c | ||
|
|
10d53e9956 | ||
|
|
780052d204 | ||
|
|
80a693c1bf | ||
|
|
7097fb46dd | ||
|
|
39d798e8c1 | ||
|
|
30a0b1d9fd | ||
|
|
41f54ceba9 | ||
|
|
d90b2b7bbe | ||
|
|
b2d506a929 | ||
|
|
81e3717d66 | ||
|
|
018b12f24f | ||
|
|
76b5d63c95 | ||
|
|
6fc6042a78 | ||
|
|
d01dc30f4f | ||
|
|
b8bad81497 | ||
|
|
63bb5d4976 | ||
|
|
49a2e084a8 | ||
|
|
42a65aba23 | ||
|
|
a812a03405 | ||
|
|
17204bbdca | ||
|
|
feef7db39d | ||
|
|
c54253fba2 | ||
|
|
ac54a064ef | ||
|
|
04dd202fab | ||
|
|
43b5c69ecd | ||
|
|
597d94bb42 | ||
|
|
ffbd80e857 | ||
|
|
f4ff2feefa | ||
|
|
a7af19d6b9 | ||
|
|
a2dd7e3939 | ||
|
|
3ed5157e03 | ||
|
|
68a9bf37e3 | ||
|
|
96711ff85a | ||
|
|
29bbaf3b78 | ||
|
|
b30657ec50 | ||
|
|
fd5f4b8639 | ||
|
|
0567bb88bf | ||
|
|
a7e968e869 | ||
|
|
e9a464cfef | ||
|
|
c23696c51f | ||
|
|
3e6e9ed255 | ||
|
|
cbc6b24c5f | ||
|
|
bb4f9ed180 | ||
|
|
f82808a82f | ||
|
|
1899483751 | ||
|
|
6d9bd6b43b | ||
|
|
2d4b25fc34 | ||
|
|
4c5df98bb8 | ||
|
|
46d288b456 | ||
|
|
7c5ef7a97b | ||
|
|
ae3973f11a | ||
|
|
e5efcfbf60 | ||
|
|
6f62917730 | ||
|
|
1b0fe7dcc6 | ||
|
|
7d1b54fae3 | ||
|
|
cb010fac2f | ||
|
|
684f52b3dd | ||
|
|
e8bc577e9d | ||
|
|
b1348ee03a | ||
|
|
e99d05d7a8 | ||
|
|
0e4cf1059a | ||
|
|
b4bcd8cce7 | ||
|
|
cb5e7081fa | ||
|
|
034b2b9f65 | ||
|
|
7ea3c59d05 | ||
|
|
6717b0948a | ||
|
|
8302cd1d6f | ||
|
|
c05670f4fd | ||
|
|
2f9658c367 | ||
|
|
40aa7e4899 | ||
|
|
fba1f02375 | ||
|
|
053ca43368 | ||
|
|
8fedc2efba | ||
|
|
2f2fa79cbe | ||
|
|
65d49619dd | ||
|
|
7f28161869 | ||
|
|
fc1493e1a4 | ||
|
|
efda6834ad | ||
|
|
e92e867019 | ||
|
|
8d8c7a481b | ||
|
|
0b3ca2fc9a | ||
|
|
2414773d61 | ||
|
|
e4181c36ea | ||
|
|
5063f6756e | ||
|
|
1a2f5308a0 | ||
|
|
cd04593d04 | ||
|
|
38241e7fae | ||
|
|
2e6d6a05b9 | ||
|
|
c73d858895 | ||
|
|
6e5486586c | ||
|
|
93642b3741 | ||
|
|
b06f2e27ea | ||
|
|
86f69b7fca | ||
|
|
fefa135f5a | ||
|
|
8fe2d35d62 | ||
|
|
21046c3047 | ||
|
|
017561a20b | ||
|
|
d3a68b2754 | ||
|
|
e98a560523 | ||
|
|
6983e8ec2f | ||
|
|
dc355602a9 | ||
|
|
924fa9259d | ||
|
|
24deeb0dbb | ||
|
|
0ce7f6e430 | ||
|
|
84fd0d0a60 | ||
|
|
96c99e0396 | ||
|
|
fd4838fc36 | ||
|
|
4873905884 | ||
|
|
6b37788971 | ||
|
|
44bf648bee | ||
|
|
a0c3249368 | ||
|
|
757412cb73 | ||
|
|
a497178b3e | ||
|
|
8b179992e8 | ||
|
|
85541b3663 | ||
|
|
0418a2ce01 | ||
|
|
c8639b38ba | ||
|
|
f04658612f | ||
|
|
d5cb379cae | ||
|
|
e3a818b607 | ||
|
|
0b1872937a | ||
|
|
f11cd361a7 | ||
|
|
7034fe1640 | ||
|
|
fc264d3f5c | ||
|
|
8e90341b3a | ||
|
|
3644956755 | ||
|
|
90e7e3162e | ||
|
|
833ae4c3c4 | ||
|
|
584baa3f71 | ||
|
|
98010e6513 | ||
|
|
a3640f655f | ||
|
|
8aaa7daf83 | ||
|
|
6a9a970df4 | ||
|
|
a309b7e6fa | ||
|
|
6971936f78 | ||
|
|
9d781a2f83 | ||
|
|
3260e5e224 | ||
|
|
ed575a00d9 | ||
|
|
2b41e61ce9 | ||
|
|
908740b3f2 | ||
|
|
e702e4da7b | ||
|
|
91023de207 |
No files matched your search
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: eval
|
||||
description: Run the code-intelligence evals (`pnpm eval`), pick the worst failing scenario, diagnose it from the attached timeline, fix it, prove the fix with before / after numbers measured on this machine without regressing other scenarios, and open one PR. Use for "跑 eval", "优化 Java 代码智能", "eval 失败了", an issue labelled `eval`, or any change to Java navigation / usages / gutter markers that needs numbers.
|
||||
---
|
||||
|
||||
# Eval loop: measure → fix → prove
|
||||
|
||||
`pnpm eval` drives the production workbench in Chromium against a release `openidea-server`
|
||||
with real language servers (Java / jdtls for now) and judges every scenario on what the user
|
||||
sees: time to feedback, to the first result, to the final one, and the results themselves vs
|
||||
`evals/<language>/golden/`. One machine, before and after: numbers from different machines or
|
||||
from the nightly host don't judge a fix.
|
||||
|
||||
## 0. Preconditions
|
||||
|
||||
- `jdtls` on PATH and `JAVA_HOME` pointing at a JDK of the pinned `JAVA` version (projects
|
||||
compile against it, and the JDK scenarios' results are in its sources; jdtls itself runs on
|
||||
a newer installed JDK when it needs one). Pinned versions: `.github/tool-versions.env`. A
|
||||
mismatch prints a warning and the report records both: say so in the PR when it applies.
|
||||
- First run builds `cargo build --release -p ide-host --bin openidea-server` and the perf
|
||||
bundle; later iterations of a frontend-only change can use `--no-build` after a
|
||||
`pnpm exec vite build --mode perf --outDir target/eval/dist`, a backend-only one after the
|
||||
cargo build alone.
|
||||
- Run evals one at a time on this machine, with nothing heavy (builds, other evals) beside them.
|
||||
|
||||
## 1. Baseline on the base commit
|
||||
|
||||
```sh
|
||||
git switch --detach origin/main # or the fix branch's merge base
|
||||
pnpm eval --save-baseline # target/eval/baseline.json, this machine only
|
||||
```
|
||||
|
||||
Read `target/eval/report.json`. Each verdict has `status`, `failures` (`kind`: `error`,
|
||||
`timeout`, `degraded`, `correctness`, `budget`, `golden`), `regressions`, `repro` (the command
|
||||
that reruns it) and, for failures, `diagnostics`: every command sent to the server in the
|
||||
slowest run's window (`cmd`, `start`, `ms`, `ok`, `bytes`, `items`), background tasks, server
|
||||
status changes and the server's log tail.
|
||||
|
||||
## 2. Pick one problem
|
||||
|
||||
Worst first: wrong or missing results and silent degradation, then timeouts, then the
|
||||
feedback budget (100 ms), then the slowest `completeMs`. One problem per PR. Say which
|
||||
scenario and metric, with its value vs budget.
|
||||
|
||||
## 3. Diagnose from the timeline
|
||||
|
||||
- Commands in the window ordered by `start`: what ran, what waited on what, what's serial
|
||||
that could overlap, which request returned many `items`/`bytes`, which failed.
|
||||
- `feedbackMs` far above 16 ms with a fast `firstResultMs`: the UI shows nothing while it
|
||||
waits (a hint or popup that only appears after a delay).
|
||||
- A task still running across the window: background work competing with the request.
|
||||
- For the frontend side rerun with `--trace` and open `target/eval/traces/<id>@<cond>.json`
|
||||
in Chrome's Performance panel.
|
||||
- Name the cause at class level (as in `issue-fix`): it usually affects sibling scenarios.
|
||||
|
||||
## 4. Fix, then rerun that scenario until it passes
|
||||
|
||||
```sh
|
||||
pnpm eval --only <scenario> --condition <warm|cold> # the verdict's `repro`
|
||||
```
|
||||
|
||||
Correctness is enforced, so a faster answer that drops, truncates or serves stale results
|
||||
fails. Don't trade a budget miss for a degraded answer.
|
||||
|
||||
## 5. Full suite, then PR
|
||||
|
||||
```sh
|
||||
pnpm eval # vs the baseline from step 1: no new failures or regressions
|
||||
```
|
||||
|
||||
PR per `issue-fix` §6, plus in **How it was verified** the before / after table from
|
||||
`target/eval/report.md` (both on this machine, commits named) and the tool versions line.
|
||||
|
||||
## Guard rails
|
||||
|
||||
- Goldens (`evals/*/golden/`) and budgets (`evals/budgets.json`) are human-owned. Don't edit
|
||||
them, and don't run `--update-golden`, to make a scenario pass. When a golden looks wrong
|
||||
(a tool bump, a deliberate behaviour change), stop and say so: a PR that touches them gets
|
||||
`needs-decision`.
|
||||
- Don't weaken a scenario's checks or timeouts in `evals/<language>/scenarios.ts` to pass.
|
||||
Adding a scenario (for a case the sweep or a user found) is fine; record its golden with
|
||||
`--update-golden --only <id>` and ask a person to review the file.
|
||||
- A flaky timing (passes on rerun) is not a regression: rerun once before acting on one.
|
||||
Symlink
@@ -0,0 +1 @@
|
||||
../../.agents/skills/eval
|
||||
@@ -0,0 +1,2 @@
|
||||
custom:
|
||||
- https://github.com/l0ng-ai/openidea#sponsor
|
||||
Бінарний файл не відображається.
|
Після Ширина: | Висота: | Розмір: 70 KiB |
Бінарний файл не відображається.
|
Після Ширина: | Висота: | Розмір: 97 KiB |
@@ -0,0 +1,14 @@
|
||||
# Language server and tool versions for Language smoke (.github/workflows/language-smoke.yml)
|
||||
# and the code-intelligence evals (pnpm eval). Pinned so a release can't fail an unrelated
|
||||
# PR or move a golden result; bump them deliberately (and re-record goldens: pnpm eval --update-golden).
|
||||
VTSLS=0.3.0
|
||||
TYPESCRIPT=6.0.3
|
||||
BASEDPYRIGHT=1.40.2
|
||||
RUFF=0.17.3
|
||||
GOPLS=v0.24.0
|
||||
JDTLS=1.63.0-202610191322
|
||||
KOTLIN_LSP=263.7084.0
|
||||
GRADLE=8.14.3
|
||||
INTELEPHENSE=1.18.5
|
||||
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
|
||||
JAVA=21
|
||||
@@ -24,6 +24,7 @@ on:
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/language-smoke.yml"
|
||||
- ".github/tool-versions.env"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
@@ -38,16 +39,6 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# Pinned so a server release can't fail an unrelated PR; bump them deliberately.
|
||||
VTSLS: 0.3.0
|
||||
TYPESCRIPT: 6.0.3
|
||||
BASEDPYRIGHT: 1.40.2
|
||||
RUFF: 0.17.3
|
||||
GOPLS: v0.24.0
|
||||
JDTLS: 1.63.0-202610191322
|
||||
KOTLIN_LSP: 263.7084.0
|
||||
GRADLE: 8.14.3
|
||||
INTELEPHENSE: 1.18.5
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
@@ -61,6 +52,8 @@ jobs:
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Tool versions, shared with the evals (pnpm eval).
|
||||
- run: grep -E '^[A-Z0-9_]+=' .github/tool-versions.env >> "$GITHUB_ENV"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "rust-analyzer, rustfmt" }
|
||||
# One build cache for every language; main saves it.
|
||||
@@ -96,7 +89,7 @@ jobs:
|
||||
|
||||
- if: matrix.language == 'java' || matrix.language == 'kotlin'
|
||||
uses: actions/setup-java@v4
|
||||
with: { distribution: temurin, java-version: "21" }
|
||||
with: { distribution: temurin, java-version: "${{ env.JAVA }}" }
|
||||
- if: matrix.language == 'java'
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/jdtls"
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
# No `nightly` tag yet (the first run): compare fails and the build goes on.
|
||||
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
|
||||
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null)" || status=ahead
|
||||
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
|
||||
echo "::notice::$sha is not newer than the last nightly"
|
||||
exit 0
|
||||
|
||||
@@ -151,6 +151,13 @@ jobs:
|
||||
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
|
||||
gh release upload $env:TAG $asset "$asset.sha256" --clobber
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if ($env:TAURI_SIGNING_PRIVATE_KEY) {
|
||||
pnpm exec tauri signer sign --app-version $version $asset
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
gh release upload $env:TAG "$asset.sig" --clobber
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
}
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
@@ -233,6 +240,8 @@ jobs:
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
|
||||
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
|
||||
[/_windows_arm64_portable\.zip$/, ["windows-aarch64-portable"]],
|
||||
[/_windows_x64_portable\.zip$/, ["windows-x86_64-portable"]],
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
|
||||
@@ -40,6 +40,7 @@ CLAUDE.local.md
|
||||
.claude/skills/*
|
||||
!.claude/skills/verify
|
||||
!.claude/skills/issue-fix
|
||||
!.claude/skills/eval
|
||||
|
||||
# Local reference screenshots
|
||||
pycharm-screenshots/
|
||||
@@ -9,9 +9,11 @@
|
||||
"dist",
|
||||
"target",
|
||||
"src/bindings.ts",
|
||||
"evals/*/golden",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"src/editor/core/cm/jsp/parser*.ts",
|
||||
"src/editor/core/cm/astro/parser*.ts",
|
||||
"src/editor/core/cm/django/parser*.ts",
|
||||
"plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
|
||||
+11
-3
@@ -24,8 +24,8 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
|
||||
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
|
||||
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
|
||||
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
|
||||
for manual and automated testing.
|
||||
- `e2e/` — Playwright UI tests (`pnpm e2e`). `evals/` — code-intelligence evals (`pnpm eval`).
|
||||
`fixtures/` — sample projects per language for manual and automated testing.
|
||||
- `docs/` — the Mintlify documentation site (`docs.json` navigation, `en/` and `zh/` pages);
|
||||
preview with `npx mint dev` in `docs/`.
|
||||
|
||||
@@ -59,9 +59,12 @@ Unclear product decisions → `needs-decision` and stop.
|
||||
|
||||
## Skills
|
||||
|
||||
Do not use Impeccable or its related skills in this repository, including globally installed copies.
|
||||
|
||||
Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
`verify` drives the running app through Tauri MCP and captures screenshot evidence;
|
||||
`issue-fix` takes an issue from reproduction to one PR.
|
||||
`issue-fix` takes an issue from reproduction to one PR;
|
||||
`eval` takes a failing code-intelligence eval to a fix with before / after numbers.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -81,6 +84,11 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
benches vs budgets). Budgets: `perf/budgets.json`.
|
||||
- `pnpm eval [--only <id>] [--runs n] [--save-baseline]` — Java code intelligence (navigation,
|
||||
usages, gutter markers) in the production workbench against a release `openidea-server` and
|
||||
jdtls: time to feedback / first result / complete, results vs `evals/<language>/golden/`.
|
||||
Report: `target/eval/report.json`. Goldens and `evals/budgets.json` are changed by people only.
|
||||
Tool versions: `.github/tool-versions.env` (shared with Language smoke).
|
||||
- `pnpm release:prepare <version>` — the release commit's changes: app version everywhere,
|
||||
`Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version.
|
||||
|
||||
|
||||
Створено
+147
-56
@@ -2637,7 +2637,6 @@ version = "1.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"rustversion",
|
||||
"typenum",
|
||||
]
|
||||
@@ -4145,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4169,7 +4168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4183,7 +4182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4194,7 +4193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chardetng",
|
||||
@@ -4212,7 +4211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
@@ -4226,7 +4225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"objc2 0.6.5",
|
||||
@@ -4236,7 +4235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4253,7 +4252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4267,7 +4266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4293,19 +4292,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bb8",
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"flate2",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"notify",
|
||||
"open",
|
||||
"oracledb",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rsa",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4319,11 +4323,12 @@ dependencies = [
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4336,7 +4341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4357,7 +4362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4369,7 +4374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4382,7 +4387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-heap"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4400,7 +4405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4443,6 +4448,7 @@ dependencies = [
|
||||
"ide-tools",
|
||||
"ide-vcs",
|
||||
"ide-vfs",
|
||||
"ide-web",
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
@@ -4459,6 +4465,7 @@ dependencies = [
|
||||
"specta-typescript",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -4469,7 +4476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vfs",
|
||||
@@ -4488,7 +4495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4510,7 +4517,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jdwp"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-jvm-inspect",
|
||||
"proptest",
|
||||
@@ -4522,7 +4529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4539,7 +4546,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-attach"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"tempfile",
|
||||
@@ -4548,7 +4555,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-inspect"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
@@ -4559,7 +4566,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4600,6 +4607,7 @@ dependencies = [
|
||||
"tree-sitter-nix",
|
||||
"tree-sitter-ocaml",
|
||||
"tree-sitter-php",
|
||||
"tree-sitter-prisma-io",
|
||||
"tree-sitter-proto",
|
||||
"tree-sitter-python",
|
||||
"tree-sitter-ruby",
|
||||
@@ -4615,9 +4623,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4642,7 +4651,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-mcp"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dunce",
|
||||
@@ -4661,7 +4670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4684,7 +4693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4707,9 +4716,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jvm-attach",
|
||||
"ide-run",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4720,9 +4730,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4739,7 +4750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4763,7 +4774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-scaffold"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-tools",
|
||||
@@ -4779,7 +4790,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4794,12 +4805,13 @@ dependencies = [
|
||||
"tempfile",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"url",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"encoding_rs",
|
||||
@@ -4820,7 +4832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4833,7 +4845,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4843,18 +4855,22 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-sftp",
|
||||
"rustls",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"suppaftp",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4869,7 +4885,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4882,7 +4898,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4895,7 +4911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4916,7 +4932,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4937,7 +4953,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4961,11 +4977,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"ide-core",
|
||||
"open",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"specta",
|
||||
@@ -4977,7 +4993,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5546,6 +5562,29 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy-regex"
|
||||
version = "3.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0a2cce8a22920a29fdcc8c9170aa5d29edd9fbcf8bea856e24c2cb1c2419f67"
|
||||
dependencies = [
|
||||
"lazy-regex-proc_macros",
|
||||
"once_cell",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy-regex-proc_macros"
|
||||
version = "3.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f1e4b1f1d8c71edb4ff2b2150ec4606445fd2ddf67ddc097655daf59286baa6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
@@ -6555,7 +6594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6912,6 +6951,26 @@ dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -7660,9 +7719,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "russh"
|
||||
version = "0.64.1"
|
||||
version = "0.65.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58"
|
||||
checksum = "94be3d86c71b9ed220a675c405e35c7a02565a009bf8da2e9cc13a700320a5c5"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"bitflags 2.13.2",
|
||||
@@ -8668,7 +8727,7 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
@@ -8680,7 +8739,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -8715,7 +8774,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -8727,7 +8786,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
@@ -8751,7 +8810,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
@@ -8776,7 +8835,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-postgres"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
@@ -8810,7 +8869,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"flume",
|
||||
@@ -8960,6 +9019,23 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "suppaftp"
|
||||
version = "12.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b58fcf921745cca6f0127c9f822069947da8c6dbeff52e9004750bcc448171f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"lazy-regex",
|
||||
"log",
|
||||
"pin-project",
|
||||
"rustls-pki-types",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.9"
|
||||
@@ -9568,7 +9644,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -9800,7 +9876,10 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
@@ -10366,6 +10445,16 @@ dependencies = [
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-prisma-io"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c35912d65a6adb07f781db46d843bf0464935e267da6ed192705516adafc7c24"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-proto"
|
||||
version = "0.6.0"
|
||||
@@ -10494,6 +10583,8 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1 0.10.7",
|
||||
"thiserror 2.0.21",
|
||||
"utf-8",
|
||||
|
||||
+3
-2
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.41"
|
||||
version = "0.1.44"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -68,6 +68,7 @@ parking_lot = "0.12"
|
||||
rayon = "1"
|
||||
dashmap = "6"
|
||||
futures = "0.3"
|
||||
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "__rustls-tls"] }
|
||||
|
||||
# errors & diagnostics
|
||||
thiserror = "2"
|
||||
@@ -110,7 +111,7 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-platform-verifier = "0.7"
|
||||
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
|
||||
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
sqlx = { version = "0.9", git = "https://github.com/l0ng-ai/sqlx", rev = "9894985a1babc7e6bc0437da4fd43e3143184073", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
|
||||
oracledb = "=26.0.0-beta.5"
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="#community">Community</a>
|
||||
·
|
||||
<a href="#sponsor">Sponsor</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
@@ -68,6 +70,17 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
|
||||
|
||||
## Sponsor
|
||||
|
||||
If OpenIDEA saves you time, you can support its development with Alipay or WeChat Pay.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="Alipay QR code" width="200"><br>Alipay</td>
|
||||
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="WeChat Pay QR code" width="200"><br>WeChat Pay</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! A short-lived ACP session to learn models, effort levels and other config options
|
||||
//! before the user's first task (Cursor and other ACP agents do not expose these in
|
||||
//! `initialize` alone).
|
||||
|
||||
use super::{Conn, Dialect};
|
||||
use crate::config;
|
||||
use crate::model::AgentConfigOption;
|
||||
use crate::provider::ProviderSpec;
|
||||
use crate::{Shared, client_info};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else { Duration::from_secs(120) };
|
||||
|
||||
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
|
||||
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<(), String> {
|
||||
let root = shared.paths.project_root.clone();
|
||||
let launch = spec.clone();
|
||||
let launch = tokio::task::spawn_blocking({
|
||||
let root = root.clone();
|
||||
move || launch.resolve_for_launch(&root)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let cwd = dunce::canonicalize(&root).unwrap_or(root);
|
||||
let stderr: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let lines = stderr.clone();
|
||||
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(move |l| lines.lock().push(l));
|
||||
let client = Dialect::wrap(Arc::new(ide_jsonrpc::NoopHandler));
|
||||
let peer = (shared.connector)(&launch, &cwd, client.clone(), on_stderr)
|
||||
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", launch.name, launch.command))?;
|
||||
let conn = Conn::new(peer.clone(), client);
|
||||
let work = async {
|
||||
conn.initialize(&client_info()).await.map_err(|e| e.to_string())?;
|
||||
let cwd_s = cwd.to_string_lossy().into_owned();
|
||||
// No project MCP during discovery: Cursor can spend a long time spawning servers.
|
||||
let new: Value = conn
|
||||
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut options = config::options_of(&new);
|
||||
config::add_client_options(&launch.id, &mut options);
|
||||
let model_option = options.iter().find(|o| o.category.as_deref() == Some("model"));
|
||||
let effort = levels_for_model_from_slice(&options);
|
||||
let levels: Vec<(String, Vec<AgentConfigOption>)> = match (model_option, effort) {
|
||||
(Some(model), Some(effort)) if !model.values.is_empty() => {
|
||||
model.values.iter().map(|v| (v.value.clone(), effort.clone())).collect()
|
||||
}
|
||||
(Some(model), Some(effort)) => model.current.clone().map(|m| vec![(m, effort)]).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let option_ids: Vec<_> = options.iter().map(|o| o.id.as_str()).collect();
|
||||
tracing::info!(
|
||||
"agent {}: settings discovery got {} config option(s) {:?}, {} model effort list(s)",
|
||||
launch.id,
|
||||
options.len(),
|
||||
option_ids,
|
||||
levels.len()
|
||||
);
|
||||
shared.remember_discovered(&launch.id, &options, &[], &levels);
|
||||
Ok::<(), String>(())
|
||||
};
|
||||
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
|
||||
peer.kill();
|
||||
match result {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(e)) => Err(match stderr.lock().last() {
|
||||
Some(line) => format!("{e}: {line}"),
|
||||
None => e,
|
||||
}),
|
||||
Err(_) => {
|
||||
let tail = stderr.lock().join("\n");
|
||||
if tail.is_empty() {
|
||||
Err("the agent did not answer in time".into())
|
||||
} else {
|
||||
Err(format!("the agent did not answer in time:\n{tail}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_thought_level(o: &AgentConfigOption) -> bool {
|
||||
o.category.as_deref() == Some("thought_level")
|
||||
|| matches!(o.id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
|
||||
}
|
||||
|
||||
fn levels_for_model_from_slice(options: &[AgentConfigOption]) -> Option<Vec<AgentConfigOption>> {
|
||||
let levels: Vec<_> = options
|
||||
.iter()
|
||||
.filter(|o| is_thought_level(o))
|
||||
.cloned()
|
||||
.map(|mut o| {
|
||||
if o.category.as_deref() != Some("thought_level") {
|
||||
o.category = Some("thought_level".into());
|
||||
}
|
||||
o
|
||||
})
|
||||
.collect();
|
||||
(!levels.is_empty()).then_some(levels)
|
||||
}
|
||||
@@ -107,6 +107,7 @@ fn manager(dir: &Path, agent: Arc<FakeV2Agent>) -> AgentManager {
|
||||
models_file: dir.join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//! - Agent → client ([`Dialect`]): `session/update`s are normalized ([`normalize`]) and
|
||||
//! permission requests get the v1 `toolCall`.
|
||||
|
||||
pub(crate) mod discover;
|
||||
mod normalize;
|
||||
|
||||
use ide_jsonrpc::{Handler, Peer, RpcError, codes};
|
||||
@@ -57,6 +58,9 @@ fn client_capabilities() -> Value {
|
||||
"terminal": false,
|
||||
// Subagents as sessions of their own (ACP subagents RFD; v2 baseline).
|
||||
"subagents": {},
|
||||
// Cursor exposes model parameters (e.g. Composer `fast`) as separate config options
|
||||
// only when the client advertises this flag (undocumented Cursor ACP extension).
|
||||
"_meta": { "parameterizedModelPicker": true },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -93,9 +93,82 @@ fn the_negotiating_request_carries_both_versions() {
|
||||
assert!(p["capabilities"].is_object());
|
||||
assert_eq!(p["clientCapabilities"]["subagents"], json!({}));
|
||||
assert_eq!(p["clientCapabilities"]["terminal"], false);
|
||||
assert_eq!(
|
||||
p["clientCapabilities"]["_meta"]["parameterizedModelPicker"], true,
|
||||
"Cursor and similar agents need parameterized model options (e.g. fast on/off)"
|
||||
);
|
||||
// What we sent in the capture is what claude-agent-acp negotiated v2 and v1 from.
|
||||
let sent = &frames(TURN, "out")[0]["params"];
|
||||
assert_eq!(sent["clientCapabilities"], p["clientCapabilities"]);
|
||||
let sent = frames(TURN, "out")[0]["params"]["clientCapabilities"].clone();
|
||||
let mut expected = sent;
|
||||
expected["_meta"] = p["clientCapabilities"]["_meta"].clone();
|
||||
assert_eq!(expected, p["clientCapabilities"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent() {
|
||||
#[derive(Default)]
|
||||
struct DiscoveryAgent {
|
||||
peer: std::sync::OnceLock<Peer>,
|
||||
calls: parking_lot::Mutex<Vec<(String, Value)>>,
|
||||
}
|
||||
impl Handler for DiscoveryAgent {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
self.calls.lock().push((method.to_owned(), params));
|
||||
match method {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": 1,
|
||||
"agentCapabilities": { "loadSession": true }
|
||||
})),
|
||||
"session/new" => Ok(json!({
|
||||
"sessionId": "discover",
|
||||
"configOptions": [
|
||||
{"id": "model", "category": "model", "type": "select", "currentValue": "m1",
|
||||
"options": [{"value": "m1", "name": "One"}, {"value": "m2", "name": "Two"}]},
|
||||
{"id": "effort", "type": "select", "currentValue": "medium",
|
||||
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]},
|
||||
{"id": "fast", "type": "select", "currentValue": "false",
|
||||
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
|
||||
]
|
||||
})),
|
||||
_ => Ok(json!({})),
|
||||
}
|
||||
}
|
||||
}
|
||||
let agent = Arc::new(DiscoveryAgent::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent_for_connector = agent.clone();
|
||||
let connector: crate::Connector = Arc::new(move |_, _, client, _| {
|
||||
let (ca, cb) = tokio::io::duplex(1 << 20);
|
||||
let (car, caw) = tokio::io::split(ca);
|
||||
let (cbr, cbw) = tokio::io::split(cb);
|
||||
let agent_peer = Peer::connect_framed(cbr, cbw, agent_for_connector.clone(), Framing::Lines);
|
||||
let _ = agent_for_connector.peer.set(agent_peer);
|
||||
Ok(Peer::connect_framed(car, caw, client, Framing::Lines))
|
||||
});
|
||||
let mgr = crate::AgentManager::with_connector(
|
||||
crate::AgentPaths {
|
||||
project_root: tmp.path().to_path_buf(),
|
||||
store_dir: tmp.path().join("store"),
|
||||
worktrees_dir: tmp.path().join("worktrees"),
|
||||
models_file: tmp.path().join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
);
|
||||
mgr.commands("cursor", None).await.unwrap();
|
||||
let calls = agent.calls.lock();
|
||||
assert_eq!(calls.iter().map(|(m, _)| m.as_str()).collect::<Vec<_>>(), ["initialize", "session/new"]);
|
||||
assert_eq!(calls[1].1["mcpServers"], json!([]));
|
||||
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
|
||||
let fast = cursor.config.iter().find(|o| o.id == "fast").expect("fast option");
|
||||
assert_eq!(fast.category.as_deref(), Some("model_config"));
|
||||
assert_eq!(fast.current.as_deref(), Some("false"));
|
||||
assert!(cursor.thought_levels.contains_key("m1") && cursor.thought_levels.contains_key("m2"));
|
||||
assert!(cursor.thought_levels["m1"].iter().any(|o| o.category.as_deref() == Some("thought_level")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -42,9 +42,38 @@ pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool {
|
||||
let selected = |category: &str| {
|
||||
config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref())
|
||||
};
|
||||
selected("mode").is_some_and(is_bypass_mode)
|
||||
|| selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true"))
|
||||
|| selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass"))
|
||||
["mode", "permissions", "approval"]
|
||||
.into_iter()
|
||||
.any(|category| selected(category).zip(bypass_value(category)).is_some_and(|(value, bypass)| bypass(value)))
|
||||
}
|
||||
|
||||
/// For an option of `category` that can approve everything, what tells its approving values.
|
||||
fn bypass_value(category: &str) -> Option<fn(&str) -> bool> {
|
||||
match category {
|
||||
"mode" => Some(is_bypass_mode),
|
||||
"permissions" => Some(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true")),
|
||||
"approval" => Some(|v| v.eq_ignore_ascii_case("bypass")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves options that approve everything without asking to a value that asks: the agent's
|
||||
/// default or ask mode, else its first one that asks.
|
||||
pub(crate) fn ask_for_approvals(config: &mut [AgentConfigOption]) {
|
||||
for option in config {
|
||||
let Some(bypass) = option.category.as_deref().and_then(bypass_value) else { continue };
|
||||
if option.current.as_deref().is_some_and(|c| !bypass(c)) {
|
||||
continue;
|
||||
}
|
||||
let asks = |v: &&AgentConfigValue| !bypass(&v.value);
|
||||
let value = ["default", "ask"]
|
||||
.iter()
|
||||
.find_map(|preferred| option.values.iter().filter(asks).find(|v| v.value == *preferred))
|
||||
.or_else(|| option.values.iter().find(asks));
|
||||
if let Some(value) = value {
|
||||
option.current = Some(value.value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
|
||||
@@ -107,7 +136,15 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
|
||||
Some(AgentConfigOption {
|
||||
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
|
||||
description: str_of(o, "description"),
|
||||
category: str_of(o, "category").or_else(|| matches!(id.as_str(), "model" | "mode").then(|| id.clone())),
|
||||
category: str_of(o, "category").or_else(|| {
|
||||
matches!(id.as_str(), "model" | "mode")
|
||||
.then(|| id.clone())
|
||||
.or_else(|| {
|
||||
matches!(id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
|
||||
.then(|| "thought_level".into())
|
||||
})
|
||||
.or_else(|| matches!(id.as_str(), "fast").then(|| "model_config".into()))
|
||||
}),
|
||||
current: str_of(o, "currentValue"),
|
||||
values,
|
||||
via: AgentConfigVia::Option,
|
||||
@@ -333,6 +370,51 @@ mod tests {
|
||||
assert!(bypass_approvals(&url_mode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asking_for_approvals_keeps_a_mode_that_asks_and_leaves_one_that_does_not() {
|
||||
let mode = |current: &str, values: &[&str]| {
|
||||
options_of(&json!({"modes": {"currentModeId": current,
|
||||
"availableModes": values.iter().map(|v| json!({"id": v})).collect::<Vec<_>>()}}))
|
||||
};
|
||||
let codex = ["read-only", "ask", "auto", "full-access"];
|
||||
for (current, expected) in [("auto", "auto"), ("read-only", "read-only"), ("full-access", "ask")] {
|
||||
let mut c = mode(current, &codex);
|
||||
ask_for_approvals(&mut c);
|
||||
assert_eq!(c[0].current.as_deref(), Some(expected), "{current}");
|
||||
assert!(!bypass_approvals(&c));
|
||||
}
|
||||
let mut claude = mode("bypassPermissions", &["acceptEdits", "default", "plan", "bypassPermissions"]);
|
||||
ask_for_approvals(&mut claude);
|
||||
assert_eq!(claude[0].current.as_deref(), Some("default"));
|
||||
let mut other = mode("yolo", &["yolo", "code", "architect"]);
|
||||
ask_for_approvals(&mut other);
|
||||
assert_eq!(other[0].current.as_deref(), Some("code"));
|
||||
|
||||
let mut c = options_of(&json!({"configOptions": [
|
||||
{"id": "allow_all", "category": "permissions", "type": "select", "currentValue": "on",
|
||||
"options": [{"value": "on"}, {"value": "off"}]},
|
||||
{"id": "approval", "category": "approval", "type": "select", "currentValue": "bypass",
|
||||
"options": [{"value": "bypass"}, {"value": "ask"}]}
|
||||
]}));
|
||||
ask_for_approvals(&mut c);
|
||||
assert_eq!(ids(&c), [("allow_all", Some("off")), ("approval", Some("ask"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_and_fast_categories_are_inferred_from_ids() {
|
||||
let c = options_of(&json!({
|
||||
"configOptions": [
|
||||
{"id": "effort", "type": "select", "currentValue": "high",
|
||||
"options": [{"value": "low"}, {"value": "high"}]},
|
||||
{"id": "fast", "type": "select", "currentValue": "false",
|
||||
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
|
||||
]
|
||||
}));
|
||||
assert_eq!(c.len(), 2);
|
||||
assert_eq!(c[0].category.as_deref(), Some("thought_level"));
|
||||
assert_eq!(c[1].category.as_deref(), Some("model_config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sources_in_updates_and_stored_tasks() {
|
||||
let update = json!({"availableCommands": [
|
||||
|
||||
@@ -16,6 +16,7 @@ mod model;
|
||||
mod native;
|
||||
mod provider;
|
||||
pub mod registry;
|
||||
pub mod security;
|
||||
mod skills;
|
||||
mod timeline;
|
||||
mod worktree;
|
||||
@@ -39,6 +40,12 @@ use std::time::Duration;
|
||||
/// Receives the events of one project's tasks.
|
||||
pub type AgentSink = Arc<dyn Fn(AgentEvent) + Send + Sync>;
|
||||
|
||||
/// Returns the active command execution security policy for the project.
|
||||
pub type SecurityHook = Arc<dyn Fn() -> ide_core::settings::AgentSecuritySettings + Send + Sync>;
|
||||
|
||||
/// Returns the user's choice of the approvals new tasks start with.
|
||||
pub type NewTaskApprovalsHook = Arc<dyn Fn() -> ide_core::settings::NewTaskApprovals + Send + Sync>;
|
||||
|
||||
/// Entry changes are batched for this long before they are sent.
|
||||
const FLUSH_DELAY: Duration = Duration::from_millis(40);
|
||||
/// Updates that arrive this late after `session/load` returns are still replayed history.
|
||||
@@ -85,6 +92,18 @@ pub struct AgentPaths {
|
||||
pub custom_agents_dir: Option<PathBuf>,
|
||||
/// The user's home: its `.claude` holds their own skills and commands.
|
||||
pub home_dir: Option<PathBuf>,
|
||||
/// Where the values of the agent files' environment variables are kept; without it they stay in the files.
|
||||
pub env_secrets: Option<Arc<dyn EnvSecrets>>,
|
||||
}
|
||||
|
||||
/// Keeps the values of agent files' environment variables (`agent/<id>/env/<name>`) out of
|
||||
/// the files, which keep only the names; the app keeps them in the OS credential store.
|
||||
pub trait EnvSecrets: Send + Sync {
|
||||
fn get(&self, key: &str) -> Option<String>;
|
||||
fn set(&self, key: &str, value: &str) -> IdeResult<()>;
|
||||
fn delete(&self, key: &str);
|
||||
/// The keys that start with `prefix`.
|
||||
fn keys(&self, prefix: &str) -> Vec<String>;
|
||||
}
|
||||
|
||||
/// Starts the ACP server of a provider in a directory, speaking to `client`.
|
||||
@@ -102,6 +121,8 @@ struct Shared {
|
||||
runtime: tokio::runtime::Handle,
|
||||
connector: Connector,
|
||||
sink: RwLock<Option<AgentSink>>,
|
||||
security: RwLock<Option<SecurityHook>>,
|
||||
new_task_approvals: RwLock<Option<NewTaskApprovalsHook>>,
|
||||
known: Mutex<HashMap<String, Known>>,
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
@@ -131,6 +152,10 @@ impl Shared {
|
||||
self.providers.read().iter().find(|p| p.id == id).cloned()
|
||||
}
|
||||
|
||||
fn env_secrets(&self) -> Option<&dyn EnvSecrets> {
|
||||
self.paths.env_secrets.as_deref()
|
||||
}
|
||||
|
||||
fn emit(&self, event: AgentEvent) {
|
||||
let sink = self.sink.read().clone();
|
||||
if let Some(sink) = sink {
|
||||
@@ -150,6 +175,11 @@ impl Shared {
|
||||
// Model-specific options are offered separately, never as provider-wide defaults.
|
||||
config.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
config::add_client_options(provider, &mut config);
|
||||
let hook = self.new_task_approvals.read().clone();
|
||||
if hook.is_some_and(|ask| ask() == ide_core::settings::NewTaskApprovals::Ask) {
|
||||
config::ask_for_approvals(&mut config);
|
||||
return config;
|
||||
}
|
||||
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
|
||||
&& let Some(o) = config.iter_mut().find(|c| c.id == o)
|
||||
{
|
||||
@@ -177,9 +207,15 @@ impl Shared {
|
||||
let shared = self.clone();
|
||||
// Keep the lookup alive if a composer unmounts while it is waiting.
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok(found) =
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
|
||||
{
|
||||
tracing::info!("agent {}: settings discovery started", spec.id);
|
||||
let discovered = if spec.driver == provider::Driver::Acp {
|
||||
acp::discover::run(&shared, &spec).await.map(|_| None)
|
||||
} else if spec.driver == provider::Driver::Claude {
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await.map(Some)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
if let Ok(Some(found)) = discovered {
|
||||
let commands =
|
||||
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": found.options }));
|
||||
@@ -198,6 +234,8 @@ impl Shared {
|
||||
shared.remember_discovered(&spec.id, &options, &commands, &levels);
|
||||
})
|
||||
.await;
|
||||
} else if let Err(e) = discovered {
|
||||
tracing::info!("agent {}: settings discovery failed: {e}", spec.id);
|
||||
}
|
||||
let _ = done.send(true);
|
||||
});
|
||||
@@ -220,12 +258,17 @@ impl Shared {
|
||||
fn knows_all_levels(&self, provider: &str) -> bool {
|
||||
let cache = self.known.lock();
|
||||
let Some(known) = cache.get(provider) else { return false };
|
||||
known
|
||||
let models: Vec<&str> = known
|
||||
.config
|
||||
.iter()
|
||||
.filter(|o| o.category.as_deref() == Some("model"))
|
||||
.flat_map(|o| &o.values)
|
||||
.all(|v| known.thought_levels.contains_key(&v.value))
|
||||
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
|
||||
.collect();
|
||||
// No advertised models yet: nothing to attach effort levels to.
|
||||
if models.is_empty() {
|
||||
return false;
|
||||
}
|
||||
models.iter().all(|id| known.thought_levels.contains_key(*id))
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
@@ -428,12 +471,14 @@ impl AgentManager {
|
||||
fn with_connector(paths: AgentPaths, runtime: tokio::runtime::Handle, connector: Connector) -> Self {
|
||||
let known =
|
||||
std::fs::read(&paths.models_file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref()));
|
||||
let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref(), paths.env_secrets.as_deref()));
|
||||
let shared = Arc::new(Shared {
|
||||
paths,
|
||||
runtime,
|
||||
connector,
|
||||
sink: RwLock::new(None),
|
||||
security: RwLock::new(None),
|
||||
new_task_approvals: RwLock::new(None),
|
||||
known: Mutex::new(known),
|
||||
providers,
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
@@ -477,9 +522,19 @@ impl AgentManager {
|
||||
tasks
|
||||
}
|
||||
|
||||
/// Sets the callback used to read the active command execution security policy.
|
||||
pub fn set_security_hook(&self, hook: SecurityHook) {
|
||||
*self.shared.security.write() = Some(hook);
|
||||
}
|
||||
|
||||
/// Sets the callback that reads which approvals new tasks start with.
|
||||
pub fn set_new_task_approvals_hook(&self, hook: NewTaskApprovalsHook) {
|
||||
*self.shared.new_task_approvals.write() = Some(hook);
|
||||
}
|
||||
|
||||
/// The agents (custom ones re-read), whether each is installed, and what they offered last time.
|
||||
pub fn providers(&self) -> Vec<AgentProvider> {
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets());
|
||||
let in_use: std::collections::HashSet<String> =
|
||||
self.tasks.read().values().map(|t| t.state.lock().task.provider.clone()).collect();
|
||||
let list = specs
|
||||
@@ -501,7 +556,9 @@ impl AgentManager {
|
||||
let new = specs.iter().find(|p| &p.id == id);
|
||||
matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env)
|
||||
});
|
||||
*prev = specs;
|
||||
*prev = specs.clone();
|
||||
drop(probes);
|
||||
drop(prev);
|
||||
list
|
||||
}
|
||||
|
||||
@@ -511,7 +568,7 @@ impl AgentManager {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::set_builtin_command(&dir, id, command.as_deref())
|
||||
provider::set_builtin_command(&dir, self.shared.env_secrets(), id, command.as_deref())
|
||||
}
|
||||
|
||||
/// Sets the environment variables built-in `id` starts with; empty clears them.
|
||||
@@ -519,12 +576,36 @@ impl AgentManager {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::set_builtin_env(&dir, id, env)
|
||||
provider::set_builtin_env(&dir, self.shared.env_secrets(), id, env)
|
||||
}
|
||||
|
||||
/// Saves a new custom ACP agent through its agent file.
|
||||
pub fn save_custom_agent(&self, input: CustomAgentInput) -> IdeResult<()> {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::save_custom(&dir, self.shared.env_secrets(), &input)
|
||||
}
|
||||
|
||||
/// Removes a custom agent's file (registry installs go through `registry_remove`).
|
||||
pub fn remove_custom_agent(&self, id: &str) -> IdeResult<()> {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::remove_custom(&dir, self.shared.env_secrets(), id)
|
||||
}
|
||||
|
||||
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
|
||||
pub fn import_acp_agents(&self, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::import_acp(&dir, self.shared.env_secrets(), text)
|
||||
}
|
||||
|
||||
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
|
||||
pub fn reload_providers(&self) {
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets());
|
||||
*self.shared.providers.write() = specs;
|
||||
self.shared.probes.lock().clear();
|
||||
self.shared.emit(AgentEvent::Providers);
|
||||
@@ -552,10 +633,16 @@ impl AgentManager {
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& (offered.is_empty() || !self.shared.has_config(&provider) || !self.shared.knows_all_levels(&provider))
|
||||
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
|
||||
&& let Some(spec) = self
|
||||
.shared
|
||||
.spec(&provider)
|
||||
.filter(|s| matches!(s.driver, provider::Driver::Claude | provider::Driver::Acp))
|
||||
{
|
||||
let claude = spec.driver == provider::Driver::Claude;
|
||||
self.shared.fetch_settings(spec).await;
|
||||
offered = self.shared.known_commands(&provider);
|
||||
if claude {
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
if !offered.iter().any(|c| c.name == name) {
|
||||
@@ -888,14 +975,38 @@ impl AgentManager {
|
||||
}
|
||||
o.clone()
|
||||
};
|
||||
let previous = target.current.clone();
|
||||
// Cursor's `session/set_config_option` (Fast, effort) can take seconds. Show the
|
||||
// choice first so the switch and slider move immediately.
|
||||
apply_config_choice(&task, option, &value);
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
|
||||
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
|
||||
let mut model_options =
|
||||
target.category.as_deref() != Some("model") || target.current.as_deref() == Some(value.as_str());
|
||||
target.category.as_deref() != Some("model") || previous.as_deref() == Some(value.as_str());
|
||||
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
|
||||
let (method, mut params) = config::set_request(&target, &value);
|
||||
params["sessionId"] = json!(live.session);
|
||||
let reply: Value =
|
||||
live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?;
|
||||
let reply: Value = match live.peer.request(method, params, None).await {
|
||||
Ok(reply) => reply,
|
||||
Err(e) => {
|
||||
{
|
||||
let mut s = task.state.lock();
|
||||
s.wanted.retain(|c| c.id != option);
|
||||
if let Some(prev) = previous.clone() {
|
||||
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: prev });
|
||||
}
|
||||
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
|
||||
o.current = previous.clone();
|
||||
}
|
||||
s.task.model = model_of(&s.task.config).or(s.task.model.take());
|
||||
}
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
return Err(IdeError::internal(task.describe(e)));
|
||||
}
|
||||
};
|
||||
if let Some(options) = reply.get("configOptions") {
|
||||
config::merge_update(&mut task.state.lock().task.config, options);
|
||||
model_options = true;
|
||||
@@ -906,8 +1017,6 @@ impl AgentManager {
|
||||
if !model_options {
|
||||
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
}
|
||||
s.wanted.retain(|c| c.id != option);
|
||||
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.clone() });
|
||||
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
|
||||
o.current = Some(value);
|
||||
}
|
||||
@@ -1234,15 +1343,6 @@ impl AgentManager {
|
||||
};
|
||||
// A copy of the task (a fork) may work in the same worktree.
|
||||
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
|
||||
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
|
||||
// On Windows the agent's processes lock the folder (their working directory,
|
||||
// open files) until they have exited.
|
||||
if let Some(peer) = live {
|
||||
let _ = tokio::time::timeout(EXIT_WAIT, peer.exited()).await;
|
||||
}
|
||||
let worktrees = self.shared.paths.worktrees_dir.clone();
|
||||
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
|
||||
}
|
||||
self.tasks.write().remove(id);
|
||||
*task.deleted.lock() = true;
|
||||
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{id}.json")));
|
||||
@@ -1252,44 +1352,83 @@ impl AgentManager {
|
||||
{
|
||||
let _ = std::fs::remove_dir_all(self.shared.paths.store_dir.join(saved));
|
||||
}
|
||||
// The list drops the task now; removing a large worktree's folder can take a while.
|
||||
self.shared.emit(AgentEvent::Removed { id: id.to_owned() });
|
||||
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
|
||||
// On Windows the agent's processes lock the folder (their working directory,
|
||||
// open files) until they have exited.
|
||||
if let Some(peer) = live {
|
||||
let _ = tokio::time::timeout(EXIT_WAIT, peer.exited()).await;
|
||||
}
|
||||
let worktrees = self.shared.paths.worktrees_dir.clone();
|
||||
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Files the task changed since it started.
|
||||
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
let task = self.task(id)?;
|
||||
let (cwd, base) = task.cwd_and_base();
|
||||
let at = task.base();
|
||||
let before = task.state.lock().before.clone();
|
||||
blocking(move || Ok(worktree::without_untouched(worktree::changes(&cwd, &base)?, &before))).await
|
||||
blocking(move || at.changes(&before)).await
|
||||
}
|
||||
|
||||
/// The task's uncommitted changes and, for a worktree task, the commits made since it
|
||||
/// started (newest first). Both are read against the same `HEAD`.
|
||||
pub async fn grouped_changes(&self, id: &str) -> IdeResult<AgentChanges> {
|
||||
let task = self.task(id)?;
|
||||
let at = task.base();
|
||||
let before = task.state.lock().before.clone();
|
||||
blocking(move || at.grouped_changes(&before)).await
|
||||
}
|
||||
|
||||
/// Content of `path` (in the task's directory) when the task started.
|
||||
pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult<Option<String>> {
|
||||
let (cwd, base) = self.task(id)?.cwd_and_base();
|
||||
blocking(move || worktree::file_at(&cwd, &path, &base)).await
|
||||
let at = self.task(id)?.base();
|
||||
blocking(move || match at.rev()? {
|
||||
Some(rev) => worktree::file_at(at.cwd(), &path, &rev),
|
||||
None => Ok(None),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
|
||||
/// were when it started (files the user had changed then, as the user left them).
|
||||
pub async fn rollback_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<()> {
|
||||
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as
|
||||
/// `view` compares them: as they were when it started, or as they are in `HEAD`.
|
||||
pub async fn rollback_changes(
|
||||
&self,
|
||||
id: &str,
|
||||
rel_paths: Option<Vec<String>>,
|
||||
view: AgentChangesView,
|
||||
) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
if task.state.lock().busy {
|
||||
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
|
||||
}
|
||||
let (cwd, base) = task.cwd_and_base();
|
||||
let at = task.base();
|
||||
let (before, saved) = {
|
||||
let s = task.state.lock();
|
||||
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
|
||||
};
|
||||
let restamped = blocking(move || {
|
||||
let changes = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
|
||||
let (changes, rev) = match view {
|
||||
AgentChangesView::Group => (at.grouped_changes(&before)?.uncommitted, worktree::head_commit(at.cwd())?),
|
||||
AgentChangesView::SinceStart => (at.changes(&before)?, at.rev()?),
|
||||
};
|
||||
let sources = match rev.as_deref() {
|
||||
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
|
||||
None => Default::default(),
|
||||
};
|
||||
let mut restamped = Vec::new();
|
||||
for change in changes.iter().filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path))) {
|
||||
worktree::restore(&cwd, &base, change, &before, saved.as_deref())?;
|
||||
// Back to the user's own edits: not a change of the task any more.
|
||||
if before.contains_key(&change.rel_path) {
|
||||
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
|
||||
// A rename goes back to its old path too; the new one is taken away first, so a
|
||||
// case-only rename on a case-insensitive disk ends as the old file.
|
||||
for change in std::iter::once(change).chain(sources.get(&change.rel_path)) {
|
||||
worktree::restore(at.cwd(), rev.as_deref(), change, &before, saved.as_deref())?;
|
||||
// Back to the user's own edits: not a change of the task any more.
|
||||
if before.contains_key(&change.rel_path) {
|
||||
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(restamped)
|
||||
@@ -1305,6 +1444,41 @@ impl AgentManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept: copies the task's changed files among `rel_paths` (all with `None`) from its
|
||||
/// worktree into the local checkout, uncommitted, for the user to review and commit.
|
||||
/// Files the checkout changed itself are left alone and reported. Only worktree tasks
|
||||
/// have anything to accept.
|
||||
pub async fn accept_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<AgentAcceptOutcome> {
|
||||
let task = self.task(id)?;
|
||||
if task.state.lock().busy {
|
||||
return Err(IdeError::invalid("the agent is working: stop it before accepting its changes"));
|
||||
}
|
||||
let (isolation, cwd, base, root, before) = {
|
||||
let s = task.state.lock();
|
||||
(
|
||||
s.task.isolation,
|
||||
s.task.cwd.0.clone(),
|
||||
s.task.base_commit.clone(),
|
||||
s.task.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()),
|
||||
(*s.before).clone(),
|
||||
)
|
||||
};
|
||||
if isolation != Isolation::Worktree {
|
||||
return Err(IdeError::invalid("the task works in the local checkout: there is nothing to accept"));
|
||||
}
|
||||
let Some(base) = base else {
|
||||
return Err(IdeError::invalid("the task has no base revision to accept from"));
|
||||
};
|
||||
blocking(move || {
|
||||
let wanted: Vec<AgentChange> = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before)
|
||||
.into_iter()
|
||||
.filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path)))
|
||||
.collect();
|
||||
worktree::accept(&root, &cwd, &base, &wanted)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stops every agent (project closed).
|
||||
pub fn shutdown(&self) {
|
||||
for task in self.tasks.read().values() {
|
||||
@@ -1355,15 +1529,15 @@ impl Task {
|
||||
})
|
||||
}
|
||||
|
||||
fn cwd_and_base(&self) -> (PathBuf, String) {
|
||||
fn base(&self) -> worktree::TaskBase {
|
||||
let s = self.state.lock();
|
||||
// In the project's own checkout the user commits too: compare against the current HEAD,
|
||||
// as the Commit tool window does, so commits made since the task started drop out.
|
||||
let base = match s.task.isolation {
|
||||
Isolation::Local => None,
|
||||
Isolation::Worktree => s.task.base_commit.clone(),
|
||||
};
|
||||
(s.task.cwd.0.clone(), base.unwrap_or_else(|| "HEAD".into()))
|
||||
let cwd = s.task.cwd.0.clone();
|
||||
match s.task.isolation {
|
||||
Isolation::Local => worktree::TaskBase::Local { cwd, start: s.task.base_commit.clone() },
|
||||
Isolation::Worktree => {
|
||||
worktree::TaskBase::Worktree { cwd, base: s.task.base_commit.clone().unwrap_or_else(|| "HEAD".into()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_task(&self) {
|
||||
@@ -2123,9 +2297,58 @@ impl Task {
|
||||
.iter()
|
||||
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
|
||||
.collect();
|
||||
let command = security::extract_command(params);
|
||||
// What the card names as the command: not for a request that grants more than running it.
|
||||
let shown = command.as_deref().filter(|_| !security::widens_access(params)).map(shown_command);
|
||||
if let Some(hook) = self.shared.security.read().clone()
|
||||
&& let Some(command) = command.as_deref()
|
||||
{
|
||||
match security::evaluate_command(command, &hook()) {
|
||||
security::PolicyDecision::Blocked(pattern) => {
|
||||
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
|
||||
let _ = self.push_entry(AgentEntry::Permission {
|
||||
id,
|
||||
title,
|
||||
command: shown.clone(),
|
||||
options,
|
||||
questions: Vec::new(),
|
||||
chosen: Some(format!("Blocked by policy ({pattern})")),
|
||||
});
|
||||
return match reject {
|
||||
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option.id } }),
|
||||
None => json!({ "outcome": { "outcome": "cancelled" } }),
|
||||
};
|
||||
}
|
||||
security::PolicyDecision::Allowed if !security::widens_access(params) => {
|
||||
if let Some(allow) = options
|
||||
.iter()
|
||||
.find(|o| o.kind == "allow_once")
|
||||
.or_else(|| options.iter().find(|o| o.kind.starts_with("allow")))
|
||||
.cloned()
|
||||
{
|
||||
let _ = self.push_entry(AgentEntry::Permission {
|
||||
id,
|
||||
title,
|
||||
command: shown.clone(),
|
||||
options,
|
||||
questions: Vec::new(),
|
||||
chosen: Some(format!("{} (whitelisted)", allow.name)),
|
||||
});
|
||||
return json!({ "outcome": { "outcome": "selected", "optionId": allow.id } });
|
||||
}
|
||||
}
|
||||
security::PolicyDecision::Allowed | security::PolicyDecision::AskUser => {}
|
||||
}
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let entry =
|
||||
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
|
||||
let entry = self.push_entry(AgentEntry::Permission {
|
||||
id,
|
||||
title,
|
||||
command: shown,
|
||||
options: options.clone(),
|
||||
questions,
|
||||
chosen: None,
|
||||
});
|
||||
self.permissions.lock().insert(id, tx);
|
||||
self.set_status(AgentStatus::NeedsInput, None);
|
||||
self.approve_pending();
|
||||
@@ -2287,6 +2510,11 @@ fn permission_title(params: &Value) -> String {
|
||||
title.unwrap_or("The agent asks for permission").to_owned()
|
||||
}
|
||||
|
||||
/// A command as a permission card shows it: the script of a `<shell> -c <script>` line.
|
||||
fn shown_command(command: &str) -> String {
|
||||
shell_script(command).unwrap_or_else(|| command.to_owned())
|
||||
}
|
||||
|
||||
/// The script of a `<shell> -c <quoted script>` command line, when the whole line is exactly that.
|
||||
pub(crate) fn shell_script(command: &str) -> Option<String> {
|
||||
let (shell, rest) = command.split_once(' ')?;
|
||||
@@ -2436,7 +2664,7 @@ fn with_details(message: String, data: Option<&Value>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn client_info() -> Value {
|
||||
pub(crate) fn client_info() -> Value {
|
||||
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
|
||||
}
|
||||
|
||||
@@ -2495,6 +2723,16 @@ fn retry_of(update: &Value, now: f64) -> Option<AgentRetry> {
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_config_choice(task: &Task, option: &str, value: &str) {
|
||||
let mut s = task.state.lock();
|
||||
s.wanted.retain(|c| c.id != option);
|
||||
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.to_owned() });
|
||||
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
|
||||
o.current = Some(value.to_owned());
|
||||
}
|
||||
s.task.model = model_of(&s.task.config).or(s.task.model.take());
|
||||
}
|
||||
|
||||
fn model_of(config: &[AgentConfigOption]) -> Option<String> {
|
||||
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
|
||||
}
|
||||
|
||||
@@ -384,6 +384,10 @@ pub enum AgentEntry {
|
||||
Permission {
|
||||
id: u32,
|
||||
title: String,
|
||||
/// The shell command the request asks to run, from its structured input; `None` for
|
||||
/// any other request, whatever its title says.
|
||||
#[serde(default)]
|
||||
command: Option<String>,
|
||||
options: Vec<AgentPermissionOption>,
|
||||
/// Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object
|
||||
/// of question to answer instead of an option.
|
||||
@@ -484,7 +488,8 @@ pub struct NewAgentTask {
|
||||
pub base: Option<String>,
|
||||
}
|
||||
|
||||
/// A file the task changed, relative to [`AgentTask::base_commit`].
|
||||
/// A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the
|
||||
/// grouped view.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentChange {
|
||||
@@ -493,6 +498,44 @@ pub struct AgentChange {
|
||||
pub kind: ide_vcs::ChangeKind,
|
||||
pub added: u32,
|
||||
pub removed: u32,
|
||||
/// Path before a rename; the left side of its diff reads this path.
|
||||
pub old_path: Option<String>,
|
||||
}
|
||||
|
||||
/// A commit made while the task ran, with the files it changed against its first parent.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCommit {
|
||||
pub id: String,
|
||||
pub short_id: String,
|
||||
pub summary: String,
|
||||
pub message: String,
|
||||
pub time_ms: f64,
|
||||
/// First parent, or `None` for a root commit.
|
||||
pub parent: Option<String>,
|
||||
pub files: Vec<AgentChange>,
|
||||
}
|
||||
|
||||
/// Which comparison a rollback of the task's changes uses.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentChangesView {
|
||||
/// Everything since the commit the task started from.
|
||||
#[default]
|
||||
SinceStart,
|
||||
/// The uncommitted files against `HEAD`; commits made since the start stay.
|
||||
Group,
|
||||
}
|
||||
|
||||
/// A task's uncommitted files and the commits made since it started.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentChanges {
|
||||
/// Files changed against `HEAD`, less those changed before the task started and untouched since.
|
||||
pub uncommitted: Vec<AgentChange>,
|
||||
/// Commits from the task's start (exclusive) to `HEAD` (inclusive), newest first. Empty
|
||||
/// for tasks working in the project's own checkout.
|
||||
pub commits: Vec<AgentCommit>,
|
||||
}
|
||||
|
||||
/// Streamed to the window that subscribed (`AgentManager::subscribe`).
|
||||
@@ -602,3 +645,49 @@ pub struct McpServerInput {
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Payload for creating a custom ACP agent (always the ACP driver); the id
|
||||
/// becomes the `<config>/agents/<id>.toml` file name.
|
||||
#[derive(Clone, Debug, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CustomAgentInput {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// What importing one entry of a JetBrains `acp.json` file did.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcpImportOutcome {
|
||||
/// The `agent_servers` key the entry was imported from.
|
||||
pub name: String,
|
||||
/// The id its agent file would get (also when skipped).
|
||||
pub id: String,
|
||||
pub saved: bool,
|
||||
/// Why the entry was skipped; empty when it was saved.
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// What accepting a worktree task's changes copied into the local checkout.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentAcceptOutcome {
|
||||
/// Relative paths copied into the checkout (uncommitted), including ones already identical.
|
||||
pub accepted: Vec<String>,
|
||||
/// Files left alone because the checkout has its own edits to them.
|
||||
pub skipped: Vec<AgentAcceptSkipped>,
|
||||
}
|
||||
|
||||
/// A file accept left alone, with why.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentAcceptSkipped {
|
||||
pub path: String,
|
||||
pub reason: String,
|
||||
}
|
||||
@@ -869,13 +869,14 @@ impl CodexShim {
|
||||
"item/commandExecution/requestApproval" => {
|
||||
let choices = translate::decisions(params, true);
|
||||
let title = translate::command_approval_title(params, item.as_ref());
|
||||
let call = tool_call_of(
|
||||
&item_id,
|
||||
&title,
|
||||
"execute",
|
||||
reason,
|
||||
Some(json!({ "command": params.get("command"), "cwd": params.get("cwd") })),
|
||||
);
|
||||
let mut input = json!({
|
||||
"command": translate::approval_command(params, item.as_ref()),
|
||||
"cwd": params.get("cwd"),
|
||||
});
|
||||
if let Some(network) = params.get("networkApprovalContext").filter(|c| !c.is_null()) {
|
||||
input["networkApprovalContext"] = network.clone();
|
||||
}
|
||||
let call = tool_call_of(&item_id, &title, "execute", reason, Some(input));
|
||||
Ok(self.ask(&thread, call, &choices, decline))
|
||||
}
|
||||
"item/fileChange/requestApproval" => {
|
||||
|
||||
@@ -757,19 +757,23 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) ->
|
||||
choices.iter().find(|c| selected && c.option["optionId"].as_str() == chosen).map_or(decline, |c| c.answer.clone())
|
||||
}
|
||||
|
||||
/// The command a command approval is about: the request's own, else its item's.
|
||||
pub(super) fn approval_command<'a>(params: &'a Value, item: Option<&'a Value>) -> Option<&'a str> {
|
||||
// Approval payloads can describe a different command from the item's earlier summary.
|
||||
let item = item.filter(|i| i.get("id") == params.get("itemId"));
|
||||
params
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|c| !c.is_empty())
|
||||
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()))
|
||||
}
|
||||
|
||||
/// The ACP title of a command approval.
|
||||
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
|
||||
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
|
||||
return format!("Allow network access to {host}");
|
||||
}
|
||||
// Approval payloads can describe a different command from the item's earlier summary.
|
||||
let item = item.filter(|i| i.get("id") == params.get("itemId"));
|
||||
let command = params
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|c| !c.is_empty())
|
||||
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()));
|
||||
match command {
|
||||
match approval_command(params, item) {
|
||||
Some(command) => format!("Run {}", crate::shell_script(command).unwrap_or_else(|| command.to_owned())),
|
||||
None => "Run a command".to_owned(),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
|
||||
//! Registry, see `registry`).
|
||||
|
||||
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
|
||||
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput, EnvSecrets};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -231,23 +231,40 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
|
||||
|
||||
/// Overrides built-in `id`'s command through its agent file, preserving other
|
||||
/// customizations. `None` restores the built-in command and removes a redundant file.
|
||||
pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, id, |agent, builtin| agent.command = command.unwrap_or(&builtin.command).to_owned())
|
||||
pub(crate) fn set_builtin_command(
|
||||
custom_dir: &Path,
|
||||
secrets: Option<&dyn EnvSecrets>,
|
||||
id: &str,
|
||||
command: Option<&str>,
|
||||
) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, secrets, id, |agent, builtin| {
|
||||
agent.command = command.unwrap_or(&builtin.command).to_owned()
|
||||
})
|
||||
}
|
||||
|
||||
/// The environment built-in `id` starts with, through its agent file (empty clears it).
|
||||
pub(crate) fn set_builtin_env(custom_dir: &Path, id: &str, env: BTreeMap<String, String>) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, id, |agent, _| agent.env = env)
|
||||
pub(crate) fn set_builtin_env(
|
||||
custom_dir: &Path,
|
||||
secrets: Option<&dyn EnvSecrets>,
|
||||
id: &str,
|
||||
env: BTreeMap<String, String>,
|
||||
) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, secrets, id, |agent, _| agent.env = env)
|
||||
}
|
||||
|
||||
/// Changes built-in `id`'s agent file; a file left with only the built-in's values is removed.
|
||||
fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAgent, &ProviderSpec)) -> IdeResult<()> {
|
||||
fn update_builtin(
|
||||
custom_dir: &Path,
|
||||
secrets: Option<&dyn EnvSecrets>,
|
||||
id: &str,
|
||||
change: impl FnOnce(&mut CustomAgent, &ProviderSpec),
|
||||
) -> IdeResult<()> {
|
||||
let Some(builtin) = builtin_spec(id) else {
|
||||
return Err(IdeError::invalid(format!("{id} is not a built-in agent")));
|
||||
};
|
||||
let file = custom_dir.join(format!("{id}.toml"));
|
||||
let mut agent = if file.try_exists()? {
|
||||
CustomAgent::read(&file).map_err(IdeError::invalid)?
|
||||
CustomAgent::read_with(&file, id, secrets).map_err(IdeError::invalid)?
|
||||
} else {
|
||||
CustomAgent {
|
||||
name: Some(builtin.name.clone()),
|
||||
@@ -272,14 +289,145 @@ fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAg
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
|
||||
_ => {}
|
||||
}
|
||||
forget_env(secrets, id, &[]);
|
||||
return Ok(());
|
||||
}
|
||||
agent.write(&file)?;
|
||||
Ok(())
|
||||
agent.write_with(&file, id, secrets)
|
||||
}
|
||||
|
||||
/// Saves a new custom agent; refuses built-in and ACP Registry ids, taken ids
|
||||
/// and an empty command. The id is also the agent file's name, so it must be
|
||||
/// a safe file stem.
|
||||
pub(crate) fn save_custom(
|
||||
custom_dir: &Path,
|
||||
secrets: Option<&dyn EnvSecrets>,
|
||||
input: &CustomAgentInput,
|
||||
) -> IdeResult<()> {
|
||||
let id = validated_custom_id(&input.id)?;
|
||||
if builtin_spec(&id).is_some() {
|
||||
return Err(IdeError::invalid(format!("{id} is a built-in agent; change its command instead")));
|
||||
}
|
||||
if input.command.trim().is_empty() {
|
||||
return Err(IdeError::invalid("the agent command must not be empty"));
|
||||
}
|
||||
let file = custom_dir.join(format!("{id}.toml"));
|
||||
if file.try_exists()? {
|
||||
if CustomAgent::read(&file).is_ok_and(|a| a.registry.is_some()) {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{id} is installed from the ACP Registry; uninstall it there to replace it"
|
||||
)));
|
||||
}
|
||||
return Err(IdeError::invalid(format!("there is already a custom agent '{id}'")));
|
||||
}
|
||||
CustomAgent {
|
||||
name: Some(input.name.clone()),
|
||||
driver: Driver::Acp,
|
||||
command: input.command.clone(),
|
||||
args: input.args.clone(),
|
||||
env: input.env.clone(),
|
||||
registry: None,
|
||||
}
|
||||
.write_with(&file, &id, secrets)
|
||||
}
|
||||
|
||||
/// Removes a custom agent's file; refuses built-in, ACP Registry and missing ids.
|
||||
pub(crate) fn remove_custom(custom_dir: &Path, secrets: Option<&dyn EnvSecrets>, id: &str) -> IdeResult<()> {
|
||||
let id = validated_custom_id(id)?;
|
||||
let id = id.as_str();
|
||||
if builtin_spec(id).is_some() {
|
||||
return Err(IdeError::invalid(format!("{id} is a built-in agent and cannot be removed")));
|
||||
}
|
||||
let file = custom_dir.join(format!("{id}.toml"));
|
||||
let agent =
|
||||
CustomAgent::read(&file).map_err(|_| IdeError::not_found(format!("there is no custom agent '{id}'")))?;
|
||||
if agent.registry.is_some() {
|
||||
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry; uninstall it there")));
|
||||
}
|
||||
match std::fs::remove_file(&file) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
|
||||
_ => {
|
||||
forget_env(secrets, id, &[]);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom
|
||||
/// agents, one outcome per entry; entries that collide, lack a command or
|
||||
/// have no usable name are skipped with a reason.
|
||||
pub(crate) fn import_acp(
|
||||
custom_dir: &Path,
|
||||
secrets: Option<&dyn EnvSecrets>,
|
||||
text: &str,
|
||||
) -> IdeResult<Vec<AcpImportOutcome>> {
|
||||
#[derive(Deserialize)]
|
||||
struct AcpJson {
|
||||
#[serde(default)]
|
||||
agent_servers: BTreeMap<String, AcpServer>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct AcpServer {
|
||||
#[serde(default)]
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: BTreeMap<String, String>,
|
||||
}
|
||||
let parsed: AcpJson =
|
||||
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("not an acp.json file: {e}")))?;
|
||||
let mut outcomes = Vec::with_capacity(parsed.agent_servers.len());
|
||||
for (name, server) in parsed.agent_servers {
|
||||
let id = slugify_id(&name);
|
||||
let mut outcome = AcpImportOutcome { name, id: id.clone(), saved: false, reason: String::new() };
|
||||
if id.is_empty() {
|
||||
outcome.reason = "the name has no usable characters for an id".to_owned();
|
||||
} else if builtin_spec(&id).is_some() {
|
||||
outcome.reason = format!("{id} is a built-in agent");
|
||||
} else if server.command.trim().is_empty() {
|
||||
outcome.reason = "the entry has no command".to_owned();
|
||||
} else if custom_dir.join(format!("{id}.toml")).try_exists()? {
|
||||
outcome.reason = format!("there is already a custom agent '{id}'");
|
||||
} else {
|
||||
CustomAgent {
|
||||
name: Some(outcome.name.clone()),
|
||||
driver: Driver::Acp,
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env,
|
||||
registry: None,
|
||||
}
|
||||
.write_with(&custom_dir.join(format!("{id}.toml")), &id, secrets)?;
|
||||
outcome.saved = true;
|
||||
}
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
/// A custom agent id doubles as a file stem: letters, digits, `-` and `_` only.
|
||||
fn validated_custom_id(id: &str) -> IdeResult<String> {
|
||||
if !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Ok(id.to_owned());
|
||||
}
|
||||
Err(IdeError::invalid(format!("'{id}' is not a usable agent id (letters, digits, '-' and '_' only)")))
|
||||
}
|
||||
|
||||
/// Turns an `acp.json` server name into a file-stem id (`"My Agent!"` → `"my-agent"`).
|
||||
fn slugify_id(name: &str) -> String {
|
||||
let mut id = String::with_capacity(name.len());
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
id.push(c.to_ascii_lowercase());
|
||||
} else if !id.ends_with('-') {
|
||||
id.push('-');
|
||||
}
|
||||
}
|
||||
id.trim_matches('-').to_owned()
|
||||
}
|
||||
|
||||
/// An agent file, `<config>/agents/<id>.toml`.
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct CustomAgent {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -326,11 +474,67 @@ impl CustomAgent {
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
/// Reads agent `id`'s file with its environment values filled in from `secrets`; values
|
||||
/// still written in the file (by hand, by an older version) move there first. A registry
|
||||
/// install keeps the environment its registry entry gives it in the file.
|
||||
pub(crate) fn read_with(path: &Path, id: &str, secrets: Option<&dyn EnvSecrets>) -> Result<Self, String> {
|
||||
let mut agent = Self::read(path)?;
|
||||
let Some(secrets) = secrets.filter(|_| agent.registry.is_none()) else {
|
||||
return Ok(agent);
|
||||
};
|
||||
let written = agent.env.values().any(|v| !v.is_empty());
|
||||
for (name, value) in agent.env.iter_mut() {
|
||||
if value.is_empty()
|
||||
&& let Some(saved) = secrets.get(&env_key(id, name))
|
||||
{
|
||||
*value = saved;
|
||||
}
|
||||
}
|
||||
if written && let Err(e) = agent.write_with(path, id, Some(secrets)) {
|
||||
tracing::warn!("agent {id}: could not move environment values out of {}: {}", path.display(), e.message);
|
||||
}
|
||||
Ok(agent)
|
||||
}
|
||||
|
||||
/// Writes agent `id`'s file with the environment's names only, its values saved in `secrets`.
|
||||
pub(crate) fn write_with(&self, path: &Path, id: &str, secrets: Option<&dyn EnvSecrets>) -> IdeResult<()> {
|
||||
let Some(secrets) = secrets.filter(|_| self.registry.is_none()) else {
|
||||
return Ok(self.write(path)?);
|
||||
};
|
||||
let mut keys = Vec::with_capacity(self.env.len());
|
||||
for (name, value) in &self.env {
|
||||
let key = env_key(id, name);
|
||||
if value.is_empty() {
|
||||
secrets.delete(&key);
|
||||
} else if secrets.get(&key).as_deref() != Some(value.as_str()) {
|
||||
secrets.set(&key, value)?;
|
||||
}
|
||||
keys.push(key);
|
||||
}
|
||||
forget_env(Some(secrets), id, &keys);
|
||||
let names = self.env.keys().map(|name| (name.clone(), String::new())).collect();
|
||||
Ok(Self { env: names, ..self.clone() }.write(path)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn env_key(id: &str, name: &str) -> String {
|
||||
format!("agent/{id}/env/{}", name.replace('%', "%25").replace('/', "%2F"))
|
||||
}
|
||||
|
||||
/// Forgets agent `id`'s saved environment values, except `keep`.
|
||||
fn forget_env(secrets: Option<&dyn EnvSecrets>, id: &str, keep: &[String]) {
|
||||
let Some(secrets) = secrets else { return };
|
||||
for key in secrets.keys(&format!("agent/{id}/env/")) {
|
||||
if !keep.contains(&key) {
|
||||
secrets.delete(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in agents, then the `*.toml` agents of `custom_dir`; a custom agent with a
|
||||
/// built-in id replaces it.
|
||||
pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
|
||||
pub(crate) fn load(custom_dir: Option<&Path>, secrets: Option<&dyn EnvSecrets>) -> Vec<ProviderSpec> {
|
||||
let mut specs = builtins();
|
||||
let mut files: Vec<PathBuf> = custom_dir
|
||||
.and_then(|d| std::fs::read_dir(d).ok())
|
||||
@@ -343,7 +547,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
|
||||
files.sort();
|
||||
for path in files {
|
||||
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
|
||||
let agent = match CustomAgent::read(&path) {
|
||||
let agent = match CustomAgent::read_with(&path, &id, secrets) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::warn!("skipping agent {}: {e}", path.display());
|
||||
@@ -413,7 +617,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn qoder_global_and_cn_editions_are_both_built_in() {
|
||||
let info = |id: &str| describe(&load(None).into_iter().find(|s| s.id == id).unwrap());
|
||||
let info = |id: &str| describe(&load(None, None).into_iter().find(|s| s.id == id).unwrap());
|
||||
let (global, cn) = (info("qoder"), info("qodercn"));
|
||||
assert_eq!((global.name.as_str(), global.command.as_str()), ("Qoder", "qodercli --acp"));
|
||||
assert_eq!((cn.name.as_str(), cn.command.as_str()), ("Qoder CN", "qoderclicn --acp"));
|
||||
@@ -423,7 +627,7 @@ mod tests {
|
||||
#[test]
|
||||
fn qodercn_is_available_without_a_file_and_preserves_existing_customizations() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let qodercn = load(None).into_iter().find(|s| s.id == "qodercn").unwrap();
|
||||
let qodercn = load(None, None).into_iter().find(|s| s.id == "qodercn").unwrap();
|
||||
assert_eq!(qodercn.driver, Driver::Acp);
|
||||
assert!(!is_unlisted(&qodercn));
|
||||
let info = describe(&qodercn);
|
||||
@@ -438,13 +642,13 @@ mod tests {
|
||||
"command = \"/custom/qoderclicn\"\nargs = [\"--acp\"]\n[env]\nK = \"v\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let specs = load(Some(dir.path()));
|
||||
let specs = load(Some(dir.path()), None);
|
||||
assert_eq!(specs.iter().filter(|s| s.id == "qodercn").count(), 1);
|
||||
let custom = specs.iter().find(|s| s.id == "qodercn").unwrap();
|
||||
assert_eq!(custom.command, "/custom/qoderclicn");
|
||||
assert_eq!(custom.env, [("K".into(), "v".into())]);
|
||||
set_builtin_command(dir.path(), "qodercn", None).unwrap();
|
||||
let reset = load(Some(dir.path())).into_iter().find(|s| s.id == "qodercn").unwrap();
|
||||
set_builtin_command(dir.path(), None, "qodercn", None).unwrap();
|
||||
let reset = load(Some(dir.path()), None).into_iter().find(|s| s.id == "qodercn").unwrap();
|
||||
assert_eq!(reset.command, "qoderclicn");
|
||||
assert_eq!(reset.args, ["--acp"]);
|
||||
assert_eq!(reset.env, custom.env);
|
||||
@@ -559,7 +763,7 @@ mod tests {
|
||||
#[test]
|
||||
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = || load(Some(dir.path())).iter().map(describe).collect::<Vec<_>>();
|
||||
let list = || load(Some(dir.path()), None).iter().map(describe).collect::<Vec<_>>();
|
||||
let defaults = list();
|
||||
let codex = defaults.iter().find(|p| p.id == "codex").unwrap();
|
||||
assert_eq!(codex.program, "codex");
|
||||
@@ -579,14 +783,14 @@ mod tests {
|
||||
p.id.ends_with("-acp").then_some(AgentCommandReadOnlyReason::PackageRunner)
|
||||
);
|
||||
}
|
||||
set_builtin_command(dir.path(), "codex", Some("/path with spaces/codex")).unwrap();
|
||||
set_builtin_command(dir.path(), None, "codex", Some("/path with spaces/codex")).unwrap();
|
||||
let overridden = list().into_iter().find(|p| p.id == "codex").unwrap();
|
||||
assert_eq!(overridden.program, "/path with spaces/codex");
|
||||
assert_eq!(overridden.args, codex.args);
|
||||
assert_eq!(overridden.default_program, codex.default_program);
|
||||
assert!(overridden.command_overridden && overridden.command_editable);
|
||||
assert_eq!(overridden.source, AgentSource::Builtin);
|
||||
set_builtin_command(dir.path(), "codex", Some("codex")).unwrap();
|
||||
set_builtin_command(dir.path(), None, "codex", Some("codex")).unwrap();
|
||||
assert!(!list().iter().find(|p| p.id == "codex").unwrap().command_overridden);
|
||||
std::fs::write(
|
||||
dir.path().join("gemini.toml"),
|
||||
@@ -611,7 +815,7 @@ mod tests {
|
||||
write("mine.toml", "name = \"Mine\"\ncommand = \"/opt/mine\"\nargs = [\"acp\"]\n[env]\nK = \"v\"\n");
|
||||
write("gemini.toml", "command = \"gemini\"\nargs = [\"--acp\"]\n");
|
||||
write("broken.toml", "nope = 1\n");
|
||||
let specs = load(Some(dir.path()));
|
||||
let specs = load(Some(dir.path()), None);
|
||||
let mine = specs.iter().find(|s| s.id == "mine").unwrap();
|
||||
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
|
||||
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
|
||||
@@ -627,34 +831,35 @@ mod tests {
|
||||
fn set_builtin_env_writes_and_clears_the_environment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let env = BTreeMap::from([("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
|
||||
set_builtin_env(dir.path(), "claude", env.clone()).unwrap();
|
||||
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
|
||||
set_builtin_env(dir.path(), None, "claude", env.clone()).unwrap();
|
||||
let claude = load(Some(dir.path()), None).into_iter().find(|s| s.id == "claude").unwrap();
|
||||
assert_eq!(claude.env, vec![("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
|
||||
assert_eq!(describe(&claude).env, env);
|
||||
// The command override and the environment are kept independently.
|
||||
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
set_builtin_command(dir.path(), None, "claude", Some("/opt/claude")).unwrap();
|
||||
set_builtin_command(dir.path(), None, "claude", None).unwrap();
|
||||
assert!(dir.path().join("claude.toml").is_file());
|
||||
set_builtin_env(dir.path(), "claude", BTreeMap::new()).unwrap();
|
||||
set_builtin_env(dir.path(), None, "claude", BTreeMap::new()).unwrap();
|
||||
assert!(!dir.path().join("claude.toml").exists());
|
||||
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
|
||||
set_builtin_env(dir.path(), None, "not-a-builtin", env).unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_adapters_of_native_agents_are_unlisted_until_customized() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let unlisted = |dir: &Path| load(Some(dir)).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
|
||||
let unlisted =
|
||||
|dir: &Path| load(Some(dir), None).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
|
||||
assert_eq!(unlisted(dir.path()), ["claude-acp", "codex-acp"]);
|
||||
set_builtin_env(dir.path(), "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
|
||||
set_builtin_env(dir.path(), None, "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
|
||||
assert_eq!(unlisted(dir.path()), ["claude-acp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_builtin_command_overrides_and_clears_a_builtin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
set_builtin_command(dir.path(), "not-a-builtin", Some("/x")).unwrap_err();
|
||||
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
|
||||
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
|
||||
set_builtin_command(dir.path(), None, "not-a-builtin", Some("/x")).unwrap_err();
|
||||
set_builtin_command(dir.path(), None, "claude", Some("/opt/claude")).unwrap();
|
||||
let claude = load(Some(dir.path()), None).into_iter().find(|s| s.id == "claude").unwrap();
|
||||
assert_eq!((claude.command.as_str(), claude.driver), ("/opt/claude", Driver::Claude));
|
||||
assert!(claude.args.is_empty());
|
||||
// A registry install is neither overwritten nor removed by a command override.
|
||||
@@ -669,14 +874,14 @@ mod tests {
|
||||
}
|
||||
.write(&path)
|
||||
.unwrap();
|
||||
set_builtin_command(dir.path(), "codex", Some("/x")).unwrap_err();
|
||||
set_builtin_command(dir.path(), "codex", None).unwrap_err();
|
||||
set_builtin_command(dir.path(), None, "codex", Some("/x")).unwrap_err();
|
||||
set_builtin_command(dir.path(), None, "codex", None).unwrap_err();
|
||||
assert!(path.exists());
|
||||
// Clearing (twice) returns the built-in to auto-detection.
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
set_builtin_command(dir.path(), None, "claude", None).unwrap();
|
||||
set_builtin_command(dir.path(), None, "claude", None).unwrap();
|
||||
assert!(!dir.path().join("claude.toml").exists());
|
||||
assert_eq!(load(Some(dir.path())).iter().find(|s| s.id == "claude").unwrap().command, "claude");
|
||||
assert_eq!(load(Some(dir.path()), None).iter().find(|s| s.id == "claude").unwrap().command, "claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -704,11 +909,11 @@ mod tests {
|
||||
}
|
||||
agent.write(&path).unwrap();
|
||||
|
||||
set_builtin_command(dir.path(), "claude", Some("/new/claude")).unwrap();
|
||||
set_builtin_command(dir.path(), None, "claude", Some("/new/claude")).unwrap();
|
||||
agent.command = "/new/claude".into();
|
||||
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
|
||||
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
set_builtin_command(dir.path(), None, "claude", None).unwrap();
|
||||
agent.command = "claude".into();
|
||||
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
|
||||
}
|
||||
@@ -724,11 +929,11 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
set_builtin_command(dir.path(), "codex", None).unwrap();
|
||||
set_builtin_command(dir.path(), None, "codex", None).unwrap();
|
||||
|
||||
assert!(!path.exists());
|
||||
assert_eq!(
|
||||
load(Some(dir.path())).into_iter().find(|s| s.id == "codex").unwrap(),
|
||||
load(Some(dir.path()), None).into_iter().find(|s| s.id == "codex").unwrap(),
|
||||
builtin_spec("codex").unwrap()
|
||||
);
|
||||
}
|
||||
@@ -749,8 +954,151 @@ mod tests {
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("[registry]") && !text.contains("driver"), "{text}");
|
||||
assert_eq!(CustomAgent::read(&path).unwrap(), agent);
|
||||
let spec = load(Some(path.parent().unwrap())).into_iter().find(|s| s.id == "amp-acp").unwrap();
|
||||
let spec = load(Some(path.parent().unwrap()), None).into_iter().find(|s| s.id == "amp-acp").unwrap();
|
||||
assert_eq!(spec.source, AgentSource::Registry { version: "0.10.0".into(), update: None });
|
||||
assert!(toml::from_str::<CustomAgent>("command = \"x\"\n[registry]\nid = \"x\"\n").is_err());
|
||||
}
|
||||
|
||||
fn custom_input(id: &str) -> CustomAgentInput {
|
||||
CustomAgentInput {
|
||||
id: id.into(),
|
||||
name: "Mine".into(),
|
||||
command: "/opt/mine".into(),
|
||||
args: vec!["acp".into()],
|
||||
env: BTreeMap::from([("K".to_owned(), "v".to_owned())]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_agents_save_list_and_remove() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_custom(dir.path(), None, &custom_input("mine")).unwrap();
|
||||
let mine = load(Some(dir.path()), None).into_iter().find(|s| s.id == "mine").unwrap();
|
||||
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
|
||||
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
|
||||
assert_eq!(mine.source, AgentSource::Custom);
|
||||
remove_custom(dir.path(), None, "mine").unwrap();
|
||||
assert!(!load(Some(dir.path()), None).iter().any(|s| s.id == "mine"));
|
||||
remove_custom(dir.path(), None, "mine").unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_agents_refuse_builtin_registry_and_bad_ids() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_custom(dir.path(), None, &custom_input("claude")).unwrap_err();
|
||||
save_custom(dir.path(), None, &custom_input("../evil")).unwrap_err();
|
||||
save_custom(dir.path(), None, &custom_input("")).unwrap_err();
|
||||
let mut empty_command = custom_input("mine");
|
||||
empty_command.command = " ".into();
|
||||
save_custom(dir.path(), None, &empty_command).unwrap_err();
|
||||
save_custom(dir.path(), None, &custom_input("mine")).unwrap();
|
||||
save_custom(dir.path(), None, &custom_input("mine")).unwrap_err();
|
||||
remove_custom(dir.path(), None, "claude").unwrap_err();
|
||||
// An id that isn't a plain file stem never reaches outside the agents directory.
|
||||
let outside = dir.path().join("outside.toml");
|
||||
std::fs::write(&outside, "command = \"/bin/sh\"\n").unwrap();
|
||||
let agents = dir.path().join("agents");
|
||||
std::fs::create_dir(&agents).unwrap();
|
||||
remove_custom(&agents, None, "../outside").unwrap_err();
|
||||
assert!(outside.exists());
|
||||
std::fs::write(
|
||||
dir.path().join("amp-acp.toml"),
|
||||
"command = \"/registry/amp\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
save_custom(dir.path(), None, &custom_input("amp-acp")).unwrap_err();
|
||||
remove_custom(dir.path(), None, "amp-acp").unwrap_err();
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemorySecrets(parking_lot::Mutex<BTreeMap<String, String>>);
|
||||
|
||||
impl EnvSecrets for MemorySecrets {
|
||||
fn get(&self, key: &str) -> Option<String> {
|
||||
self.0.lock().get(key).cloned()
|
||||
}
|
||||
fn set(&self, key: &str, value: &str) -> IdeResult<()> {
|
||||
self.0.lock().insert(key.to_owned(), value.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&self, key: &str) {
|
||||
self.0.lock().remove(key);
|
||||
}
|
||||
fn keys(&self, prefix: &str) -> Vec<String> {
|
||||
self.0.lock().keys().filter(|k| k.starts_with(prefix)).cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_values_go_to_the_secrets_and_the_files_keep_the_names() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let secrets = MemorySecrets::default();
|
||||
let s = Some(&secrets as &dyn EnvSecrets);
|
||||
let env = BTreeMap::from([("API_KEY".to_owned(), "v-91f".to_owned()), ("A/B".to_owned(), String::new())]);
|
||||
set_builtin_env(dir.path(), s, "claude", env.clone()).unwrap();
|
||||
save_custom(dir.path(), s, &CustomAgentInput { env: env.clone(), ..custom_input("mine") }).unwrap();
|
||||
for id in ["claude", "mine"] {
|
||||
let text = std::fs::read_to_string(dir.path().join(format!("{id}.toml"))).unwrap();
|
||||
assert!(text.contains("API_KEY") && !text.contains("v-91f"), "{text}");
|
||||
let spec = load(Some(dir.path()), s).into_iter().find(|p| p.id == id).unwrap();
|
||||
assert_eq!(describe(&spec).env, env);
|
||||
}
|
||||
assert_eq!(secrets.keys("agent/"), ["agent/claude/env/API_KEY", "agent/mine/env/API_KEY"]);
|
||||
|
||||
set_builtin_command(dir.path(), s, "claude", Some("/opt/claude")).unwrap();
|
||||
assert_eq!(secrets.get("agent/claude/env/API_KEY").as_deref(), Some("v-91f"));
|
||||
set_builtin_env(dir.path(), s, "claude", BTreeMap::from([("OTHER".to_owned(), "x".to_owned())])).unwrap();
|
||||
assert_eq!(secrets.keys("agent/claude/"), ["agent/claude/env/OTHER"]);
|
||||
set_builtin_command(dir.path(), s, "claude", None).unwrap();
|
||||
set_builtin_env(dir.path(), s, "claude", BTreeMap::new()).unwrap();
|
||||
remove_custom(dir.path(), s, "mine").unwrap();
|
||||
assert_eq!(secrets.keys("agent/"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_written_in_agent_files_move_to_the_secrets_unless_installed_from_the_registry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let secrets = MemorySecrets::default();
|
||||
let s = Some(&secrets as &dyn EnvSecrets);
|
||||
std::fs::write(dir.path().join("mine.toml"), "command = \"/opt/mine\"\n[env]\nK = \"v-91f\"\nS = \"\"\n")
|
||||
.unwrap();
|
||||
secrets.set("agent/mine/env/S", "saved").unwrap();
|
||||
let registry =
|
||||
"command = \"/registry/amp\"\n[env]\nR = \"1\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n";
|
||||
std::fs::write(dir.path().join("amp-acp.toml"), registry).unwrap();
|
||||
let specs = load(Some(dir.path()), s);
|
||||
assert_eq!(
|
||||
specs.iter().find(|p| p.id == "mine").unwrap().env,
|
||||
[("K".to_owned(), "v-91f".to_owned()), ("S".to_owned(), "saved".to_owned())]
|
||||
);
|
||||
assert!(!std::fs::read_to_string(dir.path().join("mine.toml")).unwrap().contains("v-91f"));
|
||||
assert_eq!(secrets.keys("agent/"), ["agent/mine/env/K", "agent/mine/env/S"]);
|
||||
assert_eq!(std::fs::read_to_string(dir.path().join("amp-acp.toml")).unwrap(), registry);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_json_imports_servers_and_skips_collisions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_custom(dir.path(), None, &custom_input("taken")).unwrap();
|
||||
let outcomes = import_acp(
|
||||
dir.path(),
|
||||
None,
|
||||
r#"{"agent_servers": {
|
||||
"My Agent!": {"command": "/opt/agent", "args": ["--acp"], "env": {"K": "v"}},
|
||||
"taken": {"command": "/opt/taken"},
|
||||
"claude": {"command": "/opt/claude"},
|
||||
"!!!": {"command": "/opt/noname"},
|
||||
"silent": {"args": []}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let saved: Vec<&str> = outcomes.iter().filter(|o| o.saved).map(|o| o.id.as_str()).collect();
|
||||
assert_eq!(saved, ["my-agent"]);
|
||||
assert!(outcomes.iter().filter(|o| !o.saved).all(|o| !o.reason.is_empty()));
|
||||
let mine = load(Some(dir.path()), None).into_iter().find(|s| s.id == "my-agent").unwrap();
|
||||
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("My Agent!", "/opt/agent"));
|
||||
assert_eq!(mine.source, AgentSource::Custom);
|
||||
import_acp(dir.path(), None, "not json").unwrap_err();
|
||||
assert!(import_acp(dir.path(), None, "{}").unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -826,7 +826,7 @@ mod tests {
|
||||
assert!(!shown.iter().any(|a| a.id == "cline"));
|
||||
|
||||
write_atomic(&p.cache_file, INDEX.as_bytes()).unwrap();
|
||||
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir))
|
||||
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir), None)
|
||||
.into_iter()
|
||||
.filter(|s| s.id == "amp-acp")
|
||||
.map(|s| provider::describe(&s))
|
||||
@@ -918,7 +918,7 @@ mod tests {
|
||||
assert_eq!(agent.command, v1.to_string_lossy());
|
||||
assert_eq!((agent.args.as_slice(), agent.env["A"].as_str()), (&["acp".to_owned()][..], "1"));
|
||||
assert_eq!(agent.registry, Some(RegistryRef { id: "demo".into(), version: "1.0.0".into() }));
|
||||
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "demo").unwrap();
|
||||
let spec = provider::load(Some(&p.agents_dir), None).into_iter().find(|s| s.id == "demo").unwrap();
|
||||
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.0".into(), update: None });
|
||||
assert!(provider::installed(&spec.command));
|
||||
|
||||
@@ -991,7 +991,7 @@ mod tests {
|
||||
assert_eq!((shown[0].installed.as_deref(), shown[0].update), (Some("1.0.104"), true));
|
||||
|
||||
write_atomic(&p.cache_file, index.as_bytes()).unwrap();
|
||||
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
|
||||
let spec = provider::load(Some(&p.agents_dir), None).into_iter().find(|s| s.id == "copilot").unwrap();
|
||||
assert_eq!(spec.name, "GitHub Copilot");
|
||||
assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in.");
|
||||
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None });
|
||||
@@ -1004,7 +1004,7 @@ mod tests {
|
||||
);
|
||||
|
||||
remove(&p, "github-copilot-cli").unwrap();
|
||||
let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
|
||||
let fallback = provider::load(Some(&p.agents_dir), None).into_iter().find(|s| s.id == "copilot").unwrap();
|
||||
assert_eq!(fallback.source, AgentSource::Builtin);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ fn paths(dir: &Path) -> AgentPaths {
|
||||
models_file: dir.join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
//! Command execution security policy (Whitelist / Blacklist) for coding agents.
|
||||
|
||||
use ide_core::settings::AgentSecuritySettings;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PolicyDecision {
|
||||
/// Command matched a blacklist pattern (`pattern`).
|
||||
Blocked(String),
|
||||
/// Command matched the whitelist and no blacklist pattern.
|
||||
Allowed,
|
||||
/// Neither matched; prompt the user as usual.
|
||||
AskUser,
|
||||
}
|
||||
|
||||
/// The shell command an ACP `session/request_permission` asks to run: only a tool call of kind
|
||||
/// `execute`, and only its structured `rawInput` command. A title is free text the agent
|
||||
/// writes, so it never names the command.
|
||||
pub fn extract_command(params: &Value) -> Option<String> {
|
||||
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"))?;
|
||||
if tool.get("kind").and_then(Value::as_str) != Some("execute") {
|
||||
return None;
|
||||
}
|
||||
let raw_cmd = tool.pointer("/rawInput/command").or_else(|| tool.pointer("/rawInput/cmd"));
|
||||
raw_cmd.and_then(command_from_value)
|
||||
}
|
||||
|
||||
/// Whether allowing the request grants more than running its command, such as network access
|
||||
/// for it: the command policy may block such a request but never approves it.
|
||||
pub fn widens_access(params: &Value) -> bool {
|
||||
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"));
|
||||
tool.and_then(|t| t.pointer("/rawInput/networkApprovalContext")).is_some_and(|c| !c.is_null())
|
||||
}
|
||||
|
||||
fn command_from_value(val: &Value) -> Option<String> {
|
||||
match val {
|
||||
Value::String(s) => {
|
||||
let trimmed = s.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
||||
}
|
||||
Value::Array(items) => {
|
||||
let words: Vec<&str> = items.iter().filter_map(Value::as_str).collect();
|
||||
if words.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cmd = match words.as_slice() {
|
||||
[_, flag, script] if flag.starts_with('-') && flag.ends_with('c') => (*script).trim().to_owned(),
|
||||
_ => words.join(" ").trim().to_owned(),
|
||||
};
|
||||
(!cmd.is_empty()).then_some(cmd)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether a single pattern matches `command` using prefix + glob rules:
|
||||
/// - If `pattern` contains `*` or `?`, matches using glob wildcards (where `*` matches
|
||||
/// any sequence of characters). A trailing ` *` in the pattern also matches the bare
|
||||
/// command without arguments (e.g. `git status *` matches `git status`).
|
||||
/// - Otherwise, matches when `command` equals `pattern` or starts with `pattern` followed
|
||||
/// by whitespace (prefix match at word boundary).
|
||||
pub fn matches_command(pattern: &str, command: &str) -> bool {
|
||||
let pat = pattern.trim();
|
||||
let cmd = command.trim();
|
||||
if pat.is_empty() || cmd.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if pat.contains('*') || pat.contains('?') {
|
||||
if let Some(stem) = pat.strip_suffix(" *")
|
||||
&& cmd == stem.trim()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
wildcard_match(pat.as_bytes(), cmd.as_bytes())
|
||||
} else {
|
||||
cmd == pat || cmd.strip_prefix(pat).is_some_and(|rest| rest.starts_with(char::is_whitespace))
|
||||
}
|
||||
}
|
||||
|
||||
fn wildcard_match(pat: &[u8], text: &[u8]) -> bool {
|
||||
let (mut p, mut t) = (0, 0);
|
||||
let (mut star_p, mut star_t) = (None, 0);
|
||||
while t < text.len() {
|
||||
if p < pat.len() && (pat[p] == b'?' || pat[p] == text[t]) {
|
||||
p += 1;
|
||||
t += 1;
|
||||
} else if p < pat.len() && pat[p] == b'*' {
|
||||
star_p = Some(p);
|
||||
p += 1;
|
||||
star_t = t;
|
||||
} else if let Some(sp) = star_p {
|
||||
p = sp + 1;
|
||||
star_t += 1;
|
||||
t = star_t;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while p < pat.len() && pat[p] == b'*' {
|
||||
p += 1;
|
||||
}
|
||||
p == pat.len()
|
||||
}
|
||||
|
||||
/// Matches an allowed command entry against a sub-command.
|
||||
/// An allow entry is a whole command or a prefix only up to a word boundary:
|
||||
/// e.g. `git status` matches `git status` and `git status -s`, but NOT `git statusx`.
|
||||
/// Any accidental trailing `*` in the allow entry is stripped so `git status*` behaves
|
||||
/// cleanly as a prefix at word boundaries instead of a glob wildcard.
|
||||
pub fn matches_allow_entry(entry: &str, command: &str) -> bool {
|
||||
let mut entry = entry.trim();
|
||||
if let Some(stripped) = entry.strip_suffix('*') {
|
||||
entry = stripped.trim_end();
|
||||
}
|
||||
let cmd = command.trim();
|
||||
if entry.is_empty() || cmd.is_empty() {
|
||||
return false;
|
||||
}
|
||||
cmd == entry || cmd.strip_prefix(entry).is_some_and(|rest| rest.starts_with(char::is_whitespace))
|
||||
}
|
||||
|
||||
/// Checks whether a word is an environment variable assignment (`IDENTIFIER=...`).
|
||||
fn is_env_assignment_word(word: &str) -> bool {
|
||||
let Some((name, _)) = word.split_once('=') else {
|
||||
return false;
|
||||
};
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut chars = name.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// Returns true if the sub-command starts with an environment variable assignment,
|
||||
/// e.g. `FOO=bar cmd` or `PORT=3000`.
|
||||
fn has_leading_env_assignment(cmd: &str) -> bool {
|
||||
let trimmed = cmd.trim_start();
|
||||
let bytes = trimmed.as_bytes();
|
||||
let mut i = 0;
|
||||
let mut quote: Option<u8> = None;
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
if b == b'\\' && quote != Some(b'\'') && i + 1 < bytes.len() {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if let Some(q) = quote {
|
||||
if b == q {
|
||||
quote = None;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if b == b'\'' || b == b'"' {
|
||||
quote = Some(b);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if b.is_ascii_whitespace() {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let first_word = &trimmed[..i];
|
||||
is_env_assignment_word(first_word)
|
||||
}
|
||||
|
||||
/// Checks whether `command` contains shell constructs that must never be auto-approved:
|
||||
/// - Command substitution: `$(...)` or backticks
|
||||
/// - Redirection: `>`, `>>`, `<`
|
||||
/// - Here-docs: `<<`
|
||||
/// - Subshells: `(...)`
|
||||
/// - Leading environment assignments: `VAR=val cmd`
|
||||
pub fn has_unsafe_shell_syntax(command: &str) -> bool {
|
||||
let cmd = command.trim();
|
||||
if cmd.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if has_leading_env_assignment(cmd) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let bytes = cmd.as_bytes();
|
||||
let mut i = 0;
|
||||
let mut quote: Option<u8> = None;
|
||||
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
|
||||
if b == b'\\' && quote != Some(b'\'') && i + 1 < bytes.len() {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(q) = quote {
|
||||
if b == q {
|
||||
quote = None;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Inside double quotes: $(...) and `...` are evaluated by the shell.
|
||||
if q == b'"' {
|
||||
if b == b'`' {
|
||||
return true;
|
||||
}
|
||||
if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match b {
|
||||
b'\'' | b'"' => {
|
||||
quote = Some(b);
|
||||
i += 1;
|
||||
}
|
||||
b'`' => return true,
|
||||
b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'(' => return true,
|
||||
b'<' | b'>' => return true,
|
||||
b'(' | b')' => return true,
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Splits a shell command into sub-commands separated by `;`, `&&`, `||`, `|`, or `\n`
|
||||
/// (outside single/double quotes) so compound commands cannot bypass policy checks.
|
||||
fn split_subcommands(command: &str) -> Vec<&str> {
|
||||
let bytes = command.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut i = 0;
|
||||
let mut quote: Option<u8> = None;
|
||||
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
if let Some(q) = quote {
|
||||
if b == b'\\' && q == b'"' && i + 1 < bytes.len() {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if b == q {
|
||||
quote = None;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
match b {
|
||||
b'\'' | b'"' => {
|
||||
quote = Some(b);
|
||||
i += 1;
|
||||
}
|
||||
b'\\' if i + 1 < bytes.len() => {
|
||||
i += 2;
|
||||
}
|
||||
b';' | b'\n' => {
|
||||
let seg = command[start..i].trim();
|
||||
if !seg.is_empty() {
|
||||
out.push(seg);
|
||||
}
|
||||
i += 1;
|
||||
start = i;
|
||||
}
|
||||
b'&' | b'|' => {
|
||||
let seg = command[start..i].trim();
|
||||
if !seg.is_empty() {
|
||||
out.push(seg);
|
||||
}
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b {
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
start = i;
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
let tail = command[start..].trim();
|
||||
if !tail.is_empty() {
|
||||
out.push(tail);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether every shell an agent may run `command` in (a POSIX shell, PowerShell, cmd.exe) reads
|
||||
/// it as the one command the policy sees. Outside double quotes no separator or redirection may
|
||||
/// remain after splitting (cmd.exe takes single quotes literally); `\` may only stand in a
|
||||
/// path (it escapes only in POSIX shells); no `^` may escape a quote (cmd.exe); no `%` (cmd.exe
|
||||
/// expands it before parsing); no carriage return; no typographic quotes (PowerShell ends
|
||||
/// strings at them).
|
||||
fn reads_alike_in_every_shell(command: &str) -> bool {
|
||||
let chars: Vec<char> = command.chars().collect();
|
||||
let mut in_double = false;
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
let next = chars.get(i + 1).copied();
|
||||
match c {
|
||||
'"' => in_double = !in_double,
|
||||
'\\' if !next.is_none_or(|n| n.is_ascii_alphanumeric() || matches!(n, '\\' | '.' | '_' | '-')) => {
|
||||
return false;
|
||||
}
|
||||
'^' if matches!(next, Some('"' | '\'' | '\n' | '\r')) => return false,
|
||||
'%' | '\r' | '\u{2018}'..='\u{201f}' => return false,
|
||||
';' | '&' | '|' | '<' | '>' | '\n' if !in_double => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A command as blocked patterns are compared with it: lower case, single spaces, and the
|
||||
/// program without its folder or `.exe` (`/bin/rm` and `RM.EXE` are `rm`).
|
||||
fn normalized_for_blocking(command: &str) -> String {
|
||||
let mut words = command.split_whitespace().map(str::to_lowercase);
|
||||
let Some(program) = words.next() else { return String::new() };
|
||||
let program = program.rsplit(['/', '\\']).next().unwrap_or(&program);
|
||||
let program = program.strip_suffix(".exe").unwrap_or(program);
|
||||
std::iter::once(program.to_owned()).chain(words).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Evaluates `command` against `settings`:
|
||||
/// 1. Blacklist takes precedence: if the full command or any sub-command matches a
|
||||
/// `blocked_commands` entry, returns [`PolicyDecision::Blocked`].
|
||||
/// 2. Whitelist: allowed_commands cannot auto-approve when empty.
|
||||
/// 3. Every sub-command in a chain must be safe (no command substitution, redirection,
|
||||
/// subshell, or env assignments) and match at least one `allowed_commands` entry
|
||||
/// exactly or up to word boundary; otherwise falls back to [`PolicyDecision::AskUser`].
|
||||
pub fn evaluate_command(command: &str, settings: &AgentSecuritySettings) -> PolicyDecision {
|
||||
let cmd = command.trim();
|
||||
if cmd.is_empty() {
|
||||
return PolicyDecision::AskUser;
|
||||
}
|
||||
let subcommands = split_subcommands(cmd);
|
||||
|
||||
let normalized: Vec<String> =
|
||||
std::iter::once(cmd).chain(subcommands.iter().copied()).map(normalized_for_blocking).collect();
|
||||
for pat in &settings.blocked_commands {
|
||||
let pat = pat.trim();
|
||||
if pat.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let normalized_pat = normalized_for_blocking(pat);
|
||||
if matches_command(pat, cmd)
|
||||
|| subcommands.iter().any(|sub| matches_command(pat, sub))
|
||||
|| normalized.iter().any(|c| matches_command(&normalized_pat, c))
|
||||
{
|
||||
return PolicyDecision::Blocked(pat.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
if settings.allowed_commands.is_empty() || subcommands.is_empty() {
|
||||
return PolicyDecision::AskUser;
|
||||
}
|
||||
|
||||
let all_allowed = subcommands.iter().all(|sub| {
|
||||
let s = sub.trim();
|
||||
if s.is_empty() || has_unsafe_shell_syntax(s) || !reads_alike_in_every_shell(s) {
|
||||
return false;
|
||||
}
|
||||
settings.allowed_commands.iter().any(|entry| matches_allow_entry(entry, s))
|
||||
});
|
||||
|
||||
if all_allowed {
|
||||
return PolicyDecision::Allowed;
|
||||
}
|
||||
|
||||
PolicyDecision::AskUser
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn prefix_and_glob_matching() {
|
||||
assert!(matches_command("git status", "git status"));
|
||||
assert!(matches_command("git status", "git status -s"));
|
||||
assert!(!matches_command("git status", "git statusbar"));
|
||||
assert!(matches_command("git status*", "git status"));
|
||||
assert!(matches_command("git status*", "git status --short"));
|
||||
assert!(matches_command("npm test *", "npm test"));
|
||||
assert!(matches_command("npm test *", "npm test unit"));
|
||||
assert!(matches_command("rm -rf *", "rm -rf /tmp/foo"));
|
||||
assert!(matches_command("rm -rf *", "rm -rf /"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blacklist_takes_precedence_and_catches_chained_commands() {
|
||||
let settings = AgentSecuritySettings::default();
|
||||
assert_eq!(evaluate_command("rm -rf /tmp/build", &settings), PolicyDecision::Blocked("rm -rf *".into()));
|
||||
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
|
||||
assert_eq!(evaluate_command(":(){ :|:& };:", &settings), PolicyDecision::Blocked(":(){ :|:& };:".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_entry_matching_word_boundary() {
|
||||
assert!(matches_allow_entry("git status", "git status"));
|
||||
assert!(matches_allow_entry("git status", "git status -s"));
|
||||
assert!(!matches_allow_entry("git status", "git statusbar"));
|
||||
assert!(!matches_allow_entry("git status", "git statusx"));
|
||||
// Trailing asterisk in allow entry normalizes cleanly to word boundary prefix
|
||||
assert!(matches_allow_entry("git status*", "git status"));
|
||||
assert!(matches_allow_entry("git status*", "git status --short"));
|
||||
assert!(!matches_allow_entry("git status*", "git statusbar"));
|
||||
assert!(matches_allow_entry("ls", "ls"));
|
||||
assert!(matches_allow_entry("ls", "ls -la"));
|
||||
assert!(!matches_allow_entry("ls", "lsof"));
|
||||
assert!(matches_allow_entry("cargo check", "cargo check"));
|
||||
assert!(matches_allow_entry("cargo check", "cargo check --workspace"));
|
||||
assert!(!matches_allow_entry("cargo check", "cargo checkmate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_shell_syntax_detection() {
|
||||
// Command substitutions: $(...) and `...`
|
||||
assert!(has_unsafe_shell_syntax("echo $(whoami)"));
|
||||
assert!(has_unsafe_shell_syntax("echo \"user: $(whoami)\""));
|
||||
assert!(has_unsafe_shell_syntax("echo `whoami`"));
|
||||
assert!(has_unsafe_shell_syntax("echo \"`whoami`\""));
|
||||
|
||||
// Redirections: >, >>, <
|
||||
assert!(has_unsafe_shell_syntax("git status > /tmp/out"));
|
||||
assert!(has_unsafe_shell_syntax("git status >> /tmp/out"));
|
||||
assert!(has_unsafe_shell_syntax("cat < /tmp/in"));
|
||||
assert!(has_unsafe_shell_syntax("cat << 'EOF'"));
|
||||
|
||||
// Subshells: (...)
|
||||
assert!(has_unsafe_shell_syntax("(cd /tmp && ls)"));
|
||||
assert!(has_unsafe_shell_syntax("git status && (exit 1)"));
|
||||
|
||||
// Environment assignments: VAR=val ...
|
||||
assert!(has_unsafe_shell_syntax("FOO=bar git status"));
|
||||
assert!(has_unsafe_shell_syntax("PORT=3000 npm test"));
|
||||
assert!(has_unsafe_shell_syntax("_SECRET=123 cargo test"));
|
||||
assert!(has_unsafe_shell_syntax("A=\"b c\" git status"));
|
||||
|
||||
// Safe constructs with characters inside quotes or command flags
|
||||
assert!(!has_unsafe_shell_syntax("git commit -m \"fix <bug> and (issue)\""));
|
||||
assert!(!has_unsafe_shell_syntax("git commit -m 'fix <bug>'"));
|
||||
assert!(!has_unsafe_shell_syntax("cargo test --test=unit"));
|
||||
assert!(!has_unsafe_shell_syntax("git status -s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_settings_has_no_auto_approval_and_blocks_dangerous_defaults() {
|
||||
let settings = AgentSecuritySettings::default();
|
||||
assert!(settings.allowed_commands.is_empty(), "allowed_commands must start empty");
|
||||
// Safe commands ask user by default
|
||||
assert_eq!(evaluate_command("git status", &settings), PolicyDecision::AskUser);
|
||||
assert_eq!(evaluate_command("ls", &settings), PolicyDecision::AskUser);
|
||||
assert_eq!(evaluate_command("cargo check", &settings), PolicyDecision::AskUser);
|
||||
// Blacklist patterns are still blocked by default
|
||||
assert_eq!(evaluate_command("rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
|
||||
assert_eq!(evaluate_command("rm -rf /tmp/test", &settings), PolicyDecision::Blocked("rm -rf *".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitelist_allows_safe_single_and_chained_commands_only() {
|
||||
let settings = AgentSecuritySettings {
|
||||
allowed_commands: vec!["git status".into(), "git diff".into(), "cargo test".into()],
|
||||
blocked_commands: vec!["rm -rf *".into()],
|
||||
};
|
||||
assert_eq!(evaluate_command("git status -s", &settings), PolicyDecision::Allowed);
|
||||
assert_eq!(evaluate_command("git diff HEAD~1", &settings), PolicyDecision::Allowed);
|
||||
assert_eq!(evaluate_command("git status && cargo test", &settings), PolicyDecision::Allowed);
|
||||
|
||||
// Word boundary: git statusx must not match git status
|
||||
assert_eq!(evaluate_command("git statusx", &settings), PolicyDecision::AskUser);
|
||||
|
||||
// Unsafe syntax (redirection, command substitution, env assignment) falls back to AskUser
|
||||
assert_eq!(evaluate_command("git status > out.txt", &settings), PolicyDecision::AskUser);
|
||||
assert_eq!(evaluate_command("git status $(whoami)", &settings), PolicyDecision::AskUser);
|
||||
assert_eq!(evaluate_command("FOO=bar git status", &settings), PolicyDecision::AskUser);
|
||||
|
||||
// Chaining an unapproved command must still ask the user.
|
||||
assert_eq!(evaluate_command("git status && curl https://example.com", &settings), PolicyDecision::AskUser);
|
||||
assert_eq!(evaluate_command("cargo publish", &settings), PolicyDecision::AskUser);
|
||||
|
||||
// Blacklist still takes precedence over whitelist
|
||||
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
|
||||
}
|
||||
|
||||
/// The command may run in PowerShell or cmd.exe as well as a POSIX shell: what any of them
|
||||
/// would split or expand differently from the policy's reading is never auto-approved.
|
||||
#[test]
|
||||
fn what_another_shell_reads_as_two_commands_is_not_auto_approved() {
|
||||
let settings = AgentSecuritySettings {
|
||||
allowed_commands: vec!["git status".into(), "git commit".into()],
|
||||
blocked_commands: vec![],
|
||||
};
|
||||
for cmd in [
|
||||
// `\` escapes only in POSIX shells.
|
||||
"git status \\; calc.exe",
|
||||
"git status \\& calc.exe",
|
||||
"git status \\| calc.exe",
|
||||
"git status \\\\\\; calc.exe",
|
||||
"git status \"\\\" ; calc.exe ; \\\"\"",
|
||||
// cmd.exe: single quotes don't quote, `^` escapes a quote, `%` expands first.
|
||||
"git status '&' calc.exe",
|
||||
"git status ';' calc.exe",
|
||||
"git status '>' out.txt",
|
||||
"git status ^\" & calc.exe ^\"",
|
||||
"git status %COMSPEC%",
|
||||
// PowerShell also closes a string at a typographic quote.
|
||||
"git status \"a \u{201d} ; calc.exe ; \u{201c} b\"",
|
||||
"git status \u{2018}x\u{2019}",
|
||||
// A carriage return ends the command line too.
|
||||
"git status\r calc.exe",
|
||||
] {
|
||||
assert_eq!(evaluate_command(cmd, &settings), PolicyDecision::AskUser, "{cmd:?}");
|
||||
}
|
||||
// Text every shell reads alike stays approvable.
|
||||
for cmd in [
|
||||
"git status -s",
|
||||
"git status && git status",
|
||||
"git commit -m \"fix <bug> & (issue); done\"",
|
||||
"git status src\\main.rs C:\\work\\repo",
|
||||
"git status HEAD^",
|
||||
] {
|
||||
assert_eq!(evaluate_command(cmd, &settings), PolicyDecision::Allowed, "{cmd:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_patterns_ignore_case_spacing_and_the_programs_folder() {
|
||||
let settings = AgentSecuritySettings::default();
|
||||
for cmd in
|
||||
["rm -rf /", "RM -RF /", "/bin/rm -rf /", "Rm -Rf /tmp/x", "C:\\Windows\\System32\\shutdown.exe /s"]
|
||||
{
|
||||
assert!(matches!(evaluate_command(cmd, &settings), PolicyDecision::Blocked(_)), "{cmd:?}");
|
||||
}
|
||||
assert_eq!(evaluate_command("git status", &settings), PolicyDecision::AskUser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_command_from_v1_v2_and_codex_payloads() {
|
||||
let claude = json!({
|
||||
"toolCall": {
|
||||
"title": "git status",
|
||||
"kind": "execute",
|
||||
"rawInput": { "command": "git status --short" }
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_command(&claude).as_deref(), Some("git status --short"));
|
||||
|
||||
let codex = json!({
|
||||
"toolCall": {
|
||||
"title": "Run cargo test",
|
||||
"kind": "execute",
|
||||
"rawInput": { "command": ["/bin/zsh", "-lc", "cargo test --workspace"] }
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_command(&codex).as_deref(), Some("cargo test --workspace"));
|
||||
|
||||
let write = json!({
|
||||
"toolCall": {
|
||||
"title": "Write foo.txt",
|
||||
"kind": "edit",
|
||||
"rawInput": { "file_path": "foo.txt" }
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_command(&write), None);
|
||||
}
|
||||
|
||||
/// Only a tool call of kind `execute` runs a command, and its command is in `rawInput`:
|
||||
/// a title says what the agent wants it to say.
|
||||
#[test]
|
||||
fn a_request_is_a_command_only_by_its_kind_and_input() {
|
||||
let escalation = json!({ "sessionId": "t", "toolCall": {
|
||||
"toolCallId": "i1", "title": "Run git status", "kind": "other", "status": "pending",
|
||||
"rawInput": { "fileSystem": { "write": ["C:\\"] }, "network": { "enabled": true } } },
|
||||
"options": [ { "optionId": "turn", "name": "Allow for this turn", "kind": "allow_once" },
|
||||
{ "optionId": "decline", "name": "Decline", "kind": "reject_once" } ] });
|
||||
assert_eq!(extract_command(&escalation), None);
|
||||
let titled_only = json!({ "toolCall": { "title": "Run git status", "kind": "execute" } });
|
||||
assert_eq!(extract_command(&titled_only), None);
|
||||
let named_only = json!({ "toolCall": { "title": "git status", "name": "Bash" } });
|
||||
assert_eq!(extract_command(&named_only), None);
|
||||
let mislabelled = json!({ "toolCall": { "title": "Run git status", "kind": "edit",
|
||||
"rawInput": { "command": "git status" } } });
|
||||
assert_eq!(extract_command(&mislabelled), None);
|
||||
let network = json!({ "toolCall": { "title": "Allow network access to example.com", "kind": "execute",
|
||||
"rawInput": { "command": "curl https://example.com", "networkApprovalContext": { "host": "example.com" } } } });
|
||||
assert_eq!(extract_command(&network).as_deref(), Some("curl https://example.com"));
|
||||
assert!(widens_access(&network));
|
||||
let plain = json!({ "toolCall": { "title": "Run git status", "kind": "execute",
|
||||
"rawInput": { "command": "git status" } } });
|
||||
assert!(!widens_access(&plain));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@ struct FakeAgent {
|
||||
/// Hold the late mode reply until the prompt's permission request is already waiting.
|
||||
hold_late_mode: bool,
|
||||
empty_config: AtomicBool,
|
||||
/// Blocks `session/new` until discovery times out (see `acp::discover` test timeout).
|
||||
hold_session_new: AtomicBool,
|
||||
/// Sleeps this long at the start of `session/set_config_option`, after noting the time.
|
||||
delay_config: Duration,
|
||||
config_entered: Mutex<Option<std::time::Instant>>,
|
||||
/// Config options `session/new` reports after the model one (per-model efforts, say).
|
||||
extra_options: Mutex<Vec<Value>>,
|
||||
model_options: Mutex<HashMap<String, Vec<Value>>>,
|
||||
@@ -157,6 +162,9 @@ impl Handler for FakeAgent {
|
||||
)
|
||||
}
|
||||
"session/new" => {
|
||||
if self.hold_session_new.load(Ordering::Acquire) {
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
}
|
||||
if self.empty_config.load(Ordering::Acquire) {
|
||||
return Ok(json!({"sessionId": "s1"}));
|
||||
}
|
||||
@@ -190,6 +198,10 @@ impl Handler for FakeAgent {
|
||||
}
|
||||
}
|
||||
"session/set_config_option" | "session/set_mode" => {
|
||||
if method == "session/set_config_option" && !self.delay_config.is_zero() {
|
||||
*self.config_entered.lock() = Some(std::time::Instant::now());
|
||||
std::thread::sleep(self.delay_config);
|
||||
}
|
||||
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
|
||||
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
|
||||
return Ok(json!({"configOptions": self.options_for(params["value"].as_str().unwrap())}));
|
||||
@@ -252,6 +264,23 @@ impl Handler for FakeAgent {
|
||||
self.say(&session, &format!("late chose {chosen}"));
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "escalate" {
|
||||
// A request for wider access, titled with text the model wrote.
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"session/request_permission",
|
||||
json!({"sessionId": session, "toolCall": {"toolCallId": "e1", "title": "Run cargo test",
|
||||
"kind": "other", "rawInput": {"network": {"enabled": true}}},
|
||||
"options": [{"optionId": "yes", "name": "Allow for this turn", "kind": "allow_once"},
|
||||
{"optionId": "no", "name": "Decline", "kind": "reject_once"}]}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
|
||||
self.say(&session, &format!("escalation chose {chosen}"));
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "ask" {
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
@@ -332,7 +361,8 @@ impl Handler for FakeAgent {
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"session/request_permission",
|
||||
json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test"},
|
||||
json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test",
|
||||
"kind": "execute", "rawInput": {"command": "cargo test"}},
|
||||
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
|
||||
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
|
||||
None,
|
||||
@@ -354,7 +384,8 @@ fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
// These tests speak to FakeAgent, never an installed CLI, including after a provider reload.
|
||||
let agents = dir.join("agents");
|
||||
provider::set_builtin_command(&agents, "claude", Some(&dir.join("missing-claude").to_string_lossy())).unwrap();
|
||||
provider::set_builtin_command(&agents, None, "claude", Some(&dir.join("missing-claude").to_string_lossy()))
|
||||
.unwrap();
|
||||
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
|
||||
let (a, b) = duplex(1 << 20);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
@@ -373,6 +404,7 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
|
||||
// The project is the boundary of the skill walk: nothing above it (such as the
|
||||
// user's profile, when temp lives under it) leaks into the test.
|
||||
home_dir: Some(dir.to_path_buf()),
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
@@ -480,7 +512,7 @@ for line in sys.stdin:
|
||||
}
|
||||
let concurrent = async {
|
||||
if outcome == "session_report" {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !root.join("initialized").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
@@ -552,7 +584,7 @@ for line in sys.stdin:
|
||||
};
|
||||
assert_eq!(subtypes, expected, "{outcome}");
|
||||
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
@@ -1398,6 +1430,7 @@ async fn real_claude_task_in_a_worktree() {
|
||||
models_file: tmp.path().join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
);
|
||||
@@ -2231,6 +2264,7 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
|
||||
models_file: tmp.path().join("models.json"),
|
||||
custom_agents_dir: Some(agents_dir.clone()),
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
@@ -2541,6 +2575,7 @@ async fn delete_running_agent(no_load: bool) {
|
||||
models_file: root.join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
@@ -2631,6 +2666,92 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn provider_reload_does_not_start_acp_discovery() {
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
|
||||
mgr.reload_providers();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(agent.calls.lock().is_empty(), "reload must not spawn cursor-agent");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn composer_commands_discover_cursor_effort_and_fast() {
|
||||
let agent = Arc::new(FakeAgent {
|
||||
extra_options: Mutex::new(vec![
|
||||
json!({"id": "effort", "type": "select", "currentValue": "medium",
|
||||
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]}),
|
||||
json!({"id": "fast", "type": "select", "currentValue": "false",
|
||||
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}),
|
||||
]),
|
||||
..Default::default()
|
||||
});
|
||||
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
|
||||
mgr.commands("cursor", None).await.unwrap();
|
||||
let calls = agent.calls.lock();
|
||||
assert!(calls.iter().any(|m| m.as_str() == "session/new"));
|
||||
assert_eq!(calls.iter().filter(|m| m.as_str() == "initialize").count(), 1);
|
||||
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
|
||||
assert_eq!(cursor.config.iter().find(|o| o.id == "fast").unwrap().current.as_deref(), Some("false"));
|
||||
assert!(
|
||||
cursor
|
||||
.thought_levels
|
||||
.values()
|
||||
.next()
|
||||
.is_some_and(|levels| { levels.iter().any(|o| o.category.as_deref() == Some("thought_level")) })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn config_choice_shows_before_the_agent_answers() {
|
||||
let agent = Arc::new(FakeAgent {
|
||||
extra_options: Mutex::new(vec![json!({
|
||||
"id": "fast", "category": "model_config", "type": "select", "currentValue": "true",
|
||||
"options": [{"value": "true", "name": "On"}, {"value": "false", "name": "Off"}]
|
||||
})]),
|
||||
delay_config: Duration::from_millis(300),
|
||||
..Default::default()
|
||||
});
|
||||
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
|
||||
let shown: Arc<Mutex<Option<std::time::Instant>>> = Arc::default();
|
||||
let mark = shown.clone();
|
||||
mgr.subscribe(Arc::new(move |e| {
|
||||
if let AgentEvent::Task { task } = e
|
||||
&& task.config.iter().any(|o| o.id == "fast" && o.current.as_deref() == Some("false"))
|
||||
&& mark.lock().is_none()
|
||||
{
|
||||
*mark.lock() = Some(std::time::Instant::now());
|
||||
}
|
||||
}));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.set_config(&task.id, "fast", "false".into()).await.unwrap();
|
||||
let shown = (*shown.lock()).expect("the switch moved");
|
||||
let entered = (*agent.config_entered.lock()).expect("the agent received the change");
|
||||
assert!(shown < entered, "the composer updates before the agent answers");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn discovery_times_out_when_session_new_hangs() {
|
||||
let agent = Arc::new(FakeAgent { hold_session_new: AtomicBool::new(true), ..Default::default() });
|
||||
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
|
||||
let started = std::time::Instant::now();
|
||||
mgr.commands("cursor", None).await.unwrap();
|
||||
assert!(started.elapsed() < Duration::from_secs(10), "discovery should time out quickly in tests");
|
||||
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
|
||||
assert!(!cursor.config.iter().any(|o| o.category.as_deref() == Some("model")));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -2755,14 +2876,14 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
|
||||
}
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt", "other.txt"]);
|
||||
|
||||
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()])).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()]), AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(read("other.txt"), "base\n");
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt"]);
|
||||
|
||||
// After a restart, from the saved copy.
|
||||
mgr.shutdown();
|
||||
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
|
||||
assert!(!dir.join("new.txt").exists());
|
||||
assert!(changed(&mgr, &task.id).await.is_empty());
|
||||
@@ -2770,3 +2891,362 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
|
||||
mgr.delete(&task.id, false).await.unwrap();
|
||||
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
|
||||
}
|
||||
|
||||
/// A git repository in a temporary folder, for local tasks whose checkout moves under them.
|
||||
struct Checkout {
|
||||
_tmp: tempfile::TempDir,
|
||||
dir: PathBuf,
|
||||
eol: Eol,
|
||||
}
|
||||
|
||||
/// How the checkout keeps line endings: as committed, or CRLF on disk with `core.autocrlf`
|
||||
/// (Git for Windows' default), where the files' bytes never equal the committed blobs.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Eol {
|
||||
Lf,
|
||||
Crlf,
|
||||
}
|
||||
|
||||
const EOLS: [Eol; 2] = [Eol::Lf, Eol::Crlf];
|
||||
|
||||
impl Checkout {
|
||||
fn new(eol: Eol) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let checkout = Self { _tmp: tmp, dir, eol };
|
||||
checkout.git(&["init", "-q", "-b", "main"]);
|
||||
let autocrlf = match eol {
|
||||
Eol::Lf => "false",
|
||||
Eol::Crlf => "true",
|
||||
};
|
||||
checkout.git(&["config", "core.autocrlf", autocrlf]);
|
||||
checkout.write(".gitignore", "store/\nworktrees/\nagents/\nmodels.json\n");
|
||||
checkout
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) {
|
||||
let ok = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(&self.dir)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success();
|
||||
assert!(ok, "{args:?}");
|
||||
}
|
||||
|
||||
fn commit(&self, message: &str) {
|
||||
self.git(&["add", "."]);
|
||||
self.git(&["commit", "-q", "-m", message]);
|
||||
}
|
||||
|
||||
fn write(&self, rel: &str, text: &str) {
|
||||
let text = match self.eol {
|
||||
Eol::Lf => text.to_owned(),
|
||||
Eol::Crlf => text.replace('\n', "\r\n"),
|
||||
};
|
||||
std::fs::write(self.dir.join(rel), text).unwrap();
|
||||
}
|
||||
|
||||
/// The file's text with LF line endings (a rollback writes the committed bytes back).
|
||||
fn read(&self, rel: &str) -> String {
|
||||
std::fs::read_to_string(self.dir.join(rel)).unwrap().replace("\r\n", "\n")
|
||||
}
|
||||
|
||||
/// A local task, stopped so it can be rolled back.
|
||||
async fn task(&self) -> (AgentManager, AgentTask) {
|
||||
let mgr = manager(&self.dir, Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Hello"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
(mgr, task)
|
||||
}
|
||||
}
|
||||
|
||||
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
|
||||
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("a.txt", "base\n");
|
||||
repo.write("mine.txt", "base\n");
|
||||
repo.commit("init");
|
||||
repo.write("mine.txt", "my work\n");
|
||||
repo.commit("my work");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("a.txt", "agent\n");
|
||||
// The user takes the last commit back into the working tree (`git reset --soft`, an
|
||||
// undone commit): its changes are the user's, not the agent's.
|
||||
repo.git(&["reset", "-q", "--soft", "HEAD~1"]);
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
|
||||
|
||||
let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap();
|
||||
assert_eq!(base.map(|b| b.replace("\r\n", "\n")).as_deref(), Some("base\n"), "{eol:?}");
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
|
||||
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}: the user's work survives the rollback");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn files_a_mixed_reset_during_a_local_task_leaves_behind_are_not_rolled_back() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("a.txt", "base\n");
|
||||
repo.write("mine.txt", "base\n");
|
||||
repo.commit("init");
|
||||
repo.write("mine.txt", "my work\n");
|
||||
repo.write("mine-new.txt", "my new file\n");
|
||||
repo.commit("my work");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("a.txt", "agent\n");
|
||||
// `git reset HEAD~1`: the commit's new file is untracked now, its edit unstaged.
|
||||
repo.git(&["reset", "-q", "HEAD~1"]);
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
|
||||
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}");
|
||||
assert_eq!(repo.read("mine-new.txt"), "my new file\n", "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("a.txt", "base\n");
|
||||
repo.write("other.txt", "base\n");
|
||||
repo.commit("init");
|
||||
repo.git(&["checkout", "-q", "-b", "feature"]);
|
||||
repo.write("other.txt", "feature\n");
|
||||
repo.write("only-feature.txt", "feature\n");
|
||||
repo.commit("feature");
|
||||
repo.git(&["checkout", "-q", "main"]);
|
||||
repo.write("only-main.txt", "main\n");
|
||||
repo.commit("main");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("a.txt", "agent\n");
|
||||
// The agent's uncommitted edit comes along to a branch that doesn't contain the task's start.
|
||||
repo.git(&["checkout", "-q", "feature"]);
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
|
||||
assert_eq!(repo.read("other.txt"), "feature\n", "{eol:?}");
|
||||
assert_eq!(repo.read("only-feature.txt"), "feature\n", "{eol:?}");
|
||||
assert!(!repo.dir.join("only-main.txt").exists(), "{eol:?}");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("a.txt", "base\n");
|
||||
repo.commit("init");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("a.txt", "agent\n");
|
||||
repo.write("b.txt", "agent\n");
|
||||
// The user commits what the agent did to `a.txt` so far: it drops out of the task's changes.
|
||||
repo.git(&["add", "a.txt"]);
|
||||
repo.git(&["commit", "-q", "-m", "keep"]);
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["b.txt"], "{eol:?}");
|
||||
repo.write("a.txt", "agent\nagain\n");
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to before it");
|
||||
assert!(!repo.dir.join("b.txt").exists(), "{eol:?}");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rolling_back_a_rename_brings_the_old_file_back() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("Old.java", "class Old {}\n");
|
||||
repo.write("Case.java", "class Case {}\n");
|
||||
repo.commit("init");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.git(&["mv", "Old.java", "New.java"]);
|
||||
repo.git(&["mv", "Case.java", "CASE.java"]);
|
||||
// Renames list their new path only.
|
||||
let listed = changed(&mgr, &task.id).await;
|
||||
assert_eq!(listed, ["CASE.java", "New.java"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, Some(listed), AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(repo.read("Old.java"), "class Old {}\n", "{eol:?}");
|
||||
assert!(!repo.dir.join("New.java").exists(), "{eol:?}");
|
||||
assert_eq!(repo.read("Case.java"), "class Case {}\n", "{eol:?}");
|
||||
let names: Vec<String> = std::fs::read_dir(&repo.dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.ends_with(".java"))
|
||||
.collect();
|
||||
assert!(!names.contains(&"CASE.java".to_owned()), "{eol:?}: {names:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_group_view_rolls_back_only_uncommitted_files() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("a.txt", "base\n");
|
||||
repo.commit("init");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("a.txt", "agent\n");
|
||||
repo.commit("agent work");
|
||||
repo.write("a.txt", "agent\nagain\n");
|
||||
|
||||
let grouped = mgr.grouped_changes(&task.id).await.unwrap();
|
||||
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"], "{eol:?}");
|
||||
assert!(grouped.commits.is_empty(), "{eol:?}: local tasks have no commits to group");
|
||||
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::Group).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to the start");
|
||||
assert!(mgr.grouped_changes(&task.id).await.unwrap().uncommitted.is_empty(), "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("mine.txt", "mine\n");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("new.txt", "agent\n");
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["new.txt"], "{eol:?}");
|
||||
assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None, "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert!(!repo.dir.join("new.txt").exists(), "{eol:?}");
|
||||
assert_eq!(repo.read("mine.txt"), "mine\n", "{eol:?}");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
|
||||
use ide_core::settings::AgentSecuritySettings;
|
||||
|
||||
// 1. Whitelist auto-approves "cargo test" without waiting for manual respond().
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent);
|
||||
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
|
||||
allowed_commands: vec!["cargo test".into()],
|
||||
blocked_commands: vec!["rm -rf *".into()],
|
||||
}));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("run tests"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(
|
||||
entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text.contains("chose yes"))),
|
||||
"{entries:?}"
|
||||
);
|
||||
assert!(
|
||||
entries.iter().any(|e| matches!(
|
||||
e,
|
||||
AgentEntry::Permission { chosen: Some(c), .. } if c.contains("whitelisted")
|
||||
)),
|
||||
"{entries:?}"
|
||||
);
|
||||
|
||||
// 2. Blacklist auto-rejects "cargo test" when matched by blocked_commands.
|
||||
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
|
||||
allowed_commands: vec!["cargo test".into()],
|
||||
blocked_commands: vec!["cargo test*".into()],
|
||||
}));
|
||||
let blocked_task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("run blocked"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &blocked_task.id, AgentStatus::Ready).await;
|
||||
let blocked_entries = mgr.entries(&blocked_task.id, None).unwrap();
|
||||
assert!(
|
||||
blocked_entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text.contains("chose no"))),
|
||||
"{blocked_entries:?}"
|
||||
);
|
||||
assert!(
|
||||
blocked_entries.iter().any(|e| matches!(
|
||||
e,
|
||||
AgentEntry::Permission { chosen: Some(c), .. } if c.contains("Blocked by policy")
|
||||
)),
|
||||
"{blocked_entries:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn security_policy_never_auto_approves_a_request_that_only_reads_like_a_command() {
|
||||
use ide_core::settings::AgentSecuritySettings;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent);
|
||||
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
|
||||
allowed_commands: vec!["cargo test".into()],
|
||||
blocked_commands: vec![],
|
||||
}));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("escalate"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
let pending = entries
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, chosen: None, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("the request was answered without the user: {entries:?}"));
|
||||
mgr.respond(&task.id, pending, Some("no".into())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(
|
||||
entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == "escalation chose no")),
|
||||
"{entries:?}"
|
||||
);
|
||||
}
|
||||
@@ -438,6 +438,7 @@ mod tests {
|
||||
t.push(AgentEntry::Permission {
|
||||
id: 1,
|
||||
title: "Run?".into(),
|
||||
command: None,
|
||||
options: vec![],
|
||||
questions: vec![],
|
||||
chosen: None,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Task worktrees and the changes made in them.
|
||||
|
||||
use crate::model::AgentChange;
|
||||
use crate::model::{AgentChange, AgentChanges, AgentCommit};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_vcs::{ChangeKind, Repo};
|
||||
use ide_vcs::{ChangeKind, LogQuery, Repo};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
@@ -14,8 +14,18 @@ pub(crate) struct Created {
|
||||
pub base_commit: String,
|
||||
}
|
||||
|
||||
/// The repository of `path`, free to execute: the host runs every agent command that
|
||||
/// reaches a repository only in a trusted project.
|
||||
fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
let repo = Repo::discover(path)?;
|
||||
if let Some(repo) = &repo {
|
||||
repo.set_execution_allowed(true);
|
||||
}
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
fn repo_at(path: &Path) -> IdeResult<Repo> {
|
||||
Repo::discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display())))
|
||||
discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display())))
|
||||
}
|
||||
|
||||
/// The current branch (or commit when detached) and its commit.
|
||||
@@ -25,6 +35,14 @@ pub(crate) fn head_of(path: &Path) -> IdeResult<(String, String)> {
|
||||
Ok((info.head.unwrap_or_else(|| commit.clone()), commit))
|
||||
}
|
||||
|
||||
/// `HEAD`'s commit in `cwd`, `None` outside a repository or before its first commit.
|
||||
pub(crate) fn head_commit(cwd: &Path) -> IdeResult<Option<String>> {
|
||||
match discover(cwd)? {
|
||||
Some(repo) => Ok(repo.head()?.1),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// `git worktree add -b agent/<slug> <dir>/<slug> <base>`.
|
||||
pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, base: Option<&str>) -> IdeResult<Created> {
|
||||
let repo = repo_at(project_root)?;
|
||||
@@ -91,28 +109,428 @@ fn retrying(attempts: u32, pause: Duration, mut op: impl FnMut() -> std::io::Res
|
||||
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
|
||||
/// Outside a repository there is nothing to compare against: no changes.
|
||||
pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
let Some(repo) = Repo::discover(cwd)? else { return Ok(Vec::new()) };
|
||||
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
|
||||
let root = repo.root().to_path_buf();
|
||||
let stats = repo.numstat_with_worktree(base)?;
|
||||
let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r));
|
||||
let stats: std::collections::HashMap<String, (u32, u32)> =
|
||||
repo.numstat_with_worktree(base)?.into_iter().map(|(p, a, r)| (p, (a, r))).collect();
|
||||
let stat = |rel: &str| stats.get(rel).copied().unwrap_or((0, 0));
|
||||
let mut out: Vec<AgentChange> = repo
|
||||
.diff_with_worktree(base)?
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let (added, removed) = stat(&f.path);
|
||||
AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed }
|
||||
AgentChange {
|
||||
path: IpcPath(root.join(&f.path)),
|
||||
rel_path: f.path,
|
||||
kind: f.kind,
|
||||
added,
|
||||
removed,
|
||||
old_path: f.old_path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
|
||||
for s in repo.status()? {
|
||||
if s.unstaged == Some(ChangeKind::Untracked) && !out.iter().any(|c| c.rel_path == s.rel_path) {
|
||||
let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32);
|
||||
out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 });
|
||||
if s.unstaged == Some(ChangeKind::Untracked) && !tracked.contains(&s.rel_path) {
|
||||
out.push(added(s.path, s.rel_path));
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Files renamed since `rev`, by their new repository-relative path: the old path, as a deletion
|
||||
/// to roll back along with the rename.
|
||||
pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String, AgentChange>> {
|
||||
let Some(repo) = discover(cwd)? else { return Ok(BTreeMap::new()) };
|
||||
let root = repo.root().to_path_buf();
|
||||
Ok(repo
|
||||
.diff_with_worktree(rev)?
|
||||
.into_iter()
|
||||
.filter_map(|f| {
|
||||
let old = f.old_path.filter(|old| *old != f.path)?;
|
||||
let source = AgentChange {
|
||||
path: IpcPath(root.join(&old)),
|
||||
rel_path: old,
|
||||
kind: ChangeKind::Deleted,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
old_path: None,
|
||||
};
|
||||
Some((f.path, source))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn added(path: IpcPath, rel_path: String) -> AgentChange {
|
||||
let added = std::fs::read_to_string(&*path).map_or(0, |t| t.lines().count() as u32);
|
||||
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0, old_path: None }
|
||||
}
|
||||
|
||||
/// The commits from `base` (exclusive) to `head` (inclusive) in `cwd`, newest first, each
|
||||
/// with the files it changed against its first parent and their line counts.
|
||||
pub(crate) fn commits(cwd: &Path, base: &str, head: &str) -> IdeResult<Vec<AgentCommit>> {
|
||||
if base == head {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
|
||||
let root = repo.root().to_path_buf();
|
||||
let query = LogQuery { range: Some(format!("{base}..{head}")), ..LogQuery::default() };
|
||||
let mut out = Vec::new();
|
||||
for commit in repo.log(&query)? {
|
||||
let parent = commit.parents.first().cloned();
|
||||
// A root commit has no parent to count lines against; its files still show.
|
||||
let stats: std::collections::HashMap<String, (u32, u32)> = match &parent {
|
||||
Some(parent) => repo
|
||||
.diff_numstat(parent, &commit.id, &[])?
|
||||
.into_iter()
|
||||
.map(|s| (s.path, (s.added, s.removed)))
|
||||
.collect(),
|
||||
None => std::collections::HashMap::new(),
|
||||
};
|
||||
let mut files: Vec<AgentChange> = repo
|
||||
.commit_files(&commit.id)?
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let (added, removed) = stats.get(&f.path).copied().unwrap_or((0, 0));
|
||||
AgentChange {
|
||||
path: IpcPath(root.join(&f.path)),
|
||||
rel_path: f.path,
|
||||
kind: f.kind,
|
||||
added,
|
||||
removed,
|
||||
old_path: f.old_path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
out.push(AgentCommit {
|
||||
id: commit.id,
|
||||
short_id: commit.short_id,
|
||||
summary: commit.summary,
|
||||
message: commit.message,
|
||||
time_ms: commit.time_ms,
|
||||
parent,
|
||||
files,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether two paths name one file (a case-insensitive disk resolving both spellings).
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of `path` (`Some(None)`: no file there), or `None` when it is there but can't be read.
|
||||
fn existing(path: &Path) -> Option<Option<Vec<u8>>> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => Some(Some(bytes)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(None),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
if !(b == b'\r' && bytes.get(i + 1) == Some(&b'\n')) {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a task works and what its changes are measured against.
|
||||
pub(crate) enum TaskBase {
|
||||
/// A worktree of its own: everything since the commit it was created from, the agent's
|
||||
/// own commits included.
|
||||
Worktree { cwd: PathBuf, base: String },
|
||||
/// The project's checkout, where the user works and commits too. `start` is the commit it
|
||||
/// started from (`None` in a repository without commits, or an imported session).
|
||||
Local { cwd: PathBuf, start: Option<String> },
|
||||
}
|
||||
|
||||
impl TaskBase {
|
||||
pub(crate) fn cwd(&self) -> &Path {
|
||||
match self {
|
||||
Self::Worktree { cwd, .. } | Self::Local { cwd, .. } => cwd,
|
||||
}
|
||||
}
|
||||
|
||||
/// The files the task changed, less those changed before it started and untouched since.
|
||||
pub(crate) fn changes(&self, before: &BTreeMap<String, Stamp>) -> IdeResult<Vec<AgentChange>> {
|
||||
let (cwd, start) = match self {
|
||||
Self::Worktree { cwd, base } => return Ok(without_untouched(changes(cwd, base)?, before)),
|
||||
Self::Local { cwd, start } => (cwd, start.as_deref()),
|
||||
};
|
||||
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
|
||||
// What is uncommitted, as the Commit tool window lists it: commits made since the task
|
||||
// started drop out.
|
||||
let head = repo.branch_info()?.head_commit;
|
||||
let listed = match &head {
|
||||
Some(head) => changes(cwd, head)?,
|
||||
None => {
|
||||
let mut all: Vec<AgentChange> = repo
|
||||
.status()?
|
||||
.into_iter()
|
||||
.filter(|s| s.path.is_file())
|
||||
.map(|s| added(s.path, s.rel_path))
|
||||
.collect();
|
||||
all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
all
|
||||
}
|
||||
};
|
||||
let listed = without_untouched(listed, before);
|
||||
let Some(start) = start.filter(|s| head.as_deref() != Some(*s)) else { return Ok(listed) };
|
||||
// HEAD moved (a reset, a checkout, a pull): a file that is as it was when the task
|
||||
// started is the user's doing, not the task's. Git compares tracked files, with its
|
||||
// line-ending and other filters: the bytes on disk and in the commit differ under
|
||||
// `core.autocrlf`.
|
||||
let since_start: std::collections::HashSet<String> = repo
|
||||
.diff_with_worktree(start)?
|
||||
.into_iter()
|
||||
.flat_map(|f| [Some(f.path), f.old_path].into_iter().flatten())
|
||||
.collect();
|
||||
let untracked: std::collections::HashSet<String> = repo
|
||||
.status()?
|
||||
.into_iter()
|
||||
.filter(|s| s.unstaged == Some(ChangeKind::Untracked))
|
||||
.map(|s| s.rel_path)
|
||||
.collect();
|
||||
Ok(listed
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
if before.contains_key(&c.rel_path) {
|
||||
return true;
|
||||
}
|
||||
let Ok(Some(at_start)) = repo.file_bytes_at(&c.path.0, Some(start)) else { return true };
|
||||
if !untracked.contains(&c.rel_path) {
|
||||
return since_start.contains(&c.rel_path);
|
||||
}
|
||||
// Untracked since (a mixed reset): git doesn't diff it, so compare the bytes,
|
||||
// CRLF on disk matching LF committed.
|
||||
!std::fs::read(&c.path.0).is_ok_and(|now| now == at_start || crlf_to_lf(&now) == at_start)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The task's uncommitted changes and, for a worktree task, the commits made since it
|
||||
/// started (newest first). Both read the same `HEAD`, so a commit landing between the two
|
||||
/// reads cannot put a file in both lists.
|
||||
pub(crate) fn grouped_changes(&self, before: &BTreeMap<String, Stamp>) -> IdeResult<AgentChanges> {
|
||||
let cwd = self.cwd();
|
||||
let Some(repo) = discover(cwd)? else { return Ok(AgentChanges::default()) };
|
||||
let head = repo.head()?.1;
|
||||
let uncommitted = match &head {
|
||||
Some(head) => without_untouched(changes(cwd, head)?, before),
|
||||
None => {
|
||||
// No commits yet: every file is new, as in `changes` for a local task.
|
||||
let mut all: Vec<AgentChange> = repo
|
||||
.status()?
|
||||
.into_iter()
|
||||
.filter(|s| s.path.is_file())
|
||||
.map(|s| added(s.path, s.rel_path))
|
||||
.collect();
|
||||
all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
without_untouched(all, before)
|
||||
}
|
||||
};
|
||||
let commits = match (self, &head) {
|
||||
(Self::Worktree { cwd, base }, Some(head)) => commits(cwd, base, head)?,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(AgentChanges { uncommitted, commits })
|
||||
}
|
||||
|
||||
/// The revision the task's changed files go back to on rollback, and are compared with:
|
||||
/// the commit the task started from, or the current `HEAD` once the user committed on top
|
||||
/// of it, so those commits stay. `None` while the repository has no commits.
|
||||
pub(crate) fn rev(&self) -> IdeResult<Option<String>> {
|
||||
let (cwd, start) = match self {
|
||||
Self::Worktree { base, .. } => return Ok(Some(base.clone())),
|
||||
Self::Local { cwd, start } => (cwd, start.as_deref()),
|
||||
};
|
||||
let Some(repo) = discover(cwd)? else { return Ok(None) };
|
||||
let head = repo.branch_info()?.head_commit;
|
||||
// A start commit git no longer knows (garbage-collected) can't be gone back to.
|
||||
let start = start.filter(|s| repo.merge_base(s, s).ok().flatten().is_some());
|
||||
Ok(match (start, head) {
|
||||
(Some(start), Some(head)) if start != head && repo.merge_base(start, &head)?.as_deref() != Some(start) => {
|
||||
Some(start.to_owned())
|
||||
}
|
||||
(start, head) => head.or_else(|| start.map(str::to_owned)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One side of an Accept (the task's worktree or the checkout): where its files really are, and
|
||||
/// git's own folders for it.
|
||||
struct Confined {
|
||||
root: PathBuf,
|
||||
metadata: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Confined {
|
||||
fn new(root: &Path, repo: Option<&Repo>) -> IdeResult<Self> {
|
||||
let metadata = match repo {
|
||||
Some(repo) => repo.metadata_dirs()?.iter().map(|d| ide_core::real_path::resolve_real(d)).collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
Ok(Self { root: dunce::canonicalize(root)?, metadata })
|
||||
}
|
||||
|
||||
/// Whether `rel` may be read or written here: it doesn't name git's metadata under any
|
||||
/// spelling (case, trailing dots or spaces, stream suffixes, short names), stays in the tree
|
||||
/// once links are followed, and isn't a link itself.
|
||||
fn allows(&self, rel: &str) -> bool {
|
||||
use ide_core::real_path::{names_git_dir, reaches_git_dir, resolve_real};
|
||||
let path = self.root.join(rel);
|
||||
if names_git_dir(Path::new(rel)) || path.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) {
|
||||
return false;
|
||||
}
|
||||
let real = resolve_real(&path);
|
||||
real.starts_with(&self.root)
|
||||
&& !reaches_git_dir(&self.root, &real)
|
||||
&& !self.metadata.iter().any(|dir| real.starts_with(dir))
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the task's `changes` (against `base`) from the worktree `cwd` into the
|
||||
/// local checkout `root`, uncommitted: the user reviews and commits them with the
|
||||
/// VCS tools. Files the checkout changed itself are left alone and reported, so
|
||||
/// accepting never destroys the user's own edits. Returns accepted and skipped paths.
|
||||
pub(crate) fn accept(
|
||||
root: &Path,
|
||||
cwd: &Path,
|
||||
base: &str,
|
||||
changes: &[AgentChange],
|
||||
) -> IdeResult<crate::AgentAcceptOutcome> {
|
||||
let repo = repo_at(cwd)?;
|
||||
// Change paths are repository-relative, and the task's project may be a folder inside it.
|
||||
let local = discover(root)?;
|
||||
let local_root = local.as_ref().map_or_else(|| root.to_path_buf(), |r| r.root().to_path_buf());
|
||||
// Paths come from what the agent left in its worktree: neither side may lead into git's own
|
||||
// files or out through a link.
|
||||
let task_side = Confined::new(repo.root(), Some(&repo))?;
|
||||
let checkout_side = Confined::new(&local_root, local.as_ref())?;
|
||||
let copyable = |rel: &str| task_side.allows(rel) && checkout_side.allows(rel);
|
||||
let renamed_from: BTreeMap<String, String> =
|
||||
repo.diff_with_worktree(base)?.into_iter().filter_map(|f| Some((f.path, f.old_path?))).collect();
|
||||
// The task's version of one path of the checkout (`Some(None)`: absent there), or `None` when
|
||||
// the checkout changed it itself.
|
||||
let target = |rel: &str| -> IdeResult<Option<Option<Vec<u8>>>> {
|
||||
let in_task = repo.root().join(rel);
|
||||
let current = std::fs::read(&in_task).ok();
|
||||
// A file there that can't be read is the checkout's own: never written over.
|
||||
let Some(local_bytes) = existing(&local_root.join(rel)) else { return Ok(None) };
|
||||
if local_bytes == current {
|
||||
return Ok(Some(current));
|
||||
}
|
||||
let base_bytes = repo.file_bytes_at(&in_task, Some(base))?;
|
||||
// The checkout may hold the committed LF text with CRLF line ends (`core.autocrlf`).
|
||||
let unchanged = match (&local_bytes, &base_bytes) {
|
||||
(Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base,
|
||||
(now, at_base) => now == at_base,
|
||||
};
|
||||
Ok(unchanged.then_some(current))
|
||||
};
|
||||
// Puts `bytes` (`None`: nothing) at `rel` of the checkout.
|
||||
let write = |rel: &str, bytes: Option<Vec<u8>>| -> IdeResult<()> {
|
||||
let local = local_root.join(rel);
|
||||
if std::fs::read(&local).ok() == bytes {
|
||||
return Ok(());
|
||||
}
|
||||
match bytes {
|
||||
Some(bytes) => {
|
||||
if let Some(parent) = local.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&local, bytes)?;
|
||||
}
|
||||
None => match std::fs::remove_file(&local) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
|
||||
let apply = |rel: &str| -> IdeResult<bool> {
|
||||
let Some(bytes) = target(rel)? else { return Ok(false) };
|
||||
write(rel, bytes)?;
|
||||
Ok(true)
|
||||
};
|
||||
let mut accepted = Vec::with_capacity(changes.len());
|
||||
let mut skipped = Vec::new();
|
||||
let skip = |path: &str| crate::AgentAcceptSkipped {
|
||||
path: path.to_owned(),
|
||||
reason: "the checkout has changes of its own to it; accept it by hand".to_owned(),
|
||||
};
|
||||
let refuse = |path: &str| crate::AgentAcceptSkipped {
|
||||
path: path.to_owned(),
|
||||
reason: "it leads into git's own files or through a link, so it is not copied".to_owned(),
|
||||
};
|
||||
for change in changes {
|
||||
let old = renamed_from.get(&change.rel_path);
|
||||
if !copyable(&change.rel_path) {
|
||||
skipped.push(refuse(&change.rel_path));
|
||||
continue;
|
||||
}
|
||||
if let Some(old) = old.filter(|old| !copyable(old)) {
|
||||
skipped.push(refuse(old));
|
||||
continue;
|
||||
}
|
||||
// A case-only rename (`a.txt` → `A.txt`). Where the disk resolves both
|
||||
// spellings to one file it is renamed in place and `apply` then refreshes
|
||||
// the content. On a case-sensitive disk the two names are different files:
|
||||
// both are checked before either changes — the old one must be the file the
|
||||
// checkout had, the new one absent or already the task's — then the task's
|
||||
// version lands at the new name before the old one goes.
|
||||
let case_only =
|
||||
old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase());
|
||||
if case_only {
|
||||
let old = old.expect("case_only implies a rename source");
|
||||
let (old_path, new_path) = (local_root.join(old), local_root.join(&change.rel_path));
|
||||
if same_file(&old_path, &new_path) {
|
||||
std::fs::rename(&old_path, &new_path)?;
|
||||
} else {
|
||||
let Some(old_target) = target(old)? else {
|
||||
skipped.push(skip(old));
|
||||
continue;
|
||||
};
|
||||
let task_bytes = std::fs::read(repo.root().join(&change.rel_path)).ok();
|
||||
let at_new = existing(&new_path);
|
||||
if at_new.as_ref().is_none_or(|at_new| at_new.is_some() && *at_new != task_bytes) {
|
||||
skipped.push(skip(&change.rel_path));
|
||||
continue;
|
||||
}
|
||||
write(&change.rel_path, task_bytes)?;
|
||||
write(old, old_target)?;
|
||||
accepted.push(change.rel_path.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !apply(&change.rel_path)? {
|
||||
skipped.push(skip(&change.rel_path));
|
||||
continue;
|
||||
}
|
||||
accepted.push(change.rel_path.clone());
|
||||
// A rename also takes the file away from its old path (a case-only rename
|
||||
// is handled above).
|
||||
if !case_only
|
||||
&& let Some(old) = old
|
||||
&& !apply(old)?
|
||||
{
|
||||
skipped.push(skip(old));
|
||||
}
|
||||
}
|
||||
Ok(crate::AgentAcceptOutcome { accepted, skipped })
|
||||
}
|
||||
|
||||
/// Size and modification time of a file, `None` once it is gone.
|
||||
pub(crate) type Stamp = Option<(u64, u128)>;
|
||||
|
||||
@@ -124,7 +542,10 @@ pub(crate) fn stamp(path: &Path) -> Stamp {
|
||||
|
||||
/// The files already changed in `cwd` against `HEAD`, stamped so later edits to them show.
|
||||
pub(crate) fn snapshot(cwd: &Path) -> IdeResult<BTreeMap<String, Stamp>> {
|
||||
Ok(changes(cwd, "HEAD")?.into_iter().map(|c| (c.rel_path, stamp(&c.path))).collect())
|
||||
// Paths and stamps only: no diff and no line counts, which read every untracked file
|
||||
// and held up starting a task in a checkout with many of them.
|
||||
let Some(repo) = Repo::discover(cwd)? else { return Ok(BTreeMap::new()) };
|
||||
Ok(repo.status()?.into_iter().map(|s| (s.rel_path, stamp(&s.path))).collect())
|
||||
}
|
||||
|
||||
/// Drops the changes that were there before the task and haven't been touched since.
|
||||
@@ -161,11 +582,11 @@ pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Pa
|
||||
}
|
||||
|
||||
/// Rolls `change` back: to the copy saved when the task started when the file had changes
|
||||
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
|
||||
/// lacks it).
|
||||
/// then (`before`, `saved` holding the copies), otherwise to `rev` (removed when `rev` lacks
|
||||
/// it, or when there is no `rev`: the repository had no commits).
|
||||
pub(crate) fn restore(
|
||||
cwd: &Path,
|
||||
base: &str,
|
||||
rev: Option<&str>,
|
||||
change: &AgentChange,
|
||||
before: &BTreeMap<String, Stamp>,
|
||||
saved: Option<&Path>,
|
||||
@@ -183,10 +604,13 @@ pub(crate) fn restore(
|
||||
})?;
|
||||
Some(std::fs::read(copy)?)
|
||||
}
|
||||
None => {
|
||||
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
|
||||
repo.file_bytes_at(path, Some(base))?
|
||||
}
|
||||
None => match rev {
|
||||
Some(rev) => {
|
||||
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
|
||||
repo.file_bytes_at(path, Some(rev))?
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
};
|
||||
match content {
|
||||
Some(bytes) => {
|
||||
@@ -204,7 +628,7 @@ pub(crate) fn restore(
|
||||
}
|
||||
|
||||
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
|
||||
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
|
||||
let Some(repo) = discover(cwd)? else { return Ok(None) };
|
||||
let encoding = match ide_vfs::load_text(path) {
|
||||
Ok(loaded) => encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()).unwrap_or(encoding_rs::UTF_8),
|
||||
Err(_) => ide_vfs::detect_encoding(&repo.file_bytes_at(path, Some(rev))?.unwrap_or_default(), None),
|
||||
@@ -313,7 +737,18 @@ mod tests {
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let ok = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args([
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
// Hermetic line endings: a developer or CI runner with
|
||||
// `core.autocrlf=true` would otherwise check the fixtures out as CRLF.
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
])
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.status()
|
||||
@@ -361,6 +796,88 @@ mod tests {
|
||||
assert!(!created.path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_since_the_base_are_listed_with_their_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
|
||||
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
|
||||
assert!(commits(&created.path, &created.base_commit, &created.base_commit).unwrap().is_empty());
|
||||
|
||||
std::fs::write(created.path.join("a.txt"), "one\ntwo\n").unwrap();
|
||||
git(&created.path, &["add", "."]);
|
||||
git(&created.path, &["commit", "-q", "-m", "change a"]);
|
||||
std::fs::write(created.path.join("b.txt"), "b\n").unwrap();
|
||||
git(&created.path, &["add", "."]);
|
||||
git(&created.path, &["commit", "-q", "-m", "add b"]);
|
||||
|
||||
let head = head_of(&created.path).unwrap().1;
|
||||
let log = commits(&created.path, &created.base_commit, &head).unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.summary.as_str()).collect::<Vec<_>>(), ["add b", "change a"]);
|
||||
assert_eq!(log[0].files.iter().map(|f| f.rel_path.as_str()).collect::<Vec<_>>(), ["b.txt"]);
|
||||
assert_eq!((log[0].files[0].added, log[0].files[0].removed), (1, 0));
|
||||
assert_eq!(log[1].files.iter().map(|f| f.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
|
||||
assert_eq!((log[1].files[0].added, log[1].files[0].removed), (1, 0));
|
||||
assert!(log.iter().all(|c| c.parent.is_some() && !c.message.is_empty()));
|
||||
|
||||
// Nothing is uncommitted after the commits; editing a committed file brings it back.
|
||||
let at = TaskBase::Worktree { cwd: created.path.clone(), base: created.base_commit.clone() };
|
||||
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
|
||||
assert!(grouped.uncommitted.is_empty());
|
||||
assert_eq!(grouped.commits.len(), 2);
|
||||
std::fs::write(created.path.join("a.txt"), "one\ntwo\nthree\n").unwrap();
|
||||
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
|
||||
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
|
||||
assert_eq!(grouped.commits.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_in_a_commit_keeps_its_old_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\ntwo\nthree\nfour\nfive\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
let base = head_of(&repo).unwrap().1;
|
||||
|
||||
std::fs::rename(repo.join("a.txt"), repo.join("b.txt")).unwrap();
|
||||
std::fs::write(repo.join("b.txt"), "one\ntwo\nthree\nfour\nFIVE\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "rename a"]);
|
||||
|
||||
let head = head_of(&repo).unwrap().1;
|
||||
let log = commits(&repo, &base, &head).unwrap();
|
||||
assert_eq!(log.len(), 1);
|
||||
assert_eq!(log[0].files.len(), 1);
|
||||
assert_eq!(log[0].files[0].rel_path, "b.txt");
|
||||
assert_eq!(log[0].files[0].old_path.as_deref(), Some("a.txt"));
|
||||
assert_eq!(log[0].files[0].kind, ChangeKind::Renamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_task_has_no_commits() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
|
||||
|
||||
let at = TaskBase::Local { cwd: repo.clone(), start: None };
|
||||
let grouped = at.grouped_changes(&BTreeMap::new()).unwrap();
|
||||
assert!(grouped.commits.is_empty());
|
||||
assert_eq!(grouped.uncommitted.iter().map(|c| c.rel_path.as_str()).collect::<Vec<_>>(), ["a.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_worktree_registration_still_leaves_a_deletable_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -391,4 +908,280 @@ mod tests {
|
||||
assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err());
|
||||
assert!(elsewhere.join("keep.txt").exists());
|
||||
}
|
||||
|
||||
fn accept_repo() -> (tempfile::TempDir, PathBuf, Created) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
// Hermetic line endings: a machine with `core.autocrlf=true` would check
|
||||
// the fixtures and the worktree out as CRLF.
|
||||
git(&repo, &["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
|
||||
std::fs::write(repo.join("gone.txt"), "bye\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
|
||||
(tmp, repo, created)
|
||||
}
|
||||
|
||||
fn listed(cwd: &Path, base: &str) -> Vec<AgentChange> {
|
||||
without_untouched(changes(cwd, base).unwrap(), &BTreeMap::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_moves_a_renamed_file_and_maps_paths_from_a_project_folder() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
std::fs::create_dir(created.path.join("app")).unwrap();
|
||||
git(&created.path, &["mv", "a.txt", "app/moved.txt"]);
|
||||
std::fs::write(created.path.join("app/new.txt"), "n\n").unwrap();
|
||||
git(&created.path, &["add", "."]);
|
||||
git(&created.path, &["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "move"]);
|
||||
|
||||
// The task's project is the repository's `app` folder.
|
||||
let project = repo.join("app");
|
||||
std::fs::create_dir(&project).unwrap();
|
||||
let changes = listed(&created.path, &created.base_commit);
|
||||
let outcome = accept(&project, &created.path, &created.base_commit, &changes).unwrap();
|
||||
assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped);
|
||||
assert_eq!(std::fs::read_to_string(repo.join("app/moved.txt")).unwrap(), "one\ntwo\n");
|
||||
assert_eq!(std::fs::read_to_string(repo.join("app/new.txt")).unwrap(), "n\n");
|
||||
assert!(!repo.join("a.txt").exists(), "the rename's old path is gone");
|
||||
assert!(!repo.join("app/app").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_treats_a_crlf_checkout_of_the_base_as_unchanged() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
// The checkout holds the committed text with CRLF line ends, as core.autocrlf leaves it.
|
||||
std::fs::write(repo.join("a.txt"), "one\r\ntwo\r\n").unwrap();
|
||||
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
|
||||
git(&created.path, &["mv", "gone.txt", "kept.txt"]);
|
||||
std::fs::write(repo.join("gone.txt"), "bye\r\n").unwrap();
|
||||
let changes = listed(&created.path, &created.base_commit);
|
||||
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
|
||||
assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped);
|
||||
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nTWO\n");
|
||||
assert!(repo.join("kept.txt").exists() && !repo.join("gone.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_applies_a_case_only_rename() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
git(&created.path, &["mv", "a.txt", "A.txt"]);
|
||||
let changes = listed(&created.path, &created.base_commit);
|
||||
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
|
||||
assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped);
|
||||
let names: Vec<_> = std::fs::read_dir(&repo)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.eq_ignore_ascii_case("a.txt"))
|
||||
.collect();
|
||||
assert_eq!(names, ["A.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_case_only_rename_keeps_a_file_the_checkout_has_at_the_new_name() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
std::fs::write(repo.join("A.txt"), "mine\n").unwrap();
|
||||
if std::fs::read_to_string(repo.join("a.txt")).unwrap() == "mine\n" {
|
||||
eprintln!("this disk doesn't tell a.txt from A.txt; nothing to check");
|
||||
return;
|
||||
}
|
||||
git(&created.path, &["mv", "a.txt", "A.txt"]);
|
||||
let changes = listed(&created.path, &created.base_commit);
|
||||
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
|
||||
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["A.txt"]);
|
||||
assert_eq!(std::fs::read_to_string(repo.join("A.txt")).unwrap(), "mine\n");
|
||||
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\ntwo\n");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn accept_leaves_a_checkout_file_it_cannot_read() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
std::fs::write(created.path.join("new.txt"), "task\n").unwrap();
|
||||
std::fs::write(repo.join("new.txt"), "mine\n").unwrap();
|
||||
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o200)).unwrap();
|
||||
if std::fs::read(repo.join("new.txt")).is_ok() {
|
||||
eprintln!("running as a user that reads any file; nothing to check");
|
||||
return;
|
||||
}
|
||||
let changes = listed(&created.path, &created.base_commit);
|
||||
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
|
||||
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["new.txt"]);
|
||||
assert_eq!(std::fs::read_to_string(repo.join("new.txt")).unwrap(), "mine\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn accept_does_not_write_into_the_checkouts_git_folder_through_its_short_name() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
if !repo.join("GIT~1").join("HEAD").is_file() {
|
||||
eprintln!("8.3 short names are disabled on this volume; nothing to check");
|
||||
return;
|
||||
}
|
||||
// Written in the worktree under the name the checkout's `.git` folder also has.
|
||||
let wt = &created.path;
|
||||
std::fs::rename(wt.join(".git"), wt.join("gitfile")).unwrap();
|
||||
std::fs::create_dir_all(wt.join("GIT~1").join("hooks")).unwrap();
|
||||
std::fs::write(wt.join("GIT~1").join("hooks").join("pre-commit"), "#!/bin/sh\nexit 0\n").unwrap();
|
||||
std::fs::rename(wt.join("gitfile"), wt.join(".git")).unwrap();
|
||||
std::fs::write(wt.join("a.txt"), "one\nTWO\n").unwrap();
|
||||
|
||||
let changes = listed(wt, &created.base_commit);
|
||||
assert!(changes.iter().any(|c| c.rel_path == "GIT~1/hooks/pre-commit"), "control: git lists it");
|
||||
let outcome = accept(&repo, wt, &created.base_commit, &changes).unwrap();
|
||||
assert!(!repo.join(".git").join("hooks").join("pre-commit").exists(), "{outcome:?}");
|
||||
assert_eq!(outcome.accepted, ["a.txt"]);
|
||||
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["GIT~1/hooks/pre-commit"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_refuses_paths_that_lead_into_git_metadata_under_any_spelling() {
|
||||
let (tmp, repo, _created) = accept_repo();
|
||||
let checkout = Confined::new(&repo, Repo::discover(&repo).unwrap().as_ref()).unwrap();
|
||||
for rel in [
|
||||
".git/hooks/pre-commit",
|
||||
".GIT/hooks/pre-commit",
|
||||
".git./hooks/pre-commit",
|
||||
".git /hooks/pre-commit",
|
||||
".git::$INDEX_ALLOCATION/hooks/pre-commit",
|
||||
"sub/.git/config",
|
||||
"../outside.txt",
|
||||
] {
|
||||
assert!(!checkout.allows(rel), "{rel}");
|
||||
}
|
||||
assert!(checkout.allows("src/new.txt"));
|
||||
assert!(checkout.allows(".github/workflows/ci.yml"));
|
||||
|
||||
// git's folder under another name inside the tree (`--separate-git-dir`).
|
||||
let separate = tmp.path().join("separate");
|
||||
std::fs::create_dir(&separate).unwrap();
|
||||
git(&separate, &["init", "-q", "--separate-git-dir", "meta", "-b", "main"]);
|
||||
let side = Confined::new(&separate, Repo::discover(&separate).unwrap().as_ref()).unwrap();
|
||||
assert!(!side.allows("meta/hooks/pre-commit"));
|
||||
assert!(side.allows("metadata.txt"));
|
||||
}
|
||||
|
||||
/// A link the task left in its worktree, or a linked folder in the checkout: neither is followed.
|
||||
#[test]
|
||||
fn accept_does_not_follow_links_on_either_side() {
|
||||
let (tmp, repo, created) = accept_repo();
|
||||
let outside = tmp.path().join("outside");
|
||||
std::fs::create_dir(&outside).unwrap();
|
||||
std::fs::write(outside.join("secret.txt"), "secret\n").unwrap();
|
||||
// A folder link in the checkout (a junction on Windows, which needs no privilege).
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&outside, repo.join("linked")).unwrap();
|
||||
#[cfg(windows)]
|
||||
assert!(
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/c", "mklink", "/J"])
|
||||
.arg(repo.join("linked"))
|
||||
.arg(&outside)
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
std::fs::create_dir(created.path.join("linked")).unwrap();
|
||||
std::fs::write(created.path.join("linked/new.txt"), "x\n").unwrap();
|
||||
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
|
||||
// A file link in the worktree, where the platform lets this user create one.
|
||||
#[cfg(unix)]
|
||||
let file_link = std::os::unix::fs::symlink(outside.join("secret.txt"), created.path.join("copied.txt"));
|
||||
#[cfg(windows)]
|
||||
let file_link = std::os::windows::fs::symlink_file(outside.join("secret.txt"), created.path.join("copied.txt"));
|
||||
if let Err(e) = &file_link {
|
||||
eprintln!("no file link ({e}); checking the folder link only");
|
||||
}
|
||||
|
||||
let changes: Vec<_> = listed(&created.path, &created.base_commit);
|
||||
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
|
||||
assert!(!outside.join("new.txt").exists(), "{outcome:?}");
|
||||
assert!(!repo.join("copied.txt").exists(), "{outcome:?}");
|
||||
assert!(outcome.accepted.contains(&"a.txt".to_owned()));
|
||||
let refused: Vec<_> = outcome.skipped.iter().map(|s| s.path.as_str()).collect();
|
||||
if file_link.is_ok() {
|
||||
assert_eq!(refused, ["copied.txt", "linked/new.txt"]);
|
||||
} else {
|
||||
assert_eq!(refused, ["linked/new.txt"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_copies_new_modified_and_deleted_files_into_the_checkout() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
|
||||
std::fs::write(created.path.join("new.txt"), "x\n").unwrap();
|
||||
std::fs::remove_file(created.path.join("gone.txt")).unwrap();
|
||||
|
||||
let outcome =
|
||||
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
|
||||
assert!(outcome.skipped.is_empty());
|
||||
assert_eq!(outcome.accepted, ["a.txt", "gone.txt", "new.txt"]);
|
||||
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nTWO\n");
|
||||
assert_eq!(std::fs::read_to_string(repo.join("new.txt")).unwrap(), "x\n");
|
||||
assert!(!repo.join("gone.txt").exists());
|
||||
|
||||
// Accepting again changes nothing: the checkout already matches.
|
||||
let again =
|
||||
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
|
||||
assert!(again.skipped.is_empty());
|
||||
assert_eq!(again.accepted, ["a.txt", "gone.txt", "new.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_task_goes_back_to_its_start_unless_the_user_committed_on_top_of_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
let local = |start: Option<&str>| TaskBase::Local { cwd: repo.clone(), start: start.map(str::to_owned) };
|
||||
let head = || head_of(&repo).unwrap().1;
|
||||
let commit = |name: &str| {
|
||||
std::fs::write(repo.join(name), "x\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", name]);
|
||||
head_of(&repo).unwrap().1
|
||||
};
|
||||
|
||||
assert_eq!(local(None).rev().unwrap(), None, "no commits yet");
|
||||
let first = commit("a.txt");
|
||||
assert_eq!(local(None).rev().unwrap(), Some(first.clone()), "an imported session: HEAD");
|
||||
let start = commit("b.txt");
|
||||
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "HEAD where it started");
|
||||
let ahead = commit("c.txt");
|
||||
assert_eq!(local(Some(&start)).rev().unwrap(), Some(ahead), "committed on top: HEAD");
|
||||
git(&repo, &["reset", "-q", "--soft", &first]);
|
||||
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "moved back: the start");
|
||||
git(&repo, &["checkout", "-q", "-b", "side"]);
|
||||
commit("d.txt");
|
||||
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "another branch: the start");
|
||||
let gone = "0123456789abcdef0123456789abcdef01234567";
|
||||
assert_eq!(local(Some(gone)).rev().unwrap(), Some(head()), "an unknown start: HEAD");
|
||||
let worktree = TaskBase::Worktree { cwd: repo.clone(), base: start.clone() };
|
||||
assert_eq!(worktree.rev().unwrap(), Some(start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_leaves_files_the_checkout_changed_itself() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
|
||||
std::fs::write(created.path.join("gone.txt"), "bye\nbye\n").unwrap();
|
||||
std::fs::write(repo.join("a.txt"), "one\nMINE\n").unwrap();
|
||||
|
||||
let outcome =
|
||||
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
|
||||
assert_eq!(outcome.accepted, ["gone.txt"]);
|
||||
assert_eq!(outcome.skipped.len(), 1);
|
||||
assert_eq!(outcome.skipped[0].path, "a.txt");
|
||||
assert!(!outcome.skipped[0].reason.is_empty());
|
||||
// The user's own edit survives; the other file comes over.
|
||||
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nMINE\n");
|
||||
assert_eq!(std::fs::read_to_string(repo.join("gone.txt")).unwrap(), "bye\nbye\n");
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ mod up_to_date;
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
|
||||
pub use launch_resources::process_launch_resources;
|
||||
pub use single::{
|
||||
SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target,
|
||||
SingleFileTarget, compile_file_plan, find_javac, javac_step, module_classpath, single_file_classpath,
|
||||
single_file_target,
|
||||
};
|
||||
pub use up_to_date::{menu_up_to_date, record_menu_build};
|
||||
|
||||
@@ -266,6 +267,11 @@ pub(super) fn maven_steps(
|
||||
BuildKind::Rebuild => &["clean", "test-compile"],
|
||||
BuildKind::Artifacts => &["package", "-DskipTests"],
|
||||
};
|
||||
// The IDE's compile builds are not coverage runs. JaCoCo's offline `instrument`
|
||||
// rewrites target/classes in `process-classes`; `jacoco.skip` prevents that in this
|
||||
// build. It does not restore classes instrumented by an earlier build: Rebuild's
|
||||
// `clean` removes those, while an incremental Build can still reuse them.
|
||||
let skip_jacoco = matches!(kind, BuildKind::Build | BuildKind::Rebuild);
|
||||
let tops: Vec<&maven::MavenProject> = m.projects.iter().filter(|p| !p.aggregated).collect();
|
||||
if tops.is_empty() {
|
||||
return Err(IdeError::not_found("no Maven project found"));
|
||||
@@ -278,6 +284,9 @@ pub(super) fn maven_steps(
|
||||
Format::Maven,
|
||||
);
|
||||
s.args.extend(p.options.iter().cloned());
|
||||
if skip_jacoco {
|
||||
s.args.push("-Djacoco.skip=true".into());
|
||||
}
|
||||
s.args.extend(extra.iter().cloned());
|
||||
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
|
||||
s.env = p.env.clone();
|
||||
@@ -1041,6 +1050,22 @@ mod tests {
|
||||
std::fs::write(p, text).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_compile_steps_skip_jacoco_but_artifacts_do_not() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("demo");
|
||||
write(&root, "pom.xml", "<project><artifactId>demo</artifactId></project>");
|
||||
let model = crate::load(&root, &tmp.path().join("cache"), None);
|
||||
for (kind, expected) in [(BuildKind::Build, true), (BuildKind::Rebuild, true), (BuildKind::Artifacts, false)] {
|
||||
let plan = plan(&root, &model, kind, None, None).unwrap();
|
||||
assert_eq!(
|
||||
plan.steps[0].args.iter().any(|arg| arg == "-Djacoco.skip=true"),
|
||||
expected,
|
||||
"{kind:?}: {plan:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_per_build_system() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1057,7 +1082,10 @@ mod tests {
|
||||
assert_eq!(p.system, BuildSystem::Maven);
|
||||
assert_eq!(p.title, "demo");
|
||||
assert_eq!(p.steps.len(), 1);
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "test-compile"]);
|
||||
assert_eq!(
|
||||
p.steps[0].args,
|
||||
["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "-Djacoco.skip=true", "test-compile"]
|
||||
);
|
||||
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
|
||||
assert_eq!(
|
||||
p.steps[0].args,
|
||||
@@ -1065,6 +1093,7 @@ mod tests {
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-Djacoco.skip=true",
|
||||
"-pl",
|
||||
"core",
|
||||
"-am",
|
||||
@@ -1353,6 +1382,7 @@ mod tests {
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-s",
|
||||
"settings.xml",
|
||||
"-Djacoco.skip=true",
|
||||
"-pl",
|
||||
"app",
|
||||
"-am",
|
||||
@@ -1369,6 +1399,7 @@ mod tests {
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-s",
|
||||
"settings.xml",
|
||||
"-Djacoco.skip=true",
|
||||
"-pl",
|
||||
"app",
|
||||
"-am",
|
||||
|
||||
@@ -4,12 +4,21 @@
|
||||
//! walks the whole reactor; compiling one file only needs that file's module
|
||||
//! outputs plus its dependency classpath, which is much faster to assemble.
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::process::Command as ProcCommand;
|
||||
|
||||
/// Output file of the `dependency:build-classpath` probe, relative to each module.
|
||||
const CP_FILE: &str = "target/ide-compile-cp.txt";
|
||||
|
||||
/// The resolved dependency classpath for one module and scope, tied to the same
|
||||
/// inputs as its launch build so POM, JDK, settings and profile changes invalidate it.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct CachedClasspath {
|
||||
fingerprint: LaunchFingerprint,
|
||||
classpath: String,
|
||||
}
|
||||
|
||||
/// A `.java` source and the module build that compiles it.
|
||||
pub struct SingleFileTarget {
|
||||
/// Absolute module directory.
|
||||
@@ -64,6 +73,28 @@ pub fn single_file_target(root: &Path, model: &BuildModel, file: &Path) -> IdeRe
|
||||
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
|
||||
}
|
||||
|
||||
/// The Maven reactor target for a launch module whose source file is not known yet.
|
||||
fn module_target(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<SingleFileTarget> {
|
||||
let Some(m) = &model.maven else {
|
||||
return Err(IdeError::not_found("no linked Maven project found for module"));
|
||||
};
|
||||
let module = if module.is_absolute() { module.to_path_buf() } else { root.join(module) };
|
||||
let project = m
|
||||
.projects
|
||||
.iter()
|
||||
.find(|p| root.join(&p.dir) == module)
|
||||
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
|
||||
if project.packaging == "pom" {
|
||||
return Err(IdeError::not_found("the launch module is a Maven aggregator without sources"));
|
||||
}
|
||||
let top = maven_reactor(m, &project.dir)
|
||||
.ok_or_else(|| IdeError::not_found("no linked Maven reactor found for module"))?;
|
||||
let top_dir = subdir(root, &top.dir);
|
||||
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
||||
let out_dir = module.join("target").join(if test { "test-classes" } else { "classes" });
|
||||
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
|
||||
}
|
||||
|
||||
/// `javac` for the module: its `JAVA_HOME`, else `PATH`.
|
||||
pub fn find_javac(env: &BTreeMap<String, String>) -> String {
|
||||
let home = env.get("JAVA_HOME").map(Path::new);
|
||||
@@ -78,6 +109,88 @@ pub fn find_javac(env: &BTreeMap<String, String>) -> String {
|
||||
/// `mvn dependency:build-classpath` (which resolves without compiling anything,
|
||||
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
|
||||
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> {
|
||||
classpath(root, model, target, "test", false)
|
||||
}
|
||||
|
||||
/// Runtime (or test) dependency classpath for a launch module.
|
||||
///
|
||||
/// The Java language server runs on its own JDK, so its Maven model can select a
|
||||
/// different JDK profile and dependency classifier than the project launch. Use the
|
||||
/// same Maven process that builds the module instead.
|
||||
pub fn module_classpath(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cache: &Path,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
let target = module_target(root, model, module, test)?;
|
||||
let fingerprint = launch_fingerprint(root, model, &target.module, test, local_repo, cancel)?;
|
||||
let cache_path = classpath_cache_path(root, cache, &target.module, test);
|
||||
if let (Some(fingerprint), Some(path)) = (&fingerprint, &cache_path)
|
||||
&& let Ok(bytes) = fs::read(path)
|
||||
&& let Ok(cached) = serde_json::from_slice::<CachedClasspath>(&bytes)
|
||||
&& cached.fingerprint == *fingerprint
|
||||
&& classpath_entries_exist(root, &cached.classpath)
|
||||
{
|
||||
return Ok(cached.classpath);
|
||||
}
|
||||
let classpath = classpath(root, model, &target, if test { "test" } else { "runtime" }, true)?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if let (Some(fingerprint), Some(path)) = (fingerprint, cache_path) {
|
||||
let cached = CachedClasspath { fingerprint, classpath: classpath.clone() };
|
||||
if let Err(error) = cache_classpath(&path, &cached) {
|
||||
tracing::debug!(path = %path.display(), %error, "Cannot cache the Java launch classpath");
|
||||
}
|
||||
}
|
||||
Ok(classpath)
|
||||
}
|
||||
|
||||
/// One cache file per module and scope. Mirroring the module path keeps nested
|
||||
/// modules and their different dependency scopes from overwriting each other.
|
||||
fn classpath_cache_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
|
||||
let relative = module.strip_prefix(root).ok()?;
|
||||
let mut path = cache.join("java-launch/classpaths");
|
||||
path.push(if relative.as_os_str().is_empty() { Path::new("_root") } else { relative });
|
||||
path.push(if test { "test.json" } else { "runtime.json" });
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Best-effort cache persistence. A failed write only costs one later Maven
|
||||
/// probe, so the launch still succeeds even when the cache directory is unusable.
|
||||
fn cache_classpath(path: &Path, cached: &CachedClasspath) -> IdeResult<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, serde_json::to_vec(cached).map_err(|e| IdeError::internal(e.to_string()))?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A cache entry stays valid while every file exists. Maven also emits reactor
|
||||
/// output directories for modules with no classes, which may legitimately never be
|
||||
/// created; the launch snapshot already supplies the module's classes in that case.
|
||||
fn classpath_entries_exist(root: &Path, classpath: &str) -> bool {
|
||||
// Temp directories on macOS are commonly reached through `/var`, while Maven
|
||||
// reports the canonical `/private/var` path. Compare against both spellings.
|
||||
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
std::env::split_paths(classpath).all(|entry| {
|
||||
entry.exists()
|
||||
|| ((entry.starts_with(root) || entry.starts_with(&canonical_root))
|
||||
&& (entry.ends_with(Path::new("target/classes")) || entry.ends_with(Path::new("target/test-classes"))))
|
||||
})
|
||||
}
|
||||
|
||||
fn classpath(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
target: &SingleFileTarget,
|
||||
scope: &str,
|
||||
compile_first: bool,
|
||||
) -> IdeResult<String> {
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
// The module's own classes first: the file almost always uses classes next to it.
|
||||
let own: &[&str] = if target.test { &["target/test-classes", "target/classes"] } else { &["target/classes"] };
|
||||
@@ -110,7 +223,14 @@ pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFil
|
||||
args.extend(["-pl", target.pl.as_str(), "-am"]);
|
||||
}
|
||||
let output_arg = format!("-Dmdep.outputFile={probe}");
|
||||
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
|
||||
let scope_arg = format!("-Dmdep.includeScope={scope}");
|
||||
// A launch build may only have reached `compile`. Running `compile` in this
|
||||
// same Maven session lets the dependency plugin resolve Reactor siblings from
|
||||
// their target/classes without paying for package-phase plugins.
|
||||
if compile_first {
|
||||
args.push("compile");
|
||||
}
|
||||
args.extend(["dependency:build-classpath", output_arg.as_str(), scope_arg.as_str(), "-q"]);
|
||||
let top_dir = subdir(root, &target.top.dir);
|
||||
let status = ProcCommand::new(&target.top.command)
|
||||
.args(&args)
|
||||
@@ -119,8 +239,10 @@ pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFil
|
||||
.output()
|
||||
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
|
||||
if !status.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&status.stdout);
|
||||
let stderr = String::from_utf8_lossy(&status.stderr);
|
||||
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
// Maven reports reactor/dependency failures on stdout, not only stderr.
|
||||
let tail: Vec<&str> = stdout.lines().chain(stderr.lines()).filter(|l| !l.trim().is_empty()).collect();
|
||||
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
|
||||
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
|
||||
}
|
||||
@@ -305,7 +427,7 @@ mod tests {
|
||||
let script = root.join("mvnw");
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
@@ -327,5 +449,90 @@ mod tests {
|
||||
assert!(cp.contains("/repo/a.jar"), "{cp}");
|
||||
assert!(cp.contains("/repo/b.jar"), "{cp}");
|
||||
assert!(cp.split(separator).count() >= 3, "{cp}");
|
||||
let args = fs::read_to_string(root.join("args.txt")).unwrap();
|
||||
assert!(!args.lines().any(|a| a == "compile" || a == "package"), "{args}");
|
||||
}
|
||||
|
||||
/// A launch uses runtime dependencies, while tests additionally see test-scope ones.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn launch_module_classpath_uses_the_matching_dependency_scope() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/runtime.jar:/repo/test.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let model = crate::load(root, &root.join("cache"), None);
|
||||
|
||||
let cache = root.join("cache");
|
||||
let runtime =
|
||||
module_classpath(root, &model, &root.join("app"), false, None, &cache, &CancellationToken::new()).unwrap();
|
||||
assert!(runtime.contains("/repo/runtime.jar"), "{runtime}");
|
||||
let args = fs::read_to_string(root.join("args.txt")).unwrap();
|
||||
assert!(args.lines().any(|a| a == "compile"), "{args}");
|
||||
assert!(!args.lines().any(|a| a == "package"), "{args}");
|
||||
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=runtime"), "{args}");
|
||||
|
||||
let test =
|
||||
module_classpath(root, &model, &root.join("app"), true, None, &cache, &CancellationToken::new()).unwrap();
|
||||
assert!(test.contains("/repo/test.jar"), "{test}");
|
||||
let args = fs::read_to_string(root.join("args.txt")).unwrap();
|
||||
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=test"), "{args}");
|
||||
}
|
||||
|
||||
/// The launch classpath follows the Maven runner JDK and is reused until that
|
||||
/// build input changes; the probe does not run packaging plugins.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn launch_classpath_cache_follows_the_runner_jdk() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
// `dependency:build-classpath` reports reactor outputs as absolute paths. Keep
|
||||
// the fake probe faithful so the cache's existence check sees the output.
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\nprintf x >> calls.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target/classes\" \"$JAVA_HOME/classpath\"\n: > \"$JAVA_HOME/classpath/jdk.jar\"\nprintf '%s/classpath/jdk.jar:%s/%s/target/classes:%s/empty/target/classes' \"$JAVA_HOME\" \"$(pwd)\" \"$pl\" \"$(pwd)\" > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut model = crate::load(root, &root.join("cache"), None);
|
||||
for (name, version) in [("jdk21", "21"), ("jdk8", "8")] {
|
||||
let home = root.join(name);
|
||||
fs::create_dir_all(home.join("bin")).unwrap();
|
||||
fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
|
||||
fs::write(home.join("bin/java"), "").unwrap();
|
||||
}
|
||||
let cache = root.join("classpath-cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let jdk21 = root.join("jdk21");
|
||||
for project in &mut model.maven.as_mut().unwrap().projects {
|
||||
project.env.insert("JAVA_HOME".into(), jdk21.to_string_lossy().into_owned());
|
||||
}
|
||||
let first = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
|
||||
assert!(first.contains("jdk21/classpath/jdk.jar"), "{first}");
|
||||
assert!(!first.contains("jdk8/classpath/jdk.jar"), "{first}");
|
||||
let second = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
|
||||
assert_eq!(second, first);
|
||||
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "x");
|
||||
|
||||
let jdk8 = root.join("jdk8");
|
||||
for project in &mut model.maven.as_mut().unwrap().projects {
|
||||
project.env.insert("JAVA_HOME".into(), jdk8.to_string_lossy().into_owned());
|
||||
}
|
||||
let switched = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
|
||||
assert!(switched.contains("jdk8/classpath/jdk.jar"), "{switched}");
|
||||
assert!(!switched.contains("jdk21/classpath/jdk.jar"), "{switched}");
|
||||
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "xx");
|
||||
}
|
||||
}
|
||||
@@ -204,7 +204,7 @@ pub fn parse_settings(text: &str) -> Settings {
|
||||
}
|
||||
|
||||
/// `a/b/../c` → `a/c`, without touching the filesystem.
|
||||
fn normalize(p: &Path) -> PathBuf {
|
||||
pub(crate) fn normalize(p: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in p.components() {
|
||||
match c {
|
||||
|
||||
@@ -536,6 +536,7 @@ mod tests {
|
||||
crate::maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-o",
|
||||
"-Peditor",
|
||||
"-Djacoco.skip=true",
|
||||
"test-compile"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -122,6 +122,14 @@ pub fn gradle_external_roots(
|
||||
gradle::external_roots(root, dirs.iter().map(String::as_str))
|
||||
}
|
||||
|
||||
/// Directories of Maven POMs linked from outside `root`, outermost only.
|
||||
/// Reads just those explicit paths (no POM parse and no directory walk).
|
||||
pub fn maven_external_roots(root: &Path, linked: &[String]) -> Vec<PathBuf> {
|
||||
let dirs = maven::external_pom_dirs(root, linked);
|
||||
let owned: Vec<String> = dirs.iter().map(|dir| dir.to_string_lossy().into_owned()).collect();
|
||||
gradle::external_roots(root, owned.iter().map(String::as_str))
|
||||
}
|
||||
|
||||
/// Source, resource and build folders of the last Gradle sync, absolute.
|
||||
pub fn gradle_source_roots(
|
||||
root: &Path,
|
||||
|
||||
@@ -16,6 +16,7 @@ use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
pub mod completion;
|
||||
pub mod sources;
|
||||
|
||||
/// Lets the IDE's own Maven runs (Reload, Build) use what the local repository already
|
||||
@@ -106,7 +107,9 @@ pub struct MavenProject {
|
||||
pub profiles: Vec<String>,
|
||||
/// Java feature version the project compiles for (`maven.compiler.release`, `java.version`…).
|
||||
pub java_version: Option<u32>,
|
||||
/// Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`.
|
||||
/// Maven launcher: the nearest `mvnw` (absolute), else `mvn`. Inside the opened
|
||||
/// project the search stops at its root; outside it, the search stops before
|
||||
/// that root so a linked project keeps its own wrapper.
|
||||
pub command: String,
|
||||
/// Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings.
|
||||
pub options: Vec<String>,
|
||||
@@ -297,7 +300,7 @@ fn read_plugin(e: &Element) -> Option<RawPlugin> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_properties(e: Option<&Element>) -> Vec<(String, String)> {
|
||||
pub(crate) fn read_properties(e: Option<&Element>) -> Vec<(String, String)> {
|
||||
e.map(|p| p.children.iter().map(|c| (c.name.clone(), c.text.trim().to_owned())).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -709,18 +712,27 @@ pub(crate) fn launch_inputs(
|
||||
// Profile activation may depend on the presence of an otherwise unrelated file.
|
||||
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
|
||||
if input || activation {
|
||||
// Other properties can be overridden by -D, profiles or settings.
|
||||
// Without Maven's effective roots, conservatively disable reuse for them.
|
||||
let value = el
|
||||
.text
|
||||
.trim()
|
||||
.replace("${project.basedir}", &dir.to_string_lossy())
|
||||
.replace("${basedir}", &dir.to_string_lossy());
|
||||
if value.contains("${") {
|
||||
return None;
|
||||
}
|
||||
if !value.is_empty() {
|
||||
out.push(dir.join(value));
|
||||
// A source root under Maven's build directory is generated by the build itself.
|
||||
// Skip it without resolving the property: `<build><directory>` may be custom, and
|
||||
// hashing its rewritten files would make every launch look changed.
|
||||
if !(input && el.text.contains("${project.build.directory}")) {
|
||||
// Other properties can be overridden by -D, profiles or settings. Without
|
||||
// Maven's effective model, leave them unresolved to conservatively disable reuse.
|
||||
let value = el
|
||||
.text
|
||||
.trim()
|
||||
.replace("${project.basedir}", &dir.to_string_lossy())
|
||||
.replace("${basedir}", &dir.to_string_lossy());
|
||||
if value.contains("${") {
|
||||
return None;
|
||||
}
|
||||
if !value.is_empty() {
|
||||
let path = dir.join(value);
|
||||
// Conventional generated sources under target are also build outputs.
|
||||
if !(input && path.starts_with(dir.join("target"))) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for child in &el.children {
|
||||
@@ -1032,10 +1044,6 @@ pub(crate) fn plugin(
|
||||
|
||||
const MAX_PROJECTS: usize = 1000;
|
||||
|
||||
fn rel_path(root: &Path, p: &Path) -> String {
|
||||
p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
/// The POMs that are not modules of another one: the root `pom.xml`, else the
|
||||
/// shallowest `pom.xml` of each branch (a folder holding several builds). An
|
||||
/// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml`
|
||||
@@ -1049,18 +1057,29 @@ pub fn is_maven_project(root: &Path) -> bool {
|
||||
!top_level_poms(root).is_empty()
|
||||
}
|
||||
|
||||
/// The `mvnw` nearest to `dir`, looking up to `root`.
|
||||
/// The `mvnw` nearest to `dir`. Inside `root` the walk stops at `root`. Outside it,
|
||||
/// the walk stops before entering `root` or any ancestor of `root`: a sibling project
|
||||
/// keeps its own wrapper, and the search does not continue to the filesystem root.
|
||||
fn wrapper(root: &Path, dir: &Path) -> String {
|
||||
let name = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" };
|
||||
let mut cur = Some(dir);
|
||||
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
|
||||
let external = !dir.starts_with(&root);
|
||||
let mut cur = Some(dir.as_path());
|
||||
while let Some(d) = cur {
|
||||
if d.join(name).is_file() {
|
||||
return d.join(name).to_string_lossy().into_owned();
|
||||
}
|
||||
if d == root {
|
||||
if !external && d == root {
|
||||
break;
|
||||
}
|
||||
cur = d.parent();
|
||||
let Some(parent) = d.parent() else { break };
|
||||
// `parent` inside the opened project, or an ancestor of it (the common parent,
|
||||
// then the filesystem root). Stop before inspecting those.
|
||||
if external && (parent.starts_with(&root) || root.starts_with(parent)) {
|
||||
break;
|
||||
}
|
||||
cur = Some(parent);
|
||||
}
|
||||
if cfg!(windows) { "mvn.cmd".into() } else { "mvn".into() }
|
||||
}
|
||||
@@ -1104,24 +1123,118 @@ pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, profiles: &[St
|
||||
load_with_linked(root, local_repo, profiles, &[])
|
||||
}
|
||||
|
||||
/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`.
|
||||
/// The `pom.xml` files `linked` names. Each entry is used as written (project-relative,
|
||||
/// `../sibling/pom.xml`, or absolute) and canonicalized; a path that is not an existing
|
||||
/// `pom.xml` is skipped. Nothing here lists a directory or walks parents looking for builds.
|
||||
fn linked_poms(root: &Path, linked: &[String]) -> Vec<PathBuf> {
|
||||
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for pom in linked {
|
||||
let pom = normalized_pom(pom);
|
||||
if pom.is_empty() || pom.starts_with("../") || pom == ".." {
|
||||
continue;
|
||||
}
|
||||
let path = root.join(&pom);
|
||||
let inside = dunce::canonicalize(&path).is_ok_and(|p| p.starts_with(&base));
|
||||
if path.is_file() && inside && !out.contains(&path) {
|
||||
let Some(path) = dunce::canonicalize(base.join(pom.trim())).ok() else { continue };
|
||||
if is_pom_file(&path) && !out.contains(&path) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_pom_file(path: &Path) -> bool {
|
||||
path.is_file()
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| if cfg!(windows) { name.eq_ignore_ascii_case("pom.xml") } else { name == "pom.xml" })
|
||||
}
|
||||
|
||||
/// Identity of `path` (both canonical) within `root`: project-relative inside it,
|
||||
/// otherwise the absolute path with forward slashes.
|
||||
fn project_rel(root: &Path, path: &Path) -> String {
|
||||
path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
/// Canonical `pom.xml` paths named by `linked`, keyed by the spelling to store and to
|
||||
/// show so unlink removes the same entry settings hold. The first spelling wins.
|
||||
fn linked_identities(root: &Path, linked: &[String]) -> HashMap<PathBuf, String> {
|
||||
let mut out = HashMap::new();
|
||||
for entry in linked {
|
||||
let entry = entry.trim();
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = dunce::canonicalize(root.join(entry)).ok() else { continue };
|
||||
if is_pom_file(&path) {
|
||||
out.entry(path).or_insert_with(|| entry.replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Stable, deduplicated form of `poms` for `languages.java.maven.linkedProjects`.
|
||||
/// An existing `pom.xml` inside `root` is project-relative; one outside it is the
|
||||
/// canonical absolute path. Spellings of the same file (`../B/pom.xml`, an absolute
|
||||
/// path, a symlink) collapse to one entry. A path that exists and is not a `pom.xml`
|
||||
/// is dropped. A `pom.xml` that cannot be resolved yet is kept, so a link is not
|
||||
/// discarded while its volume is offline. No directory is scanned.
|
||||
pub fn normalize_linked_poms(root: &Path, poms: &[String]) -> Vec<String> {
|
||||
let Some(root) = dunce::canonicalize(root).ok() else {
|
||||
let mut out = Vec::new();
|
||||
for pom in poms {
|
||||
let pom = normalized_pom(pom);
|
||||
if !pom.is_empty() && pom_name_ok(&pom) && !out.contains(&pom) {
|
||||
out.push(pom);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for pom in poms {
|
||||
let pom = pom.trim();
|
||||
if pom.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = match dunce::canonicalize(root.join(pom)) {
|
||||
Ok(path) if is_pom_file(&path) => project_rel(&root, &path),
|
||||
Ok(_) => continue,
|
||||
Err(_) => {
|
||||
let fallback = normalized_pom(pom);
|
||||
let joined = root.join(pom);
|
||||
if !pom_name_ok(&fallback) || (joined.exists() && !is_pom_file(&joined)) {
|
||||
continue;
|
||||
}
|
||||
fallback
|
||||
}
|
||||
};
|
||||
if key.is_empty() || !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
out.push(key);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn pom_name_ok(path: &str) -> bool {
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
if cfg!(windows) { name.eq_ignore_ascii_case("pom.xml") } else { name == "pom.xml" }
|
||||
}
|
||||
|
||||
/// Directories of linked `pom.xml` files that sit outside `root` (the opened project
|
||||
/// stays the root). Callers keep the outermost directory only. Modules a POM declares
|
||||
/// outside its own directory are not included: a linked file cannot widen the indexed
|
||||
/// tree to an arbitrary path.
|
||||
pub fn external_pom_dirs(root: &Path, linked: &[String]) -> Vec<PathBuf> {
|
||||
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let mut out = Vec::new();
|
||||
for pom in linked_poms(root, linked) {
|
||||
let Some(dir) = pom.parent() else { continue };
|
||||
if dir.starts_with(&base) || out.iter().any(|have| have == dir) {
|
||||
continue;
|
||||
}
|
||||
out.push(dir.to_path_buf());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `root` holds a Maven build or the settings link one inside it.
|
||||
pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -> bool {
|
||||
is_maven_project(root) || !linked_poms(root, &settings.linked_projects).is_empty()
|
||||
@@ -1130,7 +1243,9 @@ pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -
|
||||
/// The model with the chosen `profiles` (`-P` entries; `!id` deactivates: their modules,
|
||||
/// dependencies and properties are part of the model, those turned off are not) and the
|
||||
/// `linked` POMs (Add Maven Projects): builds that no reactor lists, shown as independent
|
||||
/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module.
|
||||
/// top-level projects, including a `pom.xml` outside the opened root. A linked POM that
|
||||
/// turns out to be a module of the root reactor stays that module. The same canonical file
|
||||
/// is loaded once, so a cycle or a second spelling does not duplicate it or edit either POM.
|
||||
pub fn load_with_linked(
|
||||
root: &Path,
|
||||
local_repo: Option<PathBuf>,
|
||||
@@ -1139,7 +1254,10 @@ pub fn load_with_linked(
|
||||
) -> MavenModel {
|
||||
let mut resolver = Resolver::new(local_repo, profiles);
|
||||
let mut model = MavenModel::default();
|
||||
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||
// Canonical POM → its project, for a module listed again by a later aggregator.
|
||||
let mut seen: HashMap<PathBuf, Option<usize>> = HashMap::new();
|
||||
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let linked_ids = linked_identities(&canonical_root, linked);
|
||||
let mut tops = top_level_poms(root);
|
||||
for pom in linked_poms(root, linked) {
|
||||
if !tops.contains(&pom) {
|
||||
@@ -1147,17 +1265,31 @@ pub fn load_with_linked(
|
||||
}
|
||||
}
|
||||
// (pom, aggregated); the root reactor and its modules come before linked POMs.
|
||||
let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect();
|
||||
while let Some((path, aggregated)) = stack.pop() {
|
||||
// (pom, listed by): a module carries the top-level project its chain started from and
|
||||
// whether the aggregator listing it lies inside the opened root.
|
||||
let mut stack: Vec<(PathBuf, Option<(usize, bool)>)> = tops.into_iter().rev().map(|p| (p, None)).collect();
|
||||
while let Some((path, listed_by)) = stack.pop() {
|
||||
let aggregated = listed_by.is_some();
|
||||
if model.projects.len() >= MAX_PROJECTS {
|
||||
break;
|
||||
}
|
||||
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
if !seen.insert(canonical) {
|
||||
if let Some(known) = seen.get(&canonical) {
|
||||
// Found as a top-level POM first, then listed by an aggregator elsewhere
|
||||
// (`<module>../</module>` of `parent/pom.xml`): it is that aggregator's module.
|
||||
// Not when the chain leads back to where it started (a cycle), and not for an
|
||||
// aggregator outside the opened root: a linked sibling does not take it over.
|
||||
if let (Some(known), Some((origin, true))) = (*known, listed_by)
|
||||
&& known != origin
|
||||
{
|
||||
model.projects[known].aggregated = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
seen.insert(canonical.clone(), None);
|
||||
let Some(pom) = resolver.pom(&path) else { continue };
|
||||
let dir = path.parent().unwrap_or(root).to_path_buf();
|
||||
let dir_canon = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
|
||||
let ctx = resolver.context(&path, 0);
|
||||
let props = resolver.properties_for(&pom, &ctx, &dir);
|
||||
let i = |s: &str| interpolate(s, &props);
|
||||
@@ -1168,7 +1300,7 @@ pub fn load_with_linked(
|
||||
}
|
||||
let mut modules: Vec<PathBuf> = Vec::new();
|
||||
for m in module_names {
|
||||
let mut mp = dir.join(i(&m));
|
||||
let mut mp = crate::gradle::normalize(&dir.join(i(&m)));
|
||||
if mp.is_dir() || mp.extension().is_none_or(|e| e != "xml") {
|
||||
mp.push("pom.xml");
|
||||
}
|
||||
@@ -1232,28 +1364,39 @@ pub fn load_with_linked(
|
||||
}
|
||||
}
|
||||
let artifact_id = i(&pom.artifact_id);
|
||||
let module_ids: Vec<String> = modules
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let canonical = dunce::canonicalize(m).unwrap_or_else(|_| m.clone());
|
||||
linked_ids.get(&canonical).cloned().unwrap_or_else(|| project_rel(&canonical_root, &canonical))
|
||||
})
|
||||
.collect();
|
||||
model.projects.push(MavenProject {
|
||||
project_root: None,
|
||||
pom: rel_path(root, &path),
|
||||
dir: rel_path(root, &dir),
|
||||
pom: linked_ids.get(&canonical).cloned().unwrap_or_else(|| project_rel(&canonical_root, &canonical)),
|
||||
dir: project_rel(&canonical_root, &dir_canon),
|
||||
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
|
||||
version: i(props.get("project.version").map(String::as_str).unwrap_or_default()),
|
||||
display_name: pom.name.as_deref().map(i).filter(|n| !n.is_empty()).unwrap_or_else(|| artifact_id.clone()),
|
||||
artifact_id,
|
||||
packaging,
|
||||
parent: pom.parent.as_ref().map(|p| format!("{}:{}:{}", p.group_id, p.artifact_id, i(&p.version))),
|
||||
modules: modules.iter().map(|m| rel_path(root, m)).collect(),
|
||||
modules: module_ids,
|
||||
aggregated,
|
||||
plugins,
|
||||
dependencies,
|
||||
profiles: ctx.profiles.iter().map(|(id, _)| id.clone()).collect(),
|
||||
java_version: jv,
|
||||
command: wrapper(root, &dir),
|
||||
command: wrapper(&canonical_root, &dir_canon),
|
||||
options: Vec::new(),
|
||||
env: BTreeMap::new(),
|
||||
});
|
||||
let index = model.projects.len() - 1;
|
||||
seen.insert(canonical, Some(index));
|
||||
let origin = listed_by.map_or(index, |(origin, _)| origin);
|
||||
let inside = dir_canon.starts_with(&canonical_root);
|
||||
for m in modules.into_iter().rev() {
|
||||
stack.push((m, true));
|
||||
stack.push((m, Some((origin, inside))));
|
||||
}
|
||||
}
|
||||
model
|
||||
@@ -1341,17 +1484,27 @@ impl UnlinkedProjects {
|
||||
|
||||
fn normalized_pom(pom: &str) -> String {
|
||||
let pom = pom.replace('\\', "/");
|
||||
let mut parts = Vec::new();
|
||||
let unc = pom.starts_with("//");
|
||||
let absolute = pom.starts_with('/');
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for part in pom.split('/') {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
".." if parts.last().is_some_and(|p| *p != "..") => {
|
||||
// Keep a drive letter (`C:`) and a leading `..` that has nothing to climb.
|
||||
".." if parts.last().is_some_and(|p| *p != ".." && !p.ends_with(':')) => {
|
||||
parts.pop();
|
||||
}
|
||||
part => parts.push(part),
|
||||
}
|
||||
}
|
||||
parts.join("/")
|
||||
let joined = parts.join("/");
|
||||
if unc {
|
||||
format!("//{joined}")
|
||||
} else if absolute {
|
||||
format!("/{joined}")
|
||||
} else {
|
||||
joined
|
||||
}
|
||||
}
|
||||
|
||||
/// Every POM of the model (for change detection).
|
||||
@@ -1536,6 +1689,15 @@ pub enum PomGotoKind {
|
||||
ManagedDependency,
|
||||
/// A `<pluginManagement>` entry providing the plugin's version.
|
||||
ManagedPlugin,
|
||||
/// The POM of the artifact itself: a project module's, else the local repository's.
|
||||
ArtifactPom,
|
||||
}
|
||||
|
||||
/// A Maven project of the workspace, for going to a dependency on it.
|
||||
pub struct ModulePom {
|
||||
pub group_id: String,
|
||||
pub artifact_id: String,
|
||||
pub pom: PathBuf,
|
||||
}
|
||||
|
||||
/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
|
||||
@@ -1586,7 +1748,7 @@ fn property_ref_at(text: &str, at: usize) -> Option<(&str, usize, usize)> {
|
||||
}
|
||||
|
||||
/// The elements containing `at`, from the root down to the innermost one.
|
||||
fn element_path(root: &Element, at: usize) -> Vec<&Element> {
|
||||
pub(crate) fn element_path(root: &Element, at: usize) -> Vec<&Element> {
|
||||
let mut path = vec![root];
|
||||
loop {
|
||||
let Some(next) = path.last().unwrap().children.iter().find(|c| c.start <= at && at < c.end) else {
|
||||
@@ -1615,6 +1777,20 @@ fn coordinates(e: &Element, props: &HashMap<String, String>) -> Option<(String,
|
||||
/// the local repository when it is not in the project). `None` when there is no
|
||||
/// resolvable declaration here.
|
||||
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> {
|
||||
goto_declaration_in(text, path, pos, local_repo, Vec::new)
|
||||
}
|
||||
|
||||
/// [`goto_declaration`], where a `<parent>` and a dependency or plugin whose version no
|
||||
/// management entry provides (or a management entry itself) go to the artifact's own
|
||||
/// POM: the project module among `modules` with its coordinates, else the local
|
||||
/// repository's POM of its version.
|
||||
pub fn goto_declaration_in(
|
||||
text: &str,
|
||||
path: &Path,
|
||||
pos: Pos,
|
||||
local_repo: Option<PathBuf>,
|
||||
modules: impl FnOnce() -> Vec<ModulePom>,
|
||||
) -> Option<PomGoto> {
|
||||
let root = xml::parse(text).filter(|e| e.name == "project")?;
|
||||
let at = offset_at(text, pos);
|
||||
if let Some((name, _, _)) = property_ref_at(text, at)
|
||||
@@ -1625,15 +1801,56 @@ pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<Pa
|
||||
// A `<dependency>` / `<plugin>` in a declaration position: its version may come
|
||||
// from a management entry of this POM or an ancestor's.
|
||||
let elements = element_path(&root, at);
|
||||
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
|
||||
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
|
||||
return None; // Management entries are declarations; there is nothing to go to.
|
||||
if elements.get(1).is_some_and(|e| e.name == "parent") {
|
||||
let chain = parent_chain(path, text, local_repo);
|
||||
let (pom, parent_text) = chain.get(1)?;
|
||||
let label = elements[1].text_of("artifactId").unwrap_or_default();
|
||||
return Some(pom_goto(pom.clone(), parent_text, label));
|
||||
}
|
||||
let kind = match e.name.as_str() {
|
||||
"dependency" => PomGotoKind::ManagedDependency,
|
||||
_ => PomGotoKind::ManagedPlugin,
|
||||
};
|
||||
managed_target(text, path, e, kind, local_repo)
|
||||
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
|
||||
// A management entry declares the version: it goes to the artifact (a BOM import, say).
|
||||
if !elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
|
||||
let kind = match e.name.as_str() {
|
||||
"dependency" => PomGotoKind::ManagedDependency,
|
||||
_ => PomGotoKind::ManagedPlugin,
|
||||
};
|
||||
if let Some(found) = managed_target(text, path, e, kind, local_repo.clone()) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
artifact_target(text, path, e, local_repo, modules)
|
||||
}
|
||||
|
||||
/// The artifact's own POM for a dependency or plugin element `e`.
|
||||
fn artifact_target(
|
||||
text: &str,
|
||||
path: &Path,
|
||||
e: &Element,
|
||||
local_repo: Option<PathBuf>,
|
||||
modules: impl FnOnce() -> Vec<ModulePom>,
|
||||
) -> Option<PomGoto> {
|
||||
let mut resolver = Resolver::new(local_repo.clone(), &[]);
|
||||
let chain = parent_chain(path, text, local_repo);
|
||||
let props = properties_at(&mut resolver, &chain, 0);
|
||||
let (group, artifact) = coordinates(e, &props)?;
|
||||
let label = format!("{group}:{artifact}");
|
||||
if let Some(module) = modules().into_iter().find(|m| m.group_id == group && m.artifact_id == artifact) {
|
||||
let module_text = xml::read_text(&module.pom)?;
|
||||
return Some(pom_goto(module.pom, &module_text, label));
|
||||
}
|
||||
let version = interpolate(&e.text_of("version")?, &props);
|
||||
let pom = resolver.repo_pom(&group, &artifact, &version)?;
|
||||
let pom_text = xml::read_text(&pom)?;
|
||||
Some(pom_goto(pom, &pom_text, label))
|
||||
}
|
||||
|
||||
/// The POM at `path` as a target, at its `<artifactId>`.
|
||||
fn pom_goto(path: PathBuf, text: &str, label: String) -> PomGoto {
|
||||
let range = xml::parse(text)
|
||||
.filter(|e| e.name == "project")
|
||||
.and_then(|root| root.child("artifactId").map(|a| range_of(text, a)))
|
||||
.unwrap_or_default();
|
||||
PomGoto { path, range, kind: PomGotoKind::ArtifactPom, label }
|
||||
}
|
||||
|
||||
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
|
||||
@@ -1932,6 +2149,52 @@ mod tests {
|
||||
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_inputs_skip_generated_sources_under_the_project_build_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>\
|
||||
<packaging>pom</packaging><modules><module>app</module></modules></project>",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"app/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>app</artifactId><build><plugins><plugin>
|
||||
<groupId>org.codehaus.mojo</groupId><artifactId>build-helper-maven-plugin</artifactId>
|
||||
<executions><execution><configuration><sources>
|
||||
<source>${project.build.directory}/generated-sources/java</source>
|
||||
</sources></configuration></execution></executions>
|
||||
</plugin></plugins></build></project>"#,
|
||||
);
|
||||
let model = load_with_repo(root, None);
|
||||
let projects: Vec<&MavenProject> = model.projects.iter().collect();
|
||||
let inputs = launch_inputs(root, &projects, None).expect("portable launch inputs");
|
||||
assert!(!inputs.contains(&root.join("app/target/generated-sources/java")), "{inputs:?}");
|
||||
assert!(inputs.contains(&root.join("app/src")), "conventional sources stay fingerprinted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_inputs_disable_reuse_for_file_activation_on_the_build_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version>
|
||||
<build><directory>${project.basedir}/custom-build</directory></build>
|
||||
<profiles><profile><id>generated</id><activation><file>
|
||||
<exists>${project.build.directory}/enable-profile</exists>
|
||||
</file></activation></profile></profiles></project>"#,
|
||||
);
|
||||
let model = load_with_repo(root, None);
|
||||
let projects: Vec<&MavenProject> = model.projects.iter().collect();
|
||||
assert!(launch_inputs(root, &projects, None).is_none());
|
||||
}
|
||||
|
||||
fn write(dir: &Path, rel: &str, text: &str) {
|
||||
let p = dir.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
@@ -2147,6 +2410,169 @@ mod tests {
|
||||
assert_eq!(top(&load_linked(deep.path(), &settings)), [("a/b/c/tool".into(), false)]);
|
||||
}
|
||||
|
||||
/// A sibling Maven project is linked beside the opened root. Its reactor, including a
|
||||
/// module that points back at the opened project, is read once. Neither POM is rewritten,
|
||||
/// and only the linked project's directory is an extra content root — not the common
|
||||
/// parent and not a module that lives outside that directory.
|
||||
#[test]
|
||||
fn external_sibling_keeps_its_directory_and_does_not_edit_poms() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let a = base.join("A");
|
||||
let b = base.join("B");
|
||||
let c = base.join("C");
|
||||
std::fs::create_dir_all(a.join("core")).unwrap();
|
||||
std::fs::create_dir_all(b.join("mod")).unwrap();
|
||||
std::fs::create_dir_all(&c).unwrap();
|
||||
let a_pom = "<project><groupId>demo</groupId><artifactId>a</artifactId><version>1</version><packaging>pom</packaging><modules><module>core</module></modules></project>";
|
||||
let b_pom = "<project><groupId>demo</groupId><artifactId>b</artifactId><version>1</version><packaging>pom</packaging><modules><module>mod</module><module>../A</module><module>../C</module><module>.</module></modules></project>";
|
||||
let c_pom = "<project><groupId>demo</groupId><artifactId>c</artifactId><version>1</version><modules><module>../B</module></modules></project>";
|
||||
std::fs::write(a.join("pom.xml"), a_pom).unwrap();
|
||||
std::fs::write(a.join("core/pom.xml"), "<project><artifactId>core</artifactId></project>").unwrap();
|
||||
std::fs::write(b.join("pom.xml"), b_pom).unwrap();
|
||||
std::fs::write(b.join("mod/pom.xml"), "<project><artifactId>bmod</artifactId></project>").unwrap();
|
||||
std::fs::write(c.join("pom.xml"), c_pom).unwrap();
|
||||
let wrapper = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" };
|
||||
std::fs::write(a.join(wrapper), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(b.join(wrapper), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(base.join(wrapper), "#!/bin/sh\n").unwrap();
|
||||
|
||||
let discovered = load_with_linked(&a, None, &[], &[]);
|
||||
assert_eq!(discovered.projects.iter().map(|p| p.artifact_id.as_str()).collect::<Vec<_>>(), ["a", "core"]);
|
||||
|
||||
let stored = vec!["../B/pom.xml".into()];
|
||||
let linked = load_with_linked(&a, None, &[], &stored);
|
||||
let b_project = linked.projects.iter().find(|p| p.artifact_id == "b").unwrap();
|
||||
assert!(!b_project.aggregated);
|
||||
assert_eq!(b_project.pom, "../B/pom.xml");
|
||||
assert_eq!(dunce::canonicalize(a.join(&b_project.dir)).unwrap(), b);
|
||||
assert_eq!(dunce::canonicalize(&b_project.command).unwrap(), dunce::canonicalize(b.join(wrapper)).unwrap());
|
||||
let a_project = linked.projects.iter().find(|p| p.artifact_id == "a").unwrap();
|
||||
assert!(!a_project.aggregated);
|
||||
assert_eq!(a_project.modules, ["core/pom.xml"]);
|
||||
assert_eq!(dunce::canonicalize(&a_project.command).unwrap(), dunce::canonicalize(a.join(wrapper)).unwrap());
|
||||
assert_eq!(linked.projects.iter().filter(|p| p.artifact_id == "a").count(), 1);
|
||||
assert_eq!(linked.projects.iter().filter(|p| p.artifact_id == "b").count(), 1);
|
||||
assert!(linked.projects.iter().any(|p| p.artifact_id == "c" && p.aggregated));
|
||||
assert!(linked.projects.iter().any(|p| p.artifact_id == "bmod" && p.aggregated));
|
||||
let again = load_with_linked(&a, None, &[], &stored);
|
||||
assert_eq!(
|
||||
again.projects.iter().map(|p| &p.pom).collect::<Vec<_>>(),
|
||||
linked.projects.iter().map(|p| &p.pom).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let absolute = b.join("pom.xml").to_string_lossy().replace('\\', "/");
|
||||
let via_a = a.join("../B/pom.xml").to_string_lossy().replace('\\', "/");
|
||||
let normalized = normalize_linked_poms(
|
||||
&a,
|
||||
&[
|
||||
"../B/pom.xml".into(),
|
||||
absolute,
|
||||
via_a,
|
||||
a.join("pom.xml").to_string_lossy().into_owned(),
|
||||
b.to_string_lossy().into_owned(),
|
||||
"stub/pom.xml".into(),
|
||||
"notes.xml".into(),
|
||||
],
|
||||
);
|
||||
assert_eq!(normalized.len(), 3, "{normalized:?}");
|
||||
assert_eq!(normalized[0], dunce::canonicalize(b.join("pom.xml")).unwrap().to_string_lossy().replace('\\', "/"));
|
||||
assert_eq!(normalized[1], "pom.xml");
|
||||
assert_eq!(normalized[2], "stub/pom.xml");
|
||||
assert_eq!(crate::maven_external_roots(&a, &normalized), vec![b.clone()]);
|
||||
assert_eq!(crate::maven_external_roots(&a, &stored), vec![b.clone()]);
|
||||
|
||||
let removed = load_with_linked(&a, None, &[], &[]);
|
||||
assert!(removed.projects.iter().all(|p| p.artifact_id != "b"));
|
||||
assert_eq!(std::fs::read_to_string(a.join("pom.xml")).unwrap(), a_pom);
|
||||
assert_eq!(std::fs::read_to_string(b.join("pom.xml")).unwrap(), b_pom);
|
||||
assert_eq!(std::fs::read_to_string(c.join("pom.xml")).unwrap(), c_pom);
|
||||
assert!(b.join("mod/pom.xml").is_file());
|
||||
}
|
||||
|
||||
/// The launcher recorded on an external project is that project's wrapper, and
|
||||
/// running it uses the external directory as the working directory.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn external_project_wrapper_runs_in_its_own_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let a = base.join("A");
|
||||
let b = base.join("B");
|
||||
std::fs::create_dir_all(b.join("src/main/java")).unwrap();
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::write(a.join("pom.xml"), "<project><artifactId>a</artifactId></project>").unwrap();
|
||||
std::fs::write(
|
||||
b.join("pom.xml"),
|
||||
"<project><artifactId>b</artifactId><build><sourceDirectory>src/main/java</sourceDirectory></build></project>",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(b.join("src/main/java/B.java"), "class B {}").unwrap();
|
||||
let script = b.join("mvnw");
|
||||
std::fs::write(&script, "#!/bin/sh\npwd > \"$1\"\nprintf 'BUILD SUCCESS\\n'\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let model = load_with_linked(&a, None, &[], &["../B/pom.xml".into()]);
|
||||
let project = model.projects.iter().find(|p| p.artifact_id == "b").unwrap();
|
||||
let cwd = a.join(&project.dir);
|
||||
assert_eq!(dunce::canonicalize(&cwd).unwrap(), b);
|
||||
let marker = tmp.path().join("cwd");
|
||||
let out = crate::process::run(
|
||||
&project.command,
|
||||
&[marker.to_string_lossy().into_owned()],
|
||||
&cwd,
|
||||
&std::collections::BTreeMap::new(),
|
||||
&ide_core::CancellationToken::new(),
|
||||
|_, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(out.success, "{}", out.text);
|
||||
let recorded = dunce::canonicalize(std::fs::read_to_string(&marker).unwrap().trim()).unwrap();
|
||||
assert_eq!(recorded, b);
|
||||
}
|
||||
|
||||
/// #2877: an aggregator in a subfolder lists the root POM as `../`; linked, it holds every
|
||||
/// module, the root one included, under the same POM paths the projects have.
|
||||
#[test]
|
||||
fn an_aggregator_in_a_subfolder_holds_the_root_pom() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let child = |id: &str, rel: &str| {
|
||||
format!(
|
||||
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><relativePath>{rel}</relativePath></parent><artifactId>{id}</artifactId></project>"
|
||||
)
|
||||
};
|
||||
write(root, "pom.xml", &child("app", "parent/pom.xml"));
|
||||
write(
|
||||
root,
|
||||
"parent/pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>../</module><module>../infra</module></modules></project>",
|
||||
);
|
||||
write(root, "infra/pom.xml", &child("infra", "../parent/pom.xml"));
|
||||
|
||||
let model = load_with_linked(root, None, &[], &["parent/pom.xml".into()]);
|
||||
let tops: Vec<&str> = model.projects.iter().filter(|p| !p.aggregated).map(|p| p.artifact_id.as_str()).collect();
|
||||
assert_eq!(tops, ["parent"]);
|
||||
let parent = model.projects.iter().find(|p| p.artifact_id == "parent").unwrap();
|
||||
assert_eq!(parent.modules, ["pom.xml", "infra/pom.xml"]);
|
||||
for pom in &parent.modules {
|
||||
assert!(model.projects.iter().any(|p| &p.pom == pom), "{pom}");
|
||||
}
|
||||
|
||||
// A cycle (the root lists the aggregator, which lists the root) keeps the root on top.
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>parent</module></modules></project>",
|
||||
);
|
||||
let model = load_with_linked(root, None, &[], &[]);
|
||||
let tops: Vec<&str> = model.projects.iter().filter(|p| !p.aggregated).map(|p| p.artifact_id.as_str()).collect();
|
||||
assert_eq!(tops, ["app"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_profiles_bring_their_modules_and_dependencies() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -2780,9 +3206,9 @@ mod tests {
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
// The property is declared nowhere along the chain.
|
||||
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None);
|
||||
// The dependency names its own version: it is the declaration.
|
||||
// The dependency names its own version and no local repository has its POM.
|
||||
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
|
||||
// A management entry is a declaration itself, not a reference.
|
||||
// A management entry declares the version; its artifact's POM is not at hand either.
|
||||
let parent_path = tmp.path().join("pom.xml");
|
||||
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
|
||||
assert_eq!(
|
||||
@@ -2794,6 +3220,72 @@ mod tests {
|
||||
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifacts_go_to_their_own_pom_in_the_project_or_the_local_repository() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
let bom_dir = repo.join("com/alibaba/cloud/spring-cloud-alibaba-dependencies/2.2.6.RELEASE");
|
||||
std::fs::create_dir_all(&bom_dir).unwrap();
|
||||
let bom = "<project>\n <groupId>com.alibaba.cloud</groupId>\n <artifactId>spring-cloud-alibaba-dependencies</artifactId>\n</project>";
|
||||
std::fs::write(bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"), bom).unwrap();
|
||||
let parent = format!(
|
||||
r#"{PARENT_HEADER}<properties><alibaba.version>2.2.6.RELEASE</alibaba.version></properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
|
||||
<version>${{alibaba.version}}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</project>"#
|
||||
);
|
||||
let child = format!(
|
||||
r#"{CHILD_HEADER}<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>1.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>"#
|
||||
);
|
||||
write_parent_child(tmp.path(), &parent, &child);
|
||||
let core = tmp.path().join("core/pom.xml");
|
||||
std::fs::create_dir_all(core.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&core,
|
||||
"<project>\n <groupId>com.example</groupId>\n <artifactId>core</artifactId>\n</project>",
|
||||
)
|
||||
.unwrap();
|
||||
let modules =
|
||||
|| vec![ModulePom { group_id: "com.example".into(), artifact_id: "core".into(), pom: core.clone() }];
|
||||
|
||||
// A BOM import in <dependencyManagement> opens the BOM from the local repository.
|
||||
let parent_path = tmp.path().join("pom.xml");
|
||||
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
|
||||
let at_bom = at(&parent_text, "spring-cloud-alibaba-dependencies<");
|
||||
let goto = goto_declaration_in(&parent_text, &parent_path, at_bom, Some(repo.clone()), modules).unwrap();
|
||||
assert_eq!(goto.path, bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"));
|
||||
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
|
||||
assert_eq!(goto.range.start, pos_at(bom, bom.find("<artifactId>").unwrap()));
|
||||
|
||||
// A dependency on a project module opens the module's POM.
|
||||
let path = tmp.path().join("app/pom.xml");
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let goto =
|
||||
goto_declaration_in(&text, &path, at(&text, "core</artifactId>"), Some(repo.clone()), modules).unwrap();
|
||||
assert_eq!((goto.path.as_path(), goto.label.as_str()), (core.as_path(), "com.example:core"));
|
||||
|
||||
// The <parent> section opens the parent POM.
|
||||
let goto = goto_declaration_in(&text, &path, at(&text, "parent</artifactId>"), Some(repo), modules).unwrap();
|
||||
assert_eq!(goto.path, canon(&parent_path));
|
||||
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_property_reference_after_non_ascii_text_is_still_found() {
|
||||
let text = format!(
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
//! Completion of Maven coordinates in a POM: inside a `<dependency>`, the
|
||||
//! `<groupId>`, `<artifactId>` and `<version>` values are completed from the
|
||||
//! local repository layout (`<repo>/<group path>/<artifactId>/<version>/…`).
|
||||
//! The Maven Central search endpoint tops the lists up remotely; the host op
|
||||
//! fetches those (cached) and merges them behind the local ones, so this side
|
||||
//! stays pure text and directory logic and tests build their own repositories.
|
||||
|
||||
use super::xml::{self, Element};
|
||||
use super::{element_path, interpolate, read_properties};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The coordinate element a POM completion fills.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PomCompletionKind {
|
||||
/// The dependency's `<groupId>`.
|
||||
GroupId,
|
||||
/// The dependency's `<artifactId>`.
|
||||
ArtifactId,
|
||||
/// The dependency's `<version>`.
|
||||
Version,
|
||||
}
|
||||
|
||||
/// One suggestion: the coordinate as inserted, and an optional note shown in
|
||||
/// the lookup (the owning group of an artifactId, its newest installed version).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PomCompletionOption {
|
||||
/// The text the completion inserts (`org.example.lib`, `example-lib`, `1.2.0`).
|
||||
pub label: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// The completion offered at a caret of a POM: the replaced value span (UTF-16
|
||||
/// offsets) and the suggestions, local ones first.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PomCompletion {
|
||||
/// UTF-16 offset where the completed text starts: the value's first character.
|
||||
pub from: u32,
|
||||
/// UTF-16 offset where the replaced value ends: past the caret when the caret
|
||||
/// sits inside an existing value, which is then replaced whole.
|
||||
pub to: u32,
|
||||
/// Which coordinate element the caret is in.
|
||||
pub kind: PomCompletionKind,
|
||||
/// The suggestions, local repository first.
|
||||
pub options: Vec<PomCompletionOption>,
|
||||
}
|
||||
|
||||
/// Where the caret sits in a `<dependency>` coordinate element: what is being
|
||||
/// completed, the typed prefix, and the sibling coordinates the rest need.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PomCompletionTarget {
|
||||
/// UTF-16 offset of the value's first character (the caret in an empty value).
|
||||
pub from: u32,
|
||||
/// UTF-16 offset past the value's last character (the caret in an empty value).
|
||||
pub to: u32,
|
||||
/// The value typed so far: the text between `from` and the caret, trimmed.
|
||||
pub typed: String,
|
||||
pub kind: PomCompletionKind,
|
||||
/// The interpolated `<groupId>` sibling, when completing the artifactId and
|
||||
/// the group is written out plain (no unresolved `${property}`).
|
||||
pub group: Option<String>,
|
||||
/// The interpolated `<artifactId>` sibling, when completing the version.
|
||||
pub artifact: Option<String>,
|
||||
}
|
||||
|
||||
/// A coordinate element's text, with the POM's own `${properties}` replaced;
|
||||
/// `None` when the element is missing or a property stays unresolved (the
|
||||
/// coordinates behind it are not known without the parent chain, which the
|
||||
/// completion does not follow).
|
||||
fn coordinate(e: Option<&Element>, props: &HashMap<String, String>) -> Option<String> {
|
||||
let text = interpolate(e?.text.trim(), props);
|
||||
(!text.is_empty() && !text.contains("${")).then_some(text)
|
||||
}
|
||||
|
||||
fn own_properties(root: &Element) -> HashMap<String, String> {
|
||||
read_properties(root.child("properties")).into_iter().collect()
|
||||
}
|
||||
|
||||
/// UTF-16 offset -> byte offset, clamped to `text`.
|
||||
fn utf16_to_byte(text: &str, offset: u32) -> usize {
|
||||
let mut seen = 0u32;
|
||||
for (byte, c) in text.char_indices() {
|
||||
if seen >= offset {
|
||||
return byte;
|
||||
}
|
||||
seen += c.len_utf16() as u32;
|
||||
}
|
||||
text.len()
|
||||
}
|
||||
|
||||
/// Byte offset -> UTF-16 offset.
|
||||
fn utf16_at(text: &str, byte: usize) -> u32 {
|
||||
text[..byte].encode_utf16().count() as u32
|
||||
}
|
||||
|
||||
/// Byte offset just past the start tag's `>` of the element at `start`.
|
||||
fn start_tag_end(text: &str, start: usize) -> Option<usize> {
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = start.checked_add(1)?;
|
||||
let mut quote = None;
|
||||
while i < bytes.len() {
|
||||
match (quote, bytes[i]) {
|
||||
(Some(q), b) if b == q => quote = None,
|
||||
(Some(_), _) => {}
|
||||
(None, b'"' | b'\'') => quote = Some(bytes[i]),
|
||||
(None, b'>') => return Some(i + 1),
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// What to complete at the caret (`offset`, UTF-16) of the POM `text`: the
|
||||
/// `<groupId>` / `<artifactId>` / `<version>` element of a `<dependency>` the
|
||||
/// caret sits in the value of. `None` everywhere else — other elements, carets
|
||||
/// in an end tag or a `${property}` reference, dependencies without the
|
||||
/// `<groupId>` (and, for versions, the `<artifactId>`) their completions need.
|
||||
pub fn completion_target(text: &str, offset: u32) -> Option<PomCompletionTarget> {
|
||||
let root = xml::parse(text).filter(|e| e.name == "project")?;
|
||||
let at = utf16_to_byte(text, offset);
|
||||
// A caret at the very end of the input sits in the element still being
|
||||
// typed, whose range reaches to the input's end; step back one character
|
||||
// so `element_path` sees it (the caret's own offset stays `at`).
|
||||
let probe = if at == text.len() && at > 0 {
|
||||
let mut p = at - 1;
|
||||
while p > 0 && !text.is_char_boundary(p) {
|
||||
p -= 1;
|
||||
}
|
||||
p
|
||||
} else {
|
||||
at
|
||||
};
|
||||
let elements = element_path(&root, probe);
|
||||
let e = *elements.last()?;
|
||||
let parent = elements.get(elements.len().checked_sub(2)?)?;
|
||||
let kind = match e.name.as_str() {
|
||||
"groupId" => PomCompletionKind::GroupId,
|
||||
"artifactId" => PomCompletionKind::ArtifactId,
|
||||
"version" => PomCompletionKind::Version,
|
||||
_ => return None,
|
||||
};
|
||||
if parent.name != "dependency" || at > e.inner_end {
|
||||
return None;
|
||||
}
|
||||
let inner_start = start_tag_end(text, e.start)?;
|
||||
let value = &text[inner_start..e.inner_end.max(inner_start)];
|
||||
if value.contains("${") {
|
||||
return None; // Property references are goto-declaration territory, not completion.
|
||||
}
|
||||
let lead = value.len() - value.trim_start().len();
|
||||
let trail = value.len() - value.trim_end().len();
|
||||
let (mut from, mut to) = (inner_start + lead, e.inner_end.max(inner_start) - trail);
|
||||
if from > to {
|
||||
// A value of whitespace only: complete at the caret.
|
||||
from = at.min(to);
|
||||
to = to.max(from);
|
||||
}
|
||||
from = from.min(at);
|
||||
to = to.max(from);
|
||||
let props = own_properties(&root);
|
||||
let group = (kind != PomCompletionKind::GroupId).then(|| coordinate(parent.child("groupId"), &props)).flatten();
|
||||
let artifact =
|
||||
(kind == PomCompletionKind::Version).then(|| coordinate(parent.child("artifactId"), &props)).flatten();
|
||||
let typed = text[from..at].trim().to_owned();
|
||||
Some(PomCompletionTarget { from: utf16_at(text, from), to: utf16_at(text, to), typed, kind, group, artifact })
|
||||
}
|
||||
|
||||
/// The suggestions the local repository at `repo` offers for `target`: group
|
||||
/// ids installed under it, the artifacts of the target's group, or the versions
|
||||
/// of the target's artifact. Every scan is bounded (see `SCAN_BUDGET`) and
|
||||
/// reads only directory listings of `repo` — nothing outside it, no jar
|
||||
/// contents.
|
||||
pub fn local_options(repo: &Path, target: &PomCompletionTarget) -> Vec<PomCompletionOption> {
|
||||
let mut budget = SCAN_BUDGET;
|
||||
match target.kind {
|
||||
// `org.spr` completes the `spr` part under `org/`, `org.` the next
|
||||
// segment under `org/`; every typed segment is validated so POM text
|
||||
// cannot walk out of the repository.
|
||||
PomCompletionKind::GroupId => {
|
||||
let segments: Vec<&str> = target.typed.split('.').collect();
|
||||
let (partial, base) = segments.split_last().unwrap_or((&"", &[]));
|
||||
if base.iter().all(|s| segment(s)) {
|
||||
let dir = base.iter().fold(repo.to_path_buf(), |p, s| p.join(s));
|
||||
scan_groups(&dir, &base.join("."), partial, &mut budget)
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
PomCompletionKind::ArtifactId => target
|
||||
.group
|
||||
.as_deref()
|
||||
.and_then(|g| repo_dir(repo, &[g]))
|
||||
.map_or_else(Vec::new, |dir| scan_artifacts(&dir, &target.typed, &mut budget)),
|
||||
PomCompletionKind::Version => target
|
||||
.group
|
||||
.as_deref()
|
||||
.zip(target.artifact.as_deref())
|
||||
.and_then(|(g, a)| repo_dir(repo, &[g, a]))
|
||||
.map_or_else(Vec::new, |dir| scan_versions(&dir, &target.typed, &mut budget)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory listings one completion may read: the scans are interactive, so
|
||||
/// they stop before they could be felt, and a huge repository simply yields
|
||||
/// fewer suggestions (its shallowest first — the walk is breadth-preferring).
|
||||
const SCAN_BUDGET: u32 = 2048;
|
||||
/// Suggestion caps per kind: group ids and artifact ids fill a lookup page,
|
||||
/// versions list an artifact's releases.
|
||||
const MAX_GROUP_OPTIONS: usize = 60;
|
||||
const MAX_ARTIFACT_OPTIONS: usize = 60;
|
||||
const MAX_VERSION_OPTIONS: usize = 100;
|
||||
/// The cap the merged list (local plus Maven Central's) is truncated to.
|
||||
pub const MAX_OPTIONS: usize = 100;
|
||||
|
||||
/// A directory entry whose name passes the repository layout's alphabet
|
||||
/// (mirrors `sources::artifact`'s): letters, digits, `._-`. Path separators
|
||||
/// and `..` never pass, so a POM's `${…}`-free text cannot traverse out of
|
||||
/// the repository.
|
||||
fn segment(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
|
||||
}
|
||||
|
||||
/// The subdirectories of `dir` with repository-layout names, spending `budget`
|
||||
/// on the listing; `None` when the budget ran out before `dir` was listed.
|
||||
fn subdirs(dir: &Path, budget: &mut u32) -> Option<Vec<String>> {
|
||||
if *budget == 0 {
|
||||
return None;
|
||||
}
|
||||
*budget -= 1;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Some(Vec::new());
|
||||
};
|
||||
let mut names = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
// The budget bounds file-type checks too: a plain listing answers.
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false)
|
||||
&& let Ok(name) = entry.file_name().into_string()
|
||||
&& segment(&name)
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
|
||||
/// Whether `dir` holds files — a version directory does (jar, pom, the
|
||||
/// tracking files), a group path segment never does.
|
||||
fn holds_files(dir: &Path, budget: &mut u32) -> bool {
|
||||
if *budget == 0 {
|
||||
return false;
|
||||
}
|
||||
*budget -= 1;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Whether the version directory `dir` holds the version: its `.pom` or a `.jar`. A failed
|
||||
/// download leaves only `*.lastUpdated` tracking files behind, for a version that may not
|
||||
/// even exist (one typed while the POM was being edited).
|
||||
fn installed(dir: &Path, budget: &mut u32) -> bool {
|
||||
if *budget == 0 {
|
||||
return false;
|
||||
}
|
||||
*budget -= 1;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
e.file_type().map(|t| t.is_file()).unwrap_or(false)
|
||||
&& e.file_name().to_str().is_some_and(|n| n.ends_with(".pom") || n.ends_with(".jar"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `dir` is an artifact directory: it holds versions of one artifact,
|
||||
/// recognizable by a `maven-metadata*.xml` beside them or a child directory
|
||||
/// holding files. A group segment's children are directories without files.
|
||||
fn artifact_dir(dir: &Path, budget: &mut u32) -> bool {
|
||||
if *budget == 0 {
|
||||
return false;
|
||||
}
|
||||
*budget -= 1;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(name) = entry.file_name().into_string() else { continue };
|
||||
if name.starts_with("maven-metadata") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let Some(children) = subdirs(dir, budget) else { return false };
|
||||
children.iter().any(|c| holds_files(&dir.join(c), budget))
|
||||
}
|
||||
|
||||
/// Whether `dir` is a group directory (holds artifact directories) and whether
|
||||
/// it also holds deeper group segments worth descending into.
|
||||
fn group_dir(dir: &Path, budget: &mut u32) -> Option<(bool, bool)> {
|
||||
let children = subdirs(dir, budget)?;
|
||||
let (mut group, mut descend) = (false, false);
|
||||
for child in &children {
|
||||
if artifact_dir(&dir.join(child), budget) {
|
||||
group = true;
|
||||
} else {
|
||||
descend = true;
|
||||
}
|
||||
}
|
||||
Some((group, descend))
|
||||
}
|
||||
|
||||
/// The group ids installed under `dir` (which the typed prefix `prefix` —
|
||||
/// "" for the repository root — already names), completing the partial last
|
||||
/// segment `partial`, shallower groups first.
|
||||
fn scan_groups(dir: &Path, prefix: &str, partial: &str, budget: &mut u32) -> Vec<PomCompletionOption> {
|
||||
let mut out = Vec::new();
|
||||
scan_groups_into(dir, prefix, partial, &mut out, budget);
|
||||
out.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
out
|
||||
}
|
||||
|
||||
fn scan_groups_into(dir: &Path, prefix: &str, partial: &str, out: &mut Vec<PomCompletionOption>, budget: &mut u32) {
|
||||
if out.len() >= MAX_GROUP_OPTIONS {
|
||||
return;
|
||||
}
|
||||
let Some(children) = subdirs(dir, budget) else { return };
|
||||
for name in children {
|
||||
if !name.starts_with(partial) {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(&name);
|
||||
let label = if prefix.is_empty() { name.clone() } else { format!("{prefix}.{name}") };
|
||||
let Some((group, descend)) = group_dir(&path, budget) else { return };
|
||||
if group {
|
||||
out.push(PomCompletionOption { label: label.clone(), detail: None });
|
||||
if out.len() >= MAX_GROUP_OPTIONS {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Deeper groups (org.example under org.example's own parent) follow the
|
||||
// shallower ones, so a truncated scan of a huge repository still offers
|
||||
// every first-level segment.
|
||||
if descend {
|
||||
scan_groups_into(&path, &label, "", out, budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The artifact ids installed under the group directory `dir`, completing the
|
||||
/// typed prefix, each with its newest installed version as the detail.
|
||||
fn scan_artifacts(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletionOption> {
|
||||
let Some(children) = subdirs(dir, budget) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for name in children {
|
||||
if !name.starts_with(typed) || !artifact_dir(&dir.join(&name), budget) {
|
||||
continue;
|
||||
}
|
||||
let detail = newest_version(&dir.join(&name), budget);
|
||||
out.push(PomCompletionOption { label: name, detail });
|
||||
}
|
||||
out.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
out.truncate(MAX_ARTIFACT_OPTIONS);
|
||||
out
|
||||
}
|
||||
|
||||
/// The newest version directory of the artifact at `dir`, by Maven's ordering.
|
||||
fn newest_version(dir: &Path, budget: &mut u32) -> Option<String> {
|
||||
let children = subdirs(dir, budget)?;
|
||||
children.into_iter().filter(|name| installed(&dir.join(name), budget)).max_by(|a, b| compare_versions(a, b))
|
||||
}
|
||||
|
||||
/// The versions installed of the artifact at `dir`, completing the typed
|
||||
/// prefix, newest first.
|
||||
fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletionOption> {
|
||||
let Some(children) = subdirs(dir, budget) else { return Vec::new() };
|
||||
let mut out: Vec<_> = children
|
||||
.into_iter()
|
||||
.filter(|name| name.starts_with(typed) && installed(&dir.join(name), budget))
|
||||
.map(|name| PomCompletionOption { label: name, detail: None })
|
||||
.collect();
|
||||
out.sort_by(|a, b| compare_versions(&b.label, &a.label));
|
||||
out.truncate(MAX_VERSION_OPTIONS);
|
||||
out
|
||||
}
|
||||
|
||||
/// The repository directory of `coordinates` (a group id, or a group and an artifact
|
||||
/// id): the group's dots are directories, an artifact id is one directory whatever dots
|
||||
/// it has (`org/eclipse/platform/org.eclipse.core.runtime`). Each segment is validated
|
||||
/// against the layout's alphabet so POM text cannot walk outside `repo`.
|
||||
fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> {
|
||||
let (group, artifact) = coordinates.split_first()?;
|
||||
let segments: Vec<&str> = group.split('.').chain(artifact.iter().copied()).collect();
|
||||
segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s)))
|
||||
}
|
||||
|
||||
/// Maven-ish version order, best-effort (a full `ComparableVersion` is out of
|
||||
/// proportion for ordering a lookup): numeric runs compare numerically, letter
|
||||
/// runs by their qualifier rank (`alpha < beta < m < rc < snapshot < release <
|
||||
/// sp`, unknown ones with releases lexically); missing parts rank as zero.
|
||||
fn compare_versions(a: &str, b: &str) -> Ordering {
|
||||
fn tokens(v: &str) -> Vec<String> {
|
||||
v.split(|c: char| !c.is_ascii_alphanumeric()).filter(|t| !t.is_empty()).map(str::to_ascii_lowercase).collect()
|
||||
}
|
||||
fn rank(t: &str) -> Option<u8> {
|
||||
match t {
|
||||
"a" | "alpha" => Some(0),
|
||||
"b" | "beta" => Some(1),
|
||||
"m" | "milestone" => Some(2),
|
||||
"rc" | "cr" => Some(3),
|
||||
"snapshot" => Some(4),
|
||||
"" | "ga" | "final" => Some(5),
|
||||
"sp" => Some(6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let (a, b) = (tokens(a), tokens(b));
|
||||
for i in 0..a.len().max(b.len()) {
|
||||
let (x, y) = (a.get(i).map(String::as_str), b.get(i).map(String::as_str));
|
||||
let (xn, yn) = (x.and_then(|s| s.parse::<u64>().ok()), y.and_then(|s| s.parse::<u64>().ok()));
|
||||
let order = match (xn, yn) {
|
||||
// Numbers compare numerically; a number outranks a qualifier.
|
||||
(Some(xn), Some(yn)) => xn.cmp(&yn),
|
||||
(Some(_), None) if y.is_some() => return Ordering::Greater,
|
||||
(None, Some(_)) if x.is_some() => return Ordering::Less,
|
||||
_ => {
|
||||
let xr = x.and_then(rank).unwrap_or(5);
|
||||
let yr = y.and_then(rank).unwrap_or(5);
|
||||
if xr != yr { xr.cmp(&yr) } else { x.unwrap_or("").cmp(y.unwrap_or("")) }
|
||||
}
|
||||
};
|
||||
if order != Ordering::Equal {
|
||||
return order;
|
||||
}
|
||||
}
|
||||
Ordering::Equal
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A small local repository: every file is laid out as Maven installs it,
|
||||
/// so the scans recognize group, artifact and version directories.
|
||||
fn repo(dir: &Path) -> PathBuf {
|
||||
let repo = dir.join("repository");
|
||||
for file in [
|
||||
"org/apache/maven/maven-core/4.0.0-rc-5/maven-core-4.0.0-rc-5.jar",
|
||||
"org/apache/maven/plugins/maven-compiler-plugin/3.8.1/maven-compiler-plugin-3.8.1.jar",
|
||||
"org/example/lib/1.0/lib-1.0.jar",
|
||||
"org/example/lib/1.9.1/lib-1.9.1.jar",
|
||||
"org/example/lib/1.10/lib-1.10.jar",
|
||||
"org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar",
|
||||
"org/example/lib/2.0/lib-2.0.pom",
|
||||
"net/nu/netty/4.1.100/netty-4.1.100.jar",
|
||||
"org/eclipse/platform/org.eclipse.core.runtime/3.31.0/org.eclipse.core.runtime-3.31.0.jar",
|
||||
// Failed downloads (a version typed while editing): tracking files only, not installed.
|
||||
"org/example/lib/2/lib-2.jar.lastUpdated",
|
||||
"org/example/lib/3.0/lib-3.0.pom.lastUpdated",
|
||||
] {
|
||||
let path = repo.join(file);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
}
|
||||
repo
|
||||
}
|
||||
|
||||
fn target(text: &str, needle: &str) -> Option<PomCompletionTarget> {
|
||||
completion_target(text, (text.find(needle).unwrap() + needle.len()) as u32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_each_coordinate_element_of_a_dependency() {
|
||||
let text = "<project><dependencies><dependency>\
|
||||
<groupId>org.example</groupId><artifactId>lib</artifactId><version>1.0</version>\
|
||||
</dependency></dependencies></project>";
|
||||
let t = target(text, "<groupId>org.example").unwrap();
|
||||
assert_eq!(t.kind, PomCompletionKind::GroupId);
|
||||
assert_eq!(t.typed, "org.example");
|
||||
assert_eq!((t.from, t.to), (44, 55));
|
||||
assert!(t.group.is_none() && t.artifact.is_none());
|
||||
|
||||
let t = target(text, "<artifactId>lib").unwrap();
|
||||
assert_eq!(t.kind, PomCompletionKind::ArtifactId);
|
||||
assert_eq!(t.typed, "lib");
|
||||
assert_eq!(t.group.as_deref(), Some("org.example"));
|
||||
assert!(t.artifact.is_none());
|
||||
|
||||
let t = target(text, "<version>1.0").unwrap();
|
||||
assert_eq!(t.kind, PomCompletionKind::Version);
|
||||
assert_eq!(t.typed, "1.0");
|
||||
assert_eq!(t.group.as_deref(), Some("org.example"));
|
||||
assert_eq!(t.artifact.as_deref(), Some("lib"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_while_the_element_is_being_typed_and_the_value_is_empty() {
|
||||
let empty = "<project><dependencies><dependency><groupId></groupId></dependency></dependencies></project>";
|
||||
let t = target(empty, "<groupId>").unwrap();
|
||||
assert_eq!(t.kind, PomCompletionKind::GroupId);
|
||||
assert_eq!(t.typed, "");
|
||||
assert_eq!(t.from, t.to);
|
||||
|
||||
let open = "<project><dependencies><dependency><artifactId>li";
|
||||
let t = target(open, "li").unwrap();
|
||||
assert_eq!(t.kind, PomCompletionKind::ArtifactId);
|
||||
assert_eq!(t.typed, "li");
|
||||
// The value under construction ends with the input and is replaced whole.
|
||||
assert_eq!(t.to, open.len() as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_only_dependency_coordinates() {
|
||||
// The same elements outside a <dependency> get nothing: a <plugin> is
|
||||
// completed by the repository, not coordinates.
|
||||
let plugin =
|
||||
"<project><build><plugins><plugin><groupId>org.apache.maven</groupId></plugin></plugins></build></project>";
|
||||
assert_eq!(target(plugin, "<groupId>org.apache.maven"), None);
|
||||
// A caret in the end tag, or on a `${property}` value, is not a completion.
|
||||
let text =
|
||||
"<project><dependencies><dependency><groupId>org.example</groupId></dependency></dependencies></project>";
|
||||
assert_eq!(target(text, "</groupId>"), None);
|
||||
let property = "<project><dependencies><dependency><groupId>${example.group}</groupId></dependency></dependencies></project>";
|
||||
assert_eq!(target(property, "${example.group}"), None);
|
||||
// A dependency's groupId is required before its artifacts can be listed.
|
||||
let no_group =
|
||||
"<project><dependencies><dependency><artifactId>lib</artifactId></dependency></dependencies></project>";
|
||||
let t = target(no_group, "<artifactId>lib").unwrap();
|
||||
assert!(t.group.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolates_the_poms_own_properties_for_siblings() {
|
||||
let text = "<project>\
|
||||
<properties><example.group>org.example</example.group><example.version>2.0</example.version></properties>\
|
||||
<dependencies><dependency><groupId>${example.group}</groupId><artifactId>lib</artifactId><version>${example.version}</version></dependency></dependencies>\
|
||||
</project>";
|
||||
// The groupId the caret is typing stays a completion; the siblings resolve.
|
||||
let t = target(text, "<artifactId>lib").unwrap();
|
||||
assert_eq!(t.group.as_deref(), Some("org.example"));
|
||||
// A `${property}` under the caret itself gets nothing to complete.
|
||||
assert_eq!(target(text, "<version>${example.version}"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_scan_lists_installed_groups_shallowest_first() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = repo(tmp.path());
|
||||
let options = |typed: &str| {
|
||||
local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: typed.into(),
|
||||
kind: PomCompletionKind::GroupId,
|
||||
group: None,
|
||||
artifact: None,
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.map(|o| o.label)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// Only directories holding artifacts are group ids: `net` and `org`
|
||||
// alone never receive an install, their deeper levels do.
|
||||
let org = ["org.apache.maven", "org.apache.maven.plugins", "org.eclipse.platform", "org.example"];
|
||||
assert_eq!(options(""), [&["net.nu"][..], &org].concat());
|
||||
assert_eq!(options("org"), org);
|
||||
assert_eq!(options("org."), org);
|
||||
assert_eq!(options("org.spr"), Vec::<String>::new());
|
||||
assert_eq!(options("net."), ["net.nu"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_scan_lists_artifacts_of_the_group_with_their_newest_version() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = repo(tmp.path());
|
||||
let options = |typed: &str| {
|
||||
local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: typed.into(),
|
||||
kind: PomCompletionKind::ArtifactId,
|
||||
group: Some("org.example".into()),
|
||||
artifact: None,
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.map(|o| (o.label, o.detail))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(options(""), [("lib".to_owned(), Some("2.0".to_owned()))]);
|
||||
assert_eq!(options("net"), Vec::<(String, Option<String>)>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_scan_lists_versions_of_the_artifact_newest_first() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = repo(tmp.path());
|
||||
let options = |typed: &str| {
|
||||
local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: typed.into(),
|
||||
kind: PomCompletionKind::Version,
|
||||
group: Some("org.example".into()),
|
||||
artifact: Some("lib".into()),
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.map(|o| o.label)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(options(""), ["2.0", "2.0-beta-1", "1.10", "1.9.1", "1.0"]);
|
||||
assert_eq!(options("1."), ["1.10", "1.9.1", "1.0"]);
|
||||
// An artifact id with dots is one directory.
|
||||
let dotted = local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: "".into(),
|
||||
kind: PomCompletionKind::Version,
|
||||
group: Some("org.eclipse.platform".into()),
|
||||
artifact: Some("org.eclipse.core.runtime".into()),
|
||||
},
|
||||
);
|
||||
assert_eq!(dotted.into_iter().map(|o| o.label).collect::<Vec<_>>(), ["3.31.0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poms_text_cannot_walk_out_of_the_repository() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = repo(tmp.path());
|
||||
for group in ["../../etc", "..", "org/../.."] {
|
||||
let options = local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: "".into(),
|
||||
kind: PomCompletionKind::ArtifactId,
|
||||
group: Some(group.into()),
|
||||
artifact: None,
|
||||
},
|
||||
);
|
||||
assert!(options.is_empty(), "{group}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_stops_at_the_option_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repository");
|
||||
for i in 0..MAX_GROUP_OPTIONS + 10 {
|
||||
let group = repo.join(format!("com/example/g{i}"));
|
||||
std::fs::create_dir_all(group.join("art/1.0")).unwrap();
|
||||
std::fs::write(group.join("art/1.0/art-1.0.jar"), b"").unwrap();
|
||||
}
|
||||
let options = local_options(
|
||||
&repo,
|
||||
&PomCompletionTarget {
|
||||
from: 0,
|
||||
to: 0,
|
||||
typed: "".into(),
|
||||
kind: PomCompletionKind::GroupId,
|
||||
group: None,
|
||||
artifact: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(options.len(), MAX_GROUP_OPTIONS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_version_order() {
|
||||
let mut versions = ["2.0", "1.0", "2.0-beta-1", "2.0-alpha-2", "1.10", "1.9.1", "4.0.0-rc-5", "4.0.0-rc-13"];
|
||||
versions.sort_by(|a, b| compare_versions(a, b));
|
||||
assert_eq!(versions, ["1.0", "1.9.1", "1.10", "2.0-alpha-2", "2.0-beta-1", "2.0", "4.0.0-rc-5", "4.0.0-rc-13"]);
|
||||
}
|
||||
}
|
||||
@@ -270,6 +270,13 @@ fn run(
|
||||
|
||||
/// The first error lines of a failed build, for the tool window.
|
||||
pub(crate) fn error_summary(text: &str) -> String {
|
||||
// The JVM's "could not find or load main class" (localized) when `gradlew` has no jar.
|
||||
if text.contains("org.gradle.wrapper.GradleWrapperMain") {
|
||||
return "gradle/wrapper/gradle-wrapper.jar is missing (often excluded by a `*.jar` rule in .gitignore). \
|
||||
Restore it, run `gradle wrapper` with an installed Gradle, or choose Distribution: Local installation \
|
||||
in Settings › Build, Execution, Deployment › Gradle."
|
||||
.to_owned();
|
||||
}
|
||||
let lines: Vec<&str> = text
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
@@ -909,6 +916,8 @@ mod tests {
|
||||
let text = "[INFO] x\n[ERROR] Failed to execute goal\n[ERROR] \n[ERROR] Re-run Maven using -X\n";
|
||||
assert_eq!(error_summary(text), "Failed to execute goal");
|
||||
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
|
||||
let missing_jar = "错误: 找不到或无法加载主类 org.gradle.wrapper.GradleWrapperMain\n";
|
||||
assert!(error_summary(missing_jar).starts_with("gradle/wrapper/gradle-wrapper.jar is missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,6 +27,13 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
imp::set_text(text, owner)
|
||||
}
|
||||
|
||||
/// The clipboard's plain text, None when it holds none. macOS only (main thread);
|
||||
/// Linux reads it asynchronously through GTK in the app, Windows through the WebView.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn text() -> Option<String> {
|
||||
imp::text()
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -373,6 +380,14 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text() -> Option<String> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// SAFETY: the type is an AppKit constant, valid for the process' lifetime.
|
||||
NSPasteboard::generalPasteboard().stringForType(unsafe { NSPasteboardTypeString }).map(|s| s.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
|
||||
@@ -67,6 +67,8 @@ pub struct EditorAppearanceSettings {
|
||||
/// Smooth caret movement curve: `snappy` | `gliding`.
|
||||
pub caret_movement_style: String,
|
||||
pub line_numbers: bool,
|
||||
/// Gutter numbering: `absolute` | `relative` | `hybrid` (absolute on the caret line, distances elsewhere).
|
||||
pub line_number_mode: String,
|
||||
pub method_separators: bool,
|
||||
/// Sub-options of `editor.showWhitespace`.
|
||||
pub whitespace_leading: bool,
|
||||
@@ -78,6 +80,10 @@ pub struct EditorAppearanceSettings {
|
||||
/// Right margin and visual guides from Code Style.
|
||||
pub hard_wrap_guides: bool,
|
||||
pub intention_bulb: bool,
|
||||
/// Draw doc comments (Javadoc, KDoc, JSDoc, rustdoc) as rendered documentation
|
||||
/// instead of their source, as IDEA's option of the same name. View › Active
|
||||
/// Editor › Render Documentation Comments overrides this for one editor.
|
||||
pub render_doc_comments: bool,
|
||||
/// Distraction Free Mode: width of the centred text column, in characters.
|
||||
pub distraction_free_width: u32,
|
||||
}
|
||||
@@ -92,6 +98,7 @@ impl Default for EditorAppearanceSettings {
|
||||
smooth_caret_movement: false,
|
||||
caret_movement_style: "snappy".into(),
|
||||
line_numbers: true,
|
||||
line_number_mode: "absolute".into(),
|
||||
method_separators: false,
|
||||
whitespace_leading: true,
|
||||
whitespace_inner: true,
|
||||
@@ -100,6 +107,7 @@ impl Default for EditorAppearanceSettings {
|
||||
rainbow_indent_guides: false,
|
||||
hard_wrap_guides: true,
|
||||
intention_bulb: true,
|
||||
render_doc_comments: false,
|
||||
distraction_free_width: 80,
|
||||
}
|
||||
}
|
||||
@@ -333,6 +341,23 @@ pub struct InlayHintsSettings {
|
||||
pub code_vision_usages: bool,
|
||||
/// Code vision: last author of a declaration (VCS blame) beside its usages.
|
||||
pub code_vision_author: bool,
|
||||
/// Code vision: "no usages" on declarations nothing uses (IDEA shows it; clicking searches everywhere).
|
||||
pub code_vision_no_usages: bool,
|
||||
/// Code vision: where the usages go, above the declaration or at the end of its line.
|
||||
pub code_vision_usages_position: CodeVisionPosition,
|
||||
/// Code vision: where the author goes, above the declaration or at the end of its line.
|
||||
pub code_vision_author_position: CodeVisionPosition,
|
||||
}
|
||||
|
||||
/// Editor › Inlay Hints › Code Vision position (IDEA's "Default position for metrics").
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CodeVisionPosition {
|
||||
/// A row of its own above the declaration.
|
||||
#[default]
|
||||
Top,
|
||||
/// After the end of the declaration's first line.
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Default for InlayHintsSettings {
|
||||
@@ -344,6 +369,9 @@ impl Default for InlayHintsSettings {
|
||||
disabled_languages: Vec::new(),
|
||||
code_vision_usages: true,
|
||||
code_vision_author: true,
|
||||
code_vision_no_usages: true,
|
||||
code_vision_usages_position: CodeVisionPosition::Top,
|
||||
code_vision_author_position: CodeVisionPosition::Top,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,18 @@
|
||||
use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
|
||||
/// line) in the Run tool window's agent tab `name`, on pipes, answered once the process
|
||||
/// started or failed to.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum McpUiRequest {
|
||||
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
|
||||
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
|
||||
AgentRun { name: String, command: String, cwd: crate::IpcPath, env: BTreeMap<String, String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
@@ -72,6 +77,9 @@ pub enum IdeEvent {
|
||||
WorkspaceEditApplied {
|
||||
id: u32,
|
||||
paths: Vec<crate::IpcPath>,
|
||||
/// Made by an agent through the MCP server's tools: undoable like a refactoring, but
|
||||
/// not announced as one.
|
||||
by_agent: bool,
|
||||
},
|
||||
/// An open document's language changed (Settings › File Types, Override File
|
||||
/// Type), or its UI mode (a framework's root marker appeared or went).
|
||||
@@ -275,6 +283,11 @@ pub enum DebugEvent {
|
||||
},
|
||||
/// The session's capabilities changed after it started: fetch them again.
|
||||
Capabilities,
|
||||
/// The adapter's `invalidated` event: what it shows for `areas` (`all`, `stacks`, `threads`,
|
||||
/// `variables`; empty means all) is stale and has to be fetched again.
|
||||
Invalidated {
|
||||
areas: Vec<String>,
|
||||
},
|
||||
JvmBackend {
|
||||
state: JvmBackendState,
|
||||
},
|
||||
@@ -330,4 +343,12 @@ mod tests {
|
||||
);
|
||||
assert_eq!(serde_json::to_value(DebugEvent::Capabilities).unwrap(), json!({"kind": "capabilities"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidated_event_json_shape() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(DebugEvent::Invalidated { areas: vec!["variables".into()] }).unwrap(),
|
||||
json!({"kind": "invalidated", "areas": ["variables"]})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,16 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// `p` as the UI sees it: `C:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
|
||||
/// `p` as the UI sees it: `C:\a\b`, `c:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
|
||||
pub fn to_ipc(p: &Path) -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
dunce::simplified(p).to_string_lossy().replace('\\', "/")
|
||||
let mut s = dunce::simplified(p).to_string_lossy().replace('\\', "/");
|
||||
// The UI compares path strings; a drive letter is case-insensitive.
|
||||
if s.as_bytes().get(1) == Some(&b':') {
|
||||
s[..1].make_ascii_uppercase();
|
||||
}
|
||||
s
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
@@ -144,5 +149,8 @@ mod tests {
|
||||
assert_eq!(to_ipc(Path::new(r"\\?\C:\a\b")), "C:/a/b");
|
||||
assert_eq!(to_ipc(&Path::new("C:/a").join("b")), "C:/a/b");
|
||||
assert_eq!(serde_json::to_string(&IpcPath::from(r"C:\a\b")).unwrap(), r#""C:/a/b""#);
|
||||
assert_eq!(to_ipc(Path::new(r"c:\a\b")), "C:/a/b");
|
||||
assert_eq!(to_ipc(Path::new(r"\\?\d:\a")), "D:/a");
|
||||
assert_eq!(to_ipc(Path::new(r"\\server\share\a")), "//server/share/a");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub mod process;
|
||||
pub mod project_dir;
|
||||
pub mod project_roots;
|
||||
pub mod proxy_settings;
|
||||
pub mod real_path;
|
||||
pub mod remote_settings;
|
||||
pub mod settings;
|
||||
pub mod task;
|
||||
|
||||
@@ -76,7 +76,8 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
/// The `encoding_rs` encoding of a Windows code page, when it has one.
|
||||
pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
Some(match cp {
|
||||
65001 => encoding_rs::UTF_8,
|
||||
936 => encoding_rs::GBK,
|
||||
@@ -101,6 +102,56 @@ fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The code page Windows console programs write to a pipe in: the OEM code page (GBK on a
|
||||
/// Chinese system, Shift_JIS on a Japanese one, EUC-KR on a Korean one, 437 / 850 / 866…
|
||||
/// elsewhere). `None` when it is UTF-8 (Windows' "Use Unicode UTF-8" option) or off Windows.
|
||||
pub fn console_code_page() -> Option<u32> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// SAFETY: GetOEMCP is an argument-less query with no preconditions.
|
||||
let cp = unsafe { windows_sys::Win32::Globalization::GetOEMCP() };
|
||||
(cp != 65001).then_some(cp)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every character of code page `cp` is one byte, so text in it can be decoded
|
||||
/// piece by piece.
|
||||
#[cfg(windows)]
|
||||
pub fn is_single_byte_code_page(cp: u32) -> bool {
|
||||
use windows_sys::Win32::Globalization::{CPINFO, GetCPInfo};
|
||||
// SAFETY: CPINFO is plain data, which GetCPInfo fills in.
|
||||
unsafe {
|
||||
let mut info: CPINFO = std::mem::zeroed();
|
||||
GetCPInfo(cp, &mut info) != 0 && info.MaxCharSize == 1
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` in code page `cp`, decoded by Windows (for the code pages `encoding_rs` lacks,
|
||||
/// like the OEM pages 437, 850 or 852).
|
||||
#[cfg(windows)]
|
||||
pub fn decode_code_page(bytes: &[u8], cp: u32) -> String {
|
||||
use windows_sys::Win32::Globalization::MultiByteToWideChar;
|
||||
let Ok(len) = i32::try_from(bytes.len()) else {
|
||||
return String::from_utf8_lossy(bytes).into_owned();
|
||||
};
|
||||
if len == 0 {
|
||||
return String::new();
|
||||
}
|
||||
// SAFETY: `bytes` holds `len` bytes; no output buffer asks for the length needed.
|
||||
let needed = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, std::ptr::null_mut(), 0) };
|
||||
if needed <= 0 {
|
||||
return String::from_utf8_lossy(bytes).into_owned();
|
||||
}
|
||||
let mut wide = vec![0u16; needed as usize];
|
||||
// SAFETY: `wide` holds `needed` units.
|
||||
let written = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, wide.as_mut_ptr(), needed) };
|
||||
String::from_utf16_lossy(&wide[..written.max(0) as usize])
|
||||
}
|
||||
|
||||
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
|
||||
const FILE_ENCODING: &str = "file.encoding";
|
||||
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Where a path really lands, and whether it reaches git's own metadata, for writes that must
|
||||
//! stay inside a project and out of its `.git`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `joined` after `..`/symlink resolution as far as the file system allows:
|
||||
/// the canonical path when it exists, else the canonicalized deepest existing
|
||||
/// ancestor with the still-missing tail resolved textually — so the location
|
||||
/// of a missing path is still judged where it would really land.
|
||||
pub fn resolve_real(joined: &Path) -> PathBuf {
|
||||
let mut tail = Vec::new();
|
||||
let mut candidate = joined.to_path_buf();
|
||||
let mut real = loop {
|
||||
match dunce::canonicalize(&candidate) {
|
||||
Ok(real) => break real,
|
||||
Err(_) => {
|
||||
let Some(last) = candidate.components().next_back() else {
|
||||
return joined.to_path_buf();
|
||||
};
|
||||
tail.push(last.as_os_str().to_owned());
|
||||
if !candidate.pop() {
|
||||
return joined.to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for part in tail.iter().rev() {
|
||||
if part == ".." {
|
||||
real.pop();
|
||||
} else if part != "." {
|
||||
real.push(part);
|
||||
}
|
||||
}
|
||||
real
|
||||
}
|
||||
|
||||
/// Whether a path (relative to the project root) goes through git's own `.git` folder or file.
|
||||
/// git runs what it finds in `.git` (hooks, `gpg.program`, filters), so a content write must
|
||||
/// never reach it. Names are normalized for trailing dots and spaces and NTFS stream suffixes;
|
||||
/// the caller also checks the short-name-resolved path via [`resolve_real`].
|
||||
pub fn names_git_dir(rel: &Path) -> bool {
|
||||
rel.components().any(|part| {
|
||||
let name = part.as_os_str().to_string_lossy();
|
||||
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
|
||||
name.eq_ignore_ascii_case(".git")
|
||||
})
|
||||
}
|
||||
|
||||
/// A resolved write destination under `root` that reaches git's own `.git` metadata, judged by
|
||||
/// the name given and by the name the file system resolves it to.
|
||||
pub fn reaches_git_dir(root: &Path, real: &Path) -> bool {
|
||||
real.strip_prefix(root).is_ok_and(names_git_dir) || resolve_real(real).strip_prefix(root).is_ok_and(names_git_dir)
|
||||
}
|
||||
@@ -77,6 +77,8 @@ pub enum AutoUpload {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentSettings {
|
||||
pub servers: Vec<DeploymentServer>,
|
||||
/// Server groups: Upload to… on a group uploads to each of its servers.
|
||||
pub groups: Vec<DeploymentGroup>,
|
||||
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
|
||||
pub default_server: String,
|
||||
pub auto_upload: AutoUpload,
|
||||
@@ -92,6 +94,7 @@ impl Default for DeploymentSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
default_server: String::new(),
|
||||
auto_upload: AutoUpload::Never,
|
||||
delete_remote: false,
|
||||
@@ -103,14 +106,47 @@ impl Default for DeploymentSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Named set of deployment servers that files are uploaded to together.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`DeploymentServer::id`]s, in upload order.
|
||||
pub servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DeploymentType {
|
||||
#[default]
|
||||
Sftp,
|
||||
Ftp,
|
||||
/// FTP secured with `AUTH TLS` (explicit FTPS).
|
||||
Ftps,
|
||||
Local,
|
||||
}
|
||||
|
||||
/// Host and login of an FTP / FTPS deployment server. The password is kept with
|
||||
/// the saved passwords, under the server's id.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FtpConnection {
|
||||
pub host: String,
|
||||
/// `None`: 21.
|
||||
pub port: Option<u16>,
|
||||
/// Empty: anonymous login.
|
||||
pub user: String,
|
||||
/// Passive mode (the client opens data connections); off: active mode.
|
||||
pub passive: bool,
|
||||
}
|
||||
|
||||
impl Default for FtpConnection {
|
||||
fn default() -> Self {
|
||||
Self { host: String::new(), port: None, user: String::new(), passive: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// A deployment filesystem and how project folders map onto it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -120,6 +156,8 @@ pub struct DeploymentServer {
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
pub ssh_config: String,
|
||||
/// FTP / FTPS servers.
|
||||
pub ftp: FtpConnection,
|
||||
/// Absolute remote folder the mappings are relative to; empty = the login directory.
|
||||
pub root_path: String,
|
||||
/// Web server URL of the root (Open in Browser on the deployed file).
|
||||
@@ -225,8 +263,11 @@ mod deployment_tests {
|
||||
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
|
||||
assert_eq!(legacy.kind, DeploymentType::Sftp);
|
||||
assert_eq!(legacy.ssh_config, "ssh");
|
||||
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
|
||||
let server = DeploymentServer { kind, ..legacy.clone() };
|
||||
assert!(legacy.ftp.passive);
|
||||
let ftp =
|
||||
FtpConnection { host: "ftp.example.com".into(), port: Some(2121), user: "web".into(), passive: false };
|
||||
for kind in [DeploymentType::Sftp, DeploymentType::Ftp, DeploymentType::Ftps, DeploymentType::Local] {
|
||||
let server = DeploymentServer { kind, ftp: ftp.clone(), ..legacy.clone() };
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
|
||||
server
|
||||
|
||||
@@ -63,6 +63,8 @@ pub struct Settings {
|
||||
pub agents: AgentSettings,
|
||||
/// Agents › MCP Server (global only).
|
||||
pub mcp_server: McpServerSettings,
|
||||
/// Tools › Agents: Command Execution Policy.
|
||||
pub agent_security: AgentSecuritySettings,
|
||||
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
|
||||
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
|
||||
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
|
||||
@@ -100,6 +102,11 @@ pub struct AgentSettings {
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = WhileRunning)]
|
||||
pub while_running: WhileRunning,
|
||||
/// Whether new tasks start in the agent's mode that approves everything (Claude Code's
|
||||
/// bypass permissions, Codex's full access) or in one that asks. User settings only.
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = NewTaskApprovals)]
|
||||
pub new_task_approvals: NewTaskApprovals,
|
||||
/// Settings new tasks of an agent start with: agent id → option id → value.
|
||||
#[serde(deserialize_with = "agent_defaults")]
|
||||
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
|
||||
@@ -125,6 +132,7 @@ impl Default for AgentSettings {
|
||||
expand_tool_output: false,
|
||||
send_with: SendKey::Enter,
|
||||
while_running: WhileRunning::Queue,
|
||||
new_task_approvals: NewTaskApprovals::Bypass,
|
||||
defaults: BTreeMap::new(),
|
||||
mcp_servers: BTreeMap::new(),
|
||||
}
|
||||
@@ -192,6 +200,19 @@ where
|
||||
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// A theme id, with the built-in themes that were removed read as the Islands theme of their kind.
|
||||
fn theme_id<'de, D>(d: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let id = String::deserialize(d)?;
|
||||
Ok(match id.as_str() {
|
||||
"one-light" => "light".into(),
|
||||
"one-dark" => "dark".into(),
|
||||
_ => id,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`AgentSettings::defaults`], keeping only the text values of each agent's table.
|
||||
fn agent_defaults<'de, D>(d: D) -> Result<BTreeMap<String, BTreeMap<String, String>>, D::Error>
|
||||
where
|
||||
@@ -255,12 +276,55 @@ pub enum WhileRunning {
|
||||
Steer,
|
||||
}
|
||||
|
||||
/// The approvals new agent tasks start with.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NewTaskApprovals {
|
||||
/// The agent's mode that approves everything without asking, when it has one.
|
||||
#[default]
|
||||
Bypass,
|
||||
/// A mode in which the agent asks before edits and commands.
|
||||
Ask,
|
||||
}
|
||||
|
||||
/// Agents › MCP Server: agents use the IDE's built-in tools.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct McpServerSettings {
|
||||
/// Serve the tools on 127.0.0.1 while the IDE runs.
|
||||
pub enabled: bool,
|
||||
/// Run the tools that change the project or start processes without asking first
|
||||
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
|
||||
pub run_without_confirmation: bool,
|
||||
/// Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived.
|
||||
pub agent_tab_limit: u32,
|
||||
/// Archived agent tabs kept; older ones are closed.
|
||||
pub agent_archive_limit: u32,
|
||||
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
|
||||
/// port forwards keep working after a restart.
|
||||
pub fixed_port: bool,
|
||||
/// The port of [`Self::fixed_port`].
|
||||
pub port: u16,
|
||||
/// Keep the bearer token across runs (in the app data, readable by this user only) rather than
|
||||
/// make a new one each run.
|
||||
pub fixed_token: bool,
|
||||
}
|
||||
|
||||
/// The default of [`McpServerSettings::port`].
|
||||
pub const DEFAULT_MCP_PORT: u16 = 26371;
|
||||
|
||||
impl Default for McpServerSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
run_without_confirmation: false,
|
||||
agent_tab_limit: 1,
|
||||
agent_archive_limit: 999,
|
||||
fixed_port: true,
|
||||
port: DEFAULT_MCP_PORT,
|
||||
fixed_token: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Absent profiles retain automatic discovery for existing projects.
|
||||
@@ -408,6 +472,7 @@ impl Default for Settings {
|
||||
cmake: CMakeSettings::default(),
|
||||
agents: AgentSettings::default(),
|
||||
mcp_server: McpServerSettings::default(),
|
||||
agent_security: AgentSecuritySettings::default(),
|
||||
plugins: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -551,13 +616,19 @@ pub enum NavigationBarPosition {
|
||||
pub struct AppearanceSettings {
|
||||
/// A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
|
||||
/// "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
|
||||
#[serde(deserialize_with = "theme_id")]
|
||||
#[specta(type = String)]
|
||||
pub theme: String,
|
||||
pub accent: String,
|
||||
pub ui_font_size: f32,
|
||||
pub locale: String,
|
||||
/// Follow the OS light/dark mode with the preferred themes below.
|
||||
pub sync_with_os: bool,
|
||||
#[serde(deserialize_with = "theme_id")]
|
||||
#[specta(type = String)]
|
||||
pub preferred_light_theme: String,
|
||||
#[serde(deserialize_with = "theme_id")]
|
||||
#[specta(type = String)]
|
||||
pub preferred_dark_theme: String,
|
||||
/// A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons.
|
||||
pub file_icon_theme: String,
|
||||
@@ -799,6 +870,9 @@ pub struct FileSettings {
|
||||
pub file_types: BTreeMap<String, FileTypeAssociation>,
|
||||
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
|
||||
pub file_type_overrides: BTreeMap<String, String>,
|
||||
/// File Types › Files opened in associated applications: file-name patterns
|
||||
/// (`*.xlsx`) opened with the system application instead of an editor.
|
||||
pub associated_applications: Vec<String>,
|
||||
/// Compose detection choices, keyed by project-relative file path.
|
||||
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
|
||||
}
|
||||
@@ -835,6 +909,15 @@ impl Default for FileSettings {
|
||||
local_history_max_mb: 512,
|
||||
file_types: BTreeMap::new(),
|
||||
file_type_overrides: BTreeMap::new(),
|
||||
associated_applications: vec![
|
||||
"*.xlsx".into(),
|
||||
"*.xls".into(),
|
||||
"*.docx".into(),
|
||||
"*.doc".into(),
|
||||
"*.pptx".into(),
|
||||
"*.ppt".into(),
|
||||
"*.pdf".into(),
|
||||
],
|
||||
compose_file_overrides: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -984,11 +1067,28 @@ pub struct PythonSettings {
|
||||
pub interpreter: String,
|
||||
/// Activate the interpreter's virtualenv in new terminal sessions.
|
||||
pub activate_virtualenv: bool,
|
||||
/// Interpreters the user added to Settings › Python › All Interpreters (executable paths).
|
||||
/// Detection contributes the rest; `hidden_interpreters` removes some of them.
|
||||
pub interpreters: Vec<String>,
|
||||
/// Interpreters the user removed from that list, as executable paths: detection does
|
||||
/// not list them again.
|
||||
pub hidden_interpreters: Vec<String>,
|
||||
/// Hide interpreters that belong to another project (a virtualenv inside its folder).
|
||||
pub hide_other_project_interpreters: bool,
|
||||
/// Show each interpreter's path under its name on the interpreters page.
|
||||
pub show_interpreter_paths: bool,
|
||||
}
|
||||
|
||||
impl Default for PythonSettings {
|
||||
fn default() -> Self {
|
||||
Self { interpreter: String::new(), activate_virtualenv: true }
|
||||
Self {
|
||||
interpreter: String::new(),
|
||||
activate_virtualenv: true,
|
||||
interpreters: Vec::new(),
|
||||
hidden_interpreters: Vec::new(),
|
||||
hide_other_project_interpreters: false,
|
||||
show_interpreter_paths: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,7 +1264,9 @@ pub struct MavenSettings {
|
||||
pub runner_jre: String,
|
||||
/// Project-relative root POMs removed from the Maven project model.
|
||||
pub unlinked_projects: Vec<String>,
|
||||
/// Project-relative POMs outside the root reactor added as independent top-level projects.
|
||||
/// POMs added as independent top-level projects. Inside the opened project the path is
|
||||
/// project-relative (`tools/pom.xml`); outside it, the canonical absolute path. Linking
|
||||
/// does not edit either POM.
|
||||
pub linked_projects: Vec<String>,
|
||||
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
|
||||
pub home: String,
|
||||
@@ -1185,6 +1287,8 @@ pub struct MavenSettings {
|
||||
pub threads: String,
|
||||
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
|
||||
pub active_profiles: String,
|
||||
/// Delegate IDE build/run actions to Maven: launches build with Maven (`javac`) instead of jdtls (ECJ).
|
||||
pub delegate_build: bool,
|
||||
}
|
||||
|
||||
impl MavenSettings {
|
||||
@@ -1402,6 +1506,19 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removed_one_themes_read_as_islands_themes() {
|
||||
let settings: Settings = serde_json::from_str(
|
||||
r#"{"appearance":{"theme":"one-dark","preferredLightTheme":"one-light","preferredDarkTheme":"one-dark"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let a = settings.appearance;
|
||||
assert_eq!((a.theme.as_str(), a.preferred_light_theme.as_str()), ("dark", "light"));
|
||||
assert_eq!(a.preferred_dark_theme, "dark");
|
||||
let kept: Settings = serde_json::from_str(r#"{"appearance":{"theme":"solarized-dark"}}"#).unwrap();
|
||||
assert_eq!(kept.appearance.theme, "solarized-dark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_close_settings_default_off_and_round_trip() {
|
||||
for old in [r#"{}"#, r#"{"general":{"confirmExit":false}}"#] {
|
||||
@@ -1689,10 +1806,12 @@ mod maven_linkage_tests {
|
||||
fn maven_runner_jre_defaults_and_round_trips() {
|
||||
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
|
||||
assert!(old.runner_jre.is_empty());
|
||||
assert!(!old.delegate_build);
|
||||
assert!(MavenSettings::default().runner_jre.is_empty());
|
||||
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), ..old };
|
||||
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), delegate_build: true, ..old };
|
||||
let encoded = serde_json::to_string(&settings).unwrap();
|
||||
assert!(encoded.contains(r#""runnerJre":"/opt/jdk-21""#));
|
||||
assert!(encoded.contains(r#""delegateBuild":true"#));
|
||||
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
|
||||
}
|
||||
|
||||
|
||||
@@ -138,8 +138,11 @@ impl WorkspaceEdit {
|
||||
continue;
|
||||
}
|
||||
*expected = expected.or(version);
|
||||
// Repeats within one entry are intended (LSP inserts at one position
|
||||
// apply in order); only an alias entry repeats what is already merged.
|
||||
let merged = own.len();
|
||||
for edit in edits {
|
||||
if !own.contains(&edit) {
|
||||
if !own[..merged].contains(&edit) {
|
||||
own.push(edit);
|
||||
}
|
||||
}
|
||||
@@ -277,10 +280,40 @@ pub struct OutlineItem {
|
||||
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
|
||||
pub type MatchIndices = Vec<u32>;
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(test)]
|
||||
mod workspace_edit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repeated_edits_within_one_entry_survive_but_repeats_across_entries_do_not() {
|
||||
let path = PathBuf::from("missing-dir/main.ts");
|
||||
let at = |line, text: &str| TextEdit {
|
||||
range: Range::new(Pos::new(line, 0), Pos::new(line, 0)),
|
||||
new_text: text.into(),
|
||||
};
|
||||
let entry = |edits: Vec<TextEdit>| FileEdit::Edit { path: path.clone().into(), edits, version: None };
|
||||
let (nl, brace, other) = (at(0, "\n"), at(1, "}"), at(2, "x"));
|
||||
for (entries, expected) in [
|
||||
(vec![vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
|
||||
(vec![vec![brace.clone(), nl.clone(), brace.clone()]], vec![brace.clone(), nl.clone(), brace.clone()]),
|
||||
(vec![vec![nl.clone(), nl.clone()], vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
|
||||
(vec![vec![nl.clone()], vec![nl.clone(), other.clone()]], vec![nl.clone(), other.clone()]),
|
||||
(
|
||||
vec![vec![nl.clone()], vec![other.clone(), other.clone()]],
|
||||
vec![nl.clone(), other.clone(), other.clone()],
|
||||
),
|
||||
] {
|
||||
let mut workspace =
|
||||
WorkspaceEdit { files: entries.clone().into_iter().map(entry).collect(), ..Default::default() };
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace.files, [entry(expected.clone())], "{entries:?}");
|
||||
let once = workspace.clone();
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace, once, "{entries:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -61,16 +61,13 @@ pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBu
|
||||
|
||||
fn is_executable(path: &Path) -> bool {
|
||||
let Ok(metadata) = path.metadata() else { return false };
|
||||
if !metadata.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
metadata.permissions().mode() & 0o111 != 0
|
||||
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
true
|
||||
metadata.is_file()
|
||||
}
|
||||
|
||||
/// [`search_path`]'s directories, in order and once each. Only absolute ones: a
|
||||
|
||||
@@ -192,6 +192,33 @@ pub struct IssueLink {
|
||||
pub link: String,
|
||||
}
|
||||
|
||||
/// Per-level colours for console log lines: a line whose level is detected
|
||||
/// (`ERROR`, `WARN`, …) takes the level's colour; ANSI colours the program
|
||||
/// prints itself win over it. Values are CSS colours, usually scheme tokens.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LogLevelColors {
|
||||
pub enabled: bool,
|
||||
pub error: String,
|
||||
pub warn: String,
|
||||
pub info: String,
|
||||
pub debug: String,
|
||||
pub trace: String,
|
||||
}
|
||||
|
||||
impl Default for LogLevelColors {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
error: "var(--ansi-red)".into(),
|
||||
warn: "var(--ansi-yellow)".into(),
|
||||
info: "var(--ansi-green)".into(),
|
||||
debug: "var(--ansi-cyan)".into(),
|
||||
trace: "var(--ansi-magenta)".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Console.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -203,6 +230,8 @@ pub struct ConsoleSettings {
|
||||
pub encoding: String,
|
||||
/// Consecutive lines containing any of these are folded.
|
||||
pub fold_patterns: Vec<String>,
|
||||
/// Log lines take the colour of their level (Run/Debug consoles).
|
||||
pub log_colors: LogLevelColors,
|
||||
}
|
||||
|
||||
impl Default for ConsoleSettings {
|
||||
@@ -219,6 +248,7 @@ impl Default for ConsoleSettings {
|
||||
"at node:internal/".into(),
|
||||
"<frozen importlib._bootstrap".into(),
|
||||
],
|
||||
log_colors: LogLevelColors::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,3 +442,30 @@ impl Default for ExternalTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools › Agents: Command Execution Policy (whitelist and blacklist patterns).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AgentSecuritySettings {
|
||||
/// Commands or patterns (`git status*`, `pwd`) automatically approved without prompting.
|
||||
pub allowed_commands: Vec<String>,
|
||||
/// Commands or patterns (`rm -rf *`, `mkfs*`) automatically rejected.
|
||||
pub blocked_commands: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for AgentSecuritySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allowed_commands: Vec::new(),
|
||||
blocked_commands: vec![
|
||||
"rm -rf *".into(),
|
||||
"rm -rf /".into(),
|
||||
"mkfs*".into(),
|
||||
"dd *".into(),
|
||||
":(){ :|:& };:".into(),
|
||||
"shutdown*".into(),
|
||||
"reboot*".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"stdio",
|
||||
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
// Inside the language server: reached through `debugServer`, never started.
|
||||
"kotlin-lsp" => cfg(
|
||||
"kotlin-lsp",
|
||||
&[],
|
||||
"tcp",
|
||||
"Install JetBrains Kotlin LSP in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
|
||||
"debugpy" => cfg(
|
||||
if cfg!(windows) { "python" } else { "python3" },
|
||||
@@ -98,9 +105,12 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"stdio",
|
||||
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
"lldb-dap" => {
|
||||
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
|
||||
}
|
||||
"lldb-dap" => cfg(
|
||||
"lldb-dap",
|
||||
&[],
|
||||
"stdio",
|
||||
"Install LLVM's lldb-dap (brew install llvm), or on macOS use the one found by xcrun -f lldb-dap",
|
||||
),
|
||||
// GDB's own DAP interpreter, stdio transport. The DAP support it needs shipped in GDB 14
|
||||
// (`prepare_gdb` refuses older ones with the required version in the message).
|
||||
"gdb" => cfg(
|
||||
@@ -117,6 +127,12 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"tcp",
|
||||
"Install Microsoft java-debug (Settings › Languages & Frameworks › Language Servers)",
|
||||
),
|
||||
"bun-dap" => cfg(
|
||||
"bun-dap",
|
||||
&["${port}", "127.0.0.1"],
|
||||
"tcp",
|
||||
"To install bun-dap: git clone https://github.com/morf-bls/bun-dap, run bun install && bun run build, then place dist/bun-dap in your PATH",
|
||||
),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -342,4 +342,16 @@ mod tests {
|
||||
assert_eq!(error_message(&msg), "cannot find a.py");
|
||||
assert_eq!(error_message(&json!({"message": "nope"})), "nope");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_launch_response_keeps_delves_too_old_error_verbatim() {
|
||||
// The UI's repair offer keys off this exact wording (`missingAdapter`), so the
|
||||
// conversion to the IPC error must not fold it into a generic message.
|
||||
let message = "Failed to launch /p/api: Version of Delve is too old for Go version go1.27.0 \
|
||||
(maximum supported version 1.26, suppress this error with --check-go-version=false)";
|
||||
let e = ide_core::IdeError::from(DapError::Failed { command: "launch".into(), message: message.into() });
|
||||
assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
assert_eq!(e.message, format!("launch failed: {message}"));
|
||||
assert!(e.message.contains("Version of Delve is too old for Go version go1.27.0"));
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ pub(crate) use scan::watch_for_attached_jvm;
|
||||
pub(crate) use scan::{JvmFinder, attach_target, find_attached_jvm, find_local_jvm, local_jdwp_port};
|
||||
use snapshot::{AttachProbe, JdkProbe, take_snapshot};
|
||||
|
||||
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER];
|
||||
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER, crate::kotlin_lsp::ADAPTER];
|
||||
pub const HISTOGRAM_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -9,11 +9,15 @@ use std::{
|
||||
|
||||
pub const ADAPTER: &str = "kotlin-debug-adapter";
|
||||
|
||||
/// The adapter attaches to a JVM the IDE starts, through the JDWP link. It resumes the VM while
|
||||
/// constructing its debuggee, before its breakpoint hooks exist, so the link holds resumes back
|
||||
/// until the attach is done.
|
||||
pub(crate) fn link_options() -> ide_jdwp::LinkOptions {
|
||||
ide_jdwp::LinkOptions { hold_resumes: true }
|
||||
/// fwcd's adapter or kotlin-lsp's: both debug a JVM the IDE starts, through the JDWP link.
|
||||
pub fn is_kotlin(adapter: &str) -> bool {
|
||||
adapter == ADAPTER || adapter == crate::kotlin_lsp::ADAPTER
|
||||
}
|
||||
|
||||
/// fwcd's adapter resumes the VM while constructing its debuggee, before its breakpoint hooks
|
||||
/// exist, so only its link holds resumes back until the attach is done.
|
||||
pub(crate) fn link_options(adapter: &str) -> ide_jdwp::LinkOptions {
|
||||
ide_jdwp::LinkOptions { hold_resumes: adapter == ADAPTER }
|
||||
}
|
||||
|
||||
pub struct SourceMirror {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! JetBrains kotlin-lsp's debugger: a DAP server inside the language server, reached through
|
||||
//! the `debugServer` port it opens once the project is imported.
|
||||
//!
|
||||
//! Known limits, with no guard here: an evaluate that calls a function with a breakpoint, right
|
||||
//! after a step over a suspension point, loses every later stop at that breakpoint; while
|
||||
//! stepping, breakpoints hit by other coroutines are skipped.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
pub const ADAPTER: &str = "kotlin-lsp";
|
||||
/// The `adapterID` its debug server expects in `initialize`.
|
||||
pub const DAP_ADAPTER_ID: &str = "intellij_jvm";
|
||||
|
||||
/// The path of a `file:` URI, as kotlin-lsp reports some sources (percent-encoded); `None` for
|
||||
/// anything else, including a URI naming another host.
|
||||
pub(crate) fn source_path(path: &str) -> Option<PathBuf> {
|
||||
let rest = path.strip_prefix("file:")?;
|
||||
let path = match rest.strip_prefix("//") {
|
||||
Some(authority) => {
|
||||
let slash = authority.find('/')?;
|
||||
if !matches!(&authority[..slash], "" | "localhost") {
|
||||
return None;
|
||||
}
|
||||
&authority[slash..]
|
||||
}
|
||||
None => rest,
|
||||
};
|
||||
let decoded = percent_encoding::percent_decode_str(path).decode_utf8().ok()?;
|
||||
// `/C:/p/App.kt` on Windows.
|
||||
let decoded = match decoded.strip_prefix('/') {
|
||||
Some(drive) if cfg!(windows) && drive.as_bytes().get(1) == Some(&b':') => drive,
|
||||
_ => &decoded,
|
||||
};
|
||||
Some(PathBuf::from(decoded))
|
||||
}
|
||||
|
||||
pub(crate) fn frame_name(name: &str) -> &str {
|
||||
name.strip_prefix("at ").unwrap_or(name)
|
||||
}
|
||||
|
||||
/// What kotlin-lsp's DAP server does unlike other adapters.
|
||||
///
|
||||
/// It never answers a request that carries a thread id from an earlier stop (K1 G4), so such a
|
||||
/// request would wait out `REQUEST_TIMEOUT`: [`Self::check_thread`] refuses it first. Only thread
|
||||
/// ids were measured; a stale `frameId` or `variablesReference` may wait the same way. A step
|
||||
/// ends with `reason: "breakpoint"`; a real breakpoint hit during a step is then reported as a
|
||||
/// step too, since the stop carries neither `hitBreakpointIds` nor a description.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Quirks {
|
||||
step_pending: AtomicBool,
|
||||
/// The stop (a `Suspension` ticket) and the thread ids seen in it.
|
||||
threads: Mutex<(u64, HashSet<i64>)>,
|
||||
}
|
||||
|
||||
impl Quirks {
|
||||
/// Before `next`, `stepIn`, `stepOut` or a forced step.
|
||||
pub fn step_sent(&self) {
|
||||
self.step_pending.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
/// Before `continue` or run to cursor, or after a step request failed.
|
||||
pub fn resume_sent(&self) {
|
||||
self.step_pending.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
/// The reason to report for a `stopped` event.
|
||||
pub fn stop_reason(&self, reason: String) -> String {
|
||||
let stepped = self.step_pending.swap(false, Ordering::AcqRel);
|
||||
if stepped && reason == "breakpoint" { "step".into() } else { reason }
|
||||
}
|
||||
|
||||
/// Thread ids seen in stop `stop`: a `stopped` event's, or a `threads` reply to a request sent
|
||||
/// during that stop. A reply from an older stop changes nothing.
|
||||
pub fn saw_threads(&self, stop: u64, ids: impl IntoIterator<Item = i64>) {
|
||||
let mut threads = self.threads.lock();
|
||||
if stop > threads.0 {
|
||||
*threads = (stop, ids.into_iter().collect());
|
||||
} else if stop == threads.0 {
|
||||
threads.1.extend(ids);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses a thread id that is not one of current stop `stop`'s, once some are known.
|
||||
pub fn check_thread(&self, stop: u64, suspended: bool, id: i64) -> IdeResult<()> {
|
||||
let threads = self.threads.lock();
|
||||
if suspended && threads.0 == stop && !threads.1.is_empty() && !threads.1.contains(&id) {
|
||||
return Err(IdeError::invalid(format!("thread {id} belongs to an earlier stop")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn file_uris_become_paths() {
|
||||
assert_eq!(source_path("file:///p/a%20b/App.kt").as_deref(), Some(Path::new("/p/a b/App.kt")));
|
||||
assert_eq!(source_path("file://localhost/p/x.kt").as_deref(), Some(Path::new("/p/x.kt")));
|
||||
assert_eq!(source_path("/p/x.kt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_names_lose_the_at_prefix() {
|
||||
assert_eq!(frame_name("at com.x.AppKt.main(App.kt:13)"), "com.x.AppKt.main(App.kt:13)");
|
||||
assert_eq!(frame_name("main"), "main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_pending_step_turns_a_breakpoint_into_a_step() {
|
||||
let quirks = Quirks::default();
|
||||
quirks.step_sent();
|
||||
assert_eq!(quirks.stop_reason("breakpoint".into()), "step");
|
||||
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
|
||||
quirks.step_sent();
|
||||
quirks.resume_sent();
|
||||
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threads_are_checked_against_the_current_stop() {
|
||||
let quirks = Quirks::default();
|
||||
quirks.saw_threads(3, [18]);
|
||||
assert!(quirks.check_thread(3, true, 18).is_ok());
|
||||
assert!(quirks.check_thread(3, true, 6).is_err());
|
||||
assert!(quirks.check_thread(4, true, 6).is_ok(), "nothing known about stop 4 yet");
|
||||
assert!(quirks.check_thread(3, false, 6).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_older_threads_reply_does_not_change_a_newer_stop() {
|
||||
let quirks = Quirks::default();
|
||||
quirks.saw_threads(4, [2]);
|
||||
quirks.saw_threads(3, [1]);
|
||||
assert!(quirks.check_thread(4, true, 1).is_err());
|
||||
quirks.saw_threads(4, [7]);
|
||||
assert!(quirks.check_thread(4, true, 2).is_ok() && quirks.check_thread(4, true, 7).is_ok());
|
||||
quirks.saw_threads(5, [9]);
|
||||
assert!(quirks.check_thread(5, true, 2).is_err());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod connection;
|
||||
pub mod jdwp;
|
||||
mod jvm_memory;
|
||||
pub mod kotlin;
|
||||
pub mod kotlin_lsp;
|
||||
mod session;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
@@ -610,9 +611,19 @@ fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows
|
||||
/// `__debug_bin.exe` plus `__debug_bin.exe<digits>` in DAP mode) and removes it on a
|
||||
/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
|
||||
/// A step request's outcome; a failed one leaves no step pending, so the next stop keeps its reason.
|
||||
fn step_settled(session: &Session, sent: Result<Value, DapError>) -> Result<Value, DapError> {
|
||||
if sent.is_err()
|
||||
&& let Some(quirks) = session.kotlin_lsp_quirks()
|
||||
{
|
||||
quirks.resume_sent();
|
||||
}
|
||||
sent
|
||||
}
|
||||
|
||||
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (in DAP mode a
|
||||
/// temp-file name, `__debug_bin<digits>`; on Windows with `.exe` before the digits) and
|
||||
/// removes it on a graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
|
||||
/// the leftovers once the session is down. Runs after `session_closed`, so Delve
|
||||
/// has already exited or been killed and holds no file locks; a leftover of a
|
||||
/// still-building run is removed on that run's own shutdown.
|
||||
@@ -621,14 +632,10 @@ fn remove_delve_binaries(cwd: &Path) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
// Windows writes `__debug_bin.exe` (and `.exe<digits>` in DAP mode); other
|
||||
// platforms keep the bare name. Suffixes like `.go` are user files.
|
||||
let suffix = name.strip_prefix("__debug_bin");
|
||||
let leftover = suffix.is_some_and(|s| {
|
||||
s.is_empty()
|
||||
|| s == ".exe"
|
||||
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
|
||||
});
|
||||
// `__debug_bin`, then `.exe` on Windows, then DAP mode's temp-file digits.
|
||||
// Suffixes like `.go` are user files.
|
||||
let suffix = name.strip_prefix("__debug_bin").map(|s| s.strip_prefix(".exe").unwrap_or(s));
|
||||
let leftover = suffix.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit()));
|
||||
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
@@ -981,6 +988,7 @@ impl DebugManager {
|
||||
arguments["projectRoot"] = json!(mirror.root());
|
||||
*s.kotlin_mirror.lock() = Some(mirror);
|
||||
}
|
||||
let is_kotlin_lsp = s.adapter == kotlin_lsp::ADAPTER;
|
||||
let caps = s
|
||||
.conn
|
||||
.request_timeout(
|
||||
@@ -988,7 +996,7 @@ impl DebugManager {
|
||||
json!({
|
||||
"clientID": "openidea",
|
||||
"clientName": "OpenIDEA",
|
||||
"adapterID": s.adapter,
|
||||
"adapterID": if is_kotlin_lsp { kotlin_lsp::DAP_ADAPTER_ID } else { s.adapter.as_str() },
|
||||
"locale": "en",
|
||||
"linesStartAt1": true,
|
||||
"columnsStartAt1": true,
|
||||
@@ -998,7 +1006,8 @@ impl DebugManager {
|
||||
"supportsRunInTerminalRequest": true,
|
||||
"supportsStartDebuggingRequest": true,
|
||||
"supportsProgressReporting": true,
|
||||
"supportsInvalidatedEvent": false,
|
||||
// kotlin-lsp sends `invalidated` either way.
|
||||
"supportsInvalidatedEvent": is_kotlin_lsp,
|
||||
"supportsMemoryReferences": false,
|
||||
"supportsArgsCanBeInterpretedByShell": false,
|
||||
}),
|
||||
@@ -1008,7 +1017,7 @@ impl DebugManager {
|
||||
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
|
||||
|
||||
// The Kotlin adapters ignore the resolved classpath and project runtime on launch.
|
||||
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
|
||||
let request = if kotlin::is_kotlin(&s.adapter) && s.request == "launch" {
|
||||
session::start_kotlin_debuggee(s, &mut arguments).await?;
|
||||
"attach"
|
||||
} else {
|
||||
@@ -1032,7 +1041,7 @@ impl DebugManager {
|
||||
jvm_memory::find_attached_jvm(s, port, finder).await;
|
||||
}
|
||||
}
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
if kotlin::is_kotlin(&s.adapter) {
|
||||
let host = arguments
|
||||
.get("hostName")
|
||||
.and_then(Value::as_str)
|
||||
@@ -1044,7 +1053,7 @@ impl DebugManager {
|
||||
.and_then(|p| u16::try_from(p).ok())
|
||||
.filter(|p| *p != 0)
|
||||
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
|
||||
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options()).await?;
|
||||
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options(&s.adapter)).await?;
|
||||
*s.jvm_inspector.lock() = Some(Arc::new(ide_jdwp::LinkInspector::new(link.clone())));
|
||||
jvm_memory::watch_link(s, &link);
|
||||
*s.jdwp_link.lock() = Some(link);
|
||||
@@ -1381,6 +1390,8 @@ impl DebugManager {
|
||||
|
||||
pub async fn threads(&self, s: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
|
||||
let session = self.session(s)?;
|
||||
// Before sending: a stop that overtakes the reply must not take the reply's ids as its own.
|
||||
let stop = session.suspended.ticket();
|
||||
let body = session.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
|
||||
let threads: Vec<ThreadInfo> = body
|
||||
.get("threads")
|
||||
@@ -1394,11 +1405,15 @@ impl DebugManager {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(quirks) = session.kotlin_lsp_quirks() {
|
||||
quirks.saw_threads(stop, threads.iter().map(|t| t.id));
|
||||
}
|
||||
Ok(threads)
|
||||
}
|
||||
|
||||
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
|
||||
let session = self.session(s)?;
|
||||
session.check_thread(thread_id)?;
|
||||
let body = session
|
||||
.conn
|
||||
.request_timeout(
|
||||
@@ -1407,11 +1422,15 @@ impl DebugManager {
|
||||
REQUEST_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
let kotlin_lsp = session.kotlin_lsp_quirks().is_some();
|
||||
let frame = |f: &Value| {
|
||||
let mut frame = frame_from(f);
|
||||
if let Some(client) = frame.path.as_deref().and_then(|p| session.client_path(&p.to_string_lossy())) {
|
||||
frame.path = Some(client.into());
|
||||
}
|
||||
if kotlin_lsp {
|
||||
frame.name = kotlin_lsp::frame_name(&frame.name).to_owned();
|
||||
}
|
||||
frame
|
||||
};
|
||||
Ok(body.get("stackFrames").and_then(Value::as_array).map(|f| f.iter().map(frame).collect()).unwrap_or_default())
|
||||
@@ -1523,6 +1542,10 @@ impl DebugManager {
|
||||
|
||||
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
session.check_thread(thread_id)?;
|
||||
if let Some(quirks) = session.kotlin_lsp_quirks() {
|
||||
quirks.resume_sent();
|
||||
}
|
||||
let ticket = session.suspended.ticket();
|
||||
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
|
||||
// DAP: a response without the flag continued every thread.
|
||||
@@ -1567,8 +1590,13 @@ impl DebugManager {
|
||||
StepKind::Out => "stepOut",
|
||||
};
|
||||
let session = self.session(s)?;
|
||||
session.check_thread(thread_id)?;
|
||||
if let Some(quirks) = session.kotlin_lsp_quirks() {
|
||||
quirks.step_sent();
|
||||
}
|
||||
let ticket = session.suspended.ticket();
|
||||
session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
|
||||
let sent = session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await;
|
||||
step_settled(&session, sent)?;
|
||||
// A step runs its thread; other threads stopped by their own events stay stopped.
|
||||
session.suspended.resumed(ticket, thread_id, false);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
|
||||
@@ -1587,6 +1615,8 @@ impl DebugManager {
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
// Before touching breakpoints: a refused `continue` would leave them replaced.
|
||||
session.check_thread(thread_id)?;
|
||||
if force {
|
||||
self.suspend_breakpoints(&session, Some(&path)).await?;
|
||||
}
|
||||
@@ -1661,6 +1691,7 @@ impl DebugManager {
|
||||
/// Force Step Over: `next` with every breakpoint suspended until the step ends.
|
||||
pub async fn force_step_over(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
session.check_thread(thread_id)?;
|
||||
self.suspend_breakpoints(&session, None).await?;
|
||||
self.step(s, thread_id, StepKind::Over).await
|
||||
}
|
||||
@@ -1668,11 +1699,16 @@ impl DebugManager {
|
||||
/// Step into a specific call of the current line (Smart Step Into).
|
||||
pub async fn step_in_target(&self, s: DebugSessionId, thread_id: i64, target_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
session.check_thread(thread_id)?;
|
||||
if let Some(quirks) = session.kotlin_lsp_quirks() {
|
||||
quirks.step_sent();
|
||||
}
|
||||
let ticket = session.suspended.ticket();
|
||||
session
|
||||
let sent = session
|
||||
.conn
|
||||
.request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT)
|
||||
.await?;
|
||||
.await;
|
||||
step_settled(&session, sent)?;
|
||||
session.suspended.resumed(ticket, thread_id, false);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
|
||||
Ok(())
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use crate::adapter::AdapterSpec;
|
||||
use crate::connection::{Connection, DapError, Incoming};
|
||||
use crate::kotlin_lsp;
|
||||
use crate::{Breakpoint, BreakpointState, DataBreakpoint, DebugManager, FunctionBreakpoint, SessionOwner};
|
||||
use ide_core::event::DebugEvent;
|
||||
#[cfg(unix)]
|
||||
@@ -103,6 +104,8 @@ pub(crate) struct Session {
|
||||
pub jvm_located: watch::Sender<bool>,
|
||||
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
|
||||
pub jvm_memory: tokio::sync::Mutex<crate::JvmMemoryState>,
|
||||
/// Used only by a kotlin-lsp session: see [`Session::kotlin_lsp_quirks`].
|
||||
pub(crate) quirks: kotlin_lsp::Quirks,
|
||||
}
|
||||
|
||||
/// Which threads of the program are suspended: each from its `stopped` event until a
|
||||
@@ -252,6 +255,19 @@ impl Session {
|
||||
jvm_located: watch::channel(false).0,
|
||||
kotlin_mirror: Mutex::new(None),
|
||||
jvm_memory: tokio::sync::Mutex::default(),
|
||||
quirks: kotlin_lsp::Quirks::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kotlin_lsp_quirks(&self) -> Option<&kotlin_lsp::Quirks> {
|
||||
(self.adapter == kotlin_lsp::ADAPTER).then_some(&self.quirks)
|
||||
}
|
||||
|
||||
/// Call before sending a request for thread `id`.
|
||||
pub fn check_thread(&self, id: i64) -> ide_core::IdeResult<()> {
|
||||
match self.kotlin_lsp_quirks() {
|
||||
Some(quirks) => quirks.check_thread(self.suspended.ticket(), self.suspended.get(), id),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +299,11 @@ impl Session {
|
||||
|
||||
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
|
||||
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
|
||||
if self.adapter == kotlin_lsp::ADAPTER
|
||||
&& let Some(path) = kotlin_lsp::source_path(path)
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
|
||||
return Some(real);
|
||||
}
|
||||
@@ -697,7 +718,11 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
"stopped" => {
|
||||
let all = body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false);
|
||||
s.suspended.stopped(int("threadId"), all);
|
||||
let reason = str_("reason").unwrap_or_else(|| "pause".into());
|
||||
let mut reason = str_("reason").unwrap_or_else(|| "pause".into());
|
||||
if let Some(quirks) = s.kotlin_lsp_quirks() {
|
||||
quirks.saw_threads(s.suspended.ticket(), int("threadId"));
|
||||
reason = quirks.stop_reason(reason);
|
||||
}
|
||||
mgr.restore_breakpoints(s).await;
|
||||
s.emit(DebugEvent::Stopped {
|
||||
thread_id: int("threadId").unwrap_or(0),
|
||||
@@ -799,6 +824,14 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
change_type: crate::hot_code_replace_type(body.get("changeType")),
|
||||
message: str_("message").unwrap_or_default(),
|
||||
}),
|
||||
// Sent only by adapters told `supportsInvalidatedEvent` (kotlin-lsp).
|
||||
"invalidated" => s.emit(DebugEvent::Invalidated {
|
||||
areas: body
|
||||
.get("areas")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
_ => tracing::trace!("dap: ignored event {event}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,8 +126,22 @@ struct Script {
|
||||
continued_after_stop: bool,
|
||||
/// Close the connection when a `pause` request arrives (the adapter dies).
|
||||
hang_up_on_pause: bool,
|
||||
/// Close the connection at the first request after the first `stopped` event.
|
||||
hang_up_after_stop: bool,
|
||||
/// Answer `continue` with this body and keep the program running, instead of letting it exit.
|
||||
continue_reply: Option<Value>,
|
||||
/// Answer `continue` and stop at a breakpoint again, instead of letting the program exit.
|
||||
stop_after_continue: bool,
|
||||
/// The `reason` of the stop a `next` ends with (`step` when unset; kotlin-lsp says `breakpoint`).
|
||||
step_reason: Option<&'static str>,
|
||||
/// Refuse `next`, then stop at a breakpoint (one the program hit meanwhile).
|
||||
refuse_next: bool,
|
||||
/// Send `invalidated { areas: ["variables"] }` right after the first `stopped` event.
|
||||
invalidated_after_stop: bool,
|
||||
/// On `threads`: stop again on thread 2 first, then answer with thread 1 of the earlier stop.
|
||||
stop_during_threads: bool,
|
||||
/// Name of the first stack frame (`main` when unset).
|
||||
frame_name: Option<&'static str>,
|
||||
/// Answer `initialize` only after this long (a slow adapter keeps the session connecting).
|
||||
delay_initialize: Option<Duration>,
|
||||
initialized_delay: Duration,
|
||||
@@ -156,7 +170,14 @@ impl Default for Script {
|
||||
hold_set_breakpoints: 0,
|
||||
continued_after_stop: false,
|
||||
hang_up_on_pause: false,
|
||||
hang_up_after_stop: false,
|
||||
continue_reply: None,
|
||||
stop_after_continue: false,
|
||||
step_reason: None,
|
||||
refuse_next: false,
|
||||
invalidated_after_stop: false,
|
||||
stop_during_threads: false,
|
||||
frame_name: None,
|
||||
delay_initialize: None,
|
||||
initialized_delay: Duration::ZERO,
|
||||
launch_delay: Duration::ZERO,
|
||||
@@ -202,6 +223,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
let mut seq = 1000;
|
||||
let mut pending_launch: Option<i64> = None;
|
||||
let mut held: Vec<Value> = Vec::new();
|
||||
let mut stopped = false;
|
||||
let mut running = false;
|
||||
macro_rules! send {
|
||||
($v:expr) => {{
|
||||
@@ -222,6 +244,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
if msg["type"] == "response" {
|
||||
continue;
|
||||
}
|
||||
if script.hang_up_after_stop && stopped {
|
||||
break;
|
||||
}
|
||||
match msg["command"].as_str().unwrap_or_default() {
|
||||
"initialize" => {
|
||||
if let Some(delay) = script.delay_initialize {
|
||||
@@ -320,9 +345,18 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
ok!(msg, Value::Null);
|
||||
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "restart", "threadId": 1}}));
|
||||
}
|
||||
"next" if script.refuse_next => {
|
||||
send!(
|
||||
json!({"type": "response", "request_seq": msg["seq"], "command": "next", "success": false, "message": "refused"})
|
||||
);
|
||||
// Lets the client handle the refusal before the stop.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1}}));
|
||||
}
|
||||
"next" => {
|
||||
ok!(msg, Value::Null);
|
||||
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "step", "threadId": 1}}));
|
||||
let reason = script.step_reason.unwrap_or("step");
|
||||
send!(json!({"type": "event", "event": "stopped", "body": {"reason": reason, "threadId": 1}}));
|
||||
}
|
||||
"setVariable" => {
|
||||
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
|
||||
@@ -358,6 +392,12 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
send!(
|
||||
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
|
||||
);
|
||||
stopped = true;
|
||||
if script.invalidated_after_stop {
|
||||
send!(
|
||||
json!({"type": "event", "event": "invalidated", "body": {"areas": ["variables"], "threadId": 1}})
|
||||
);
|
||||
}
|
||||
if script.continued_after_stop {
|
||||
send!(json!({"type": "event", "event": "continued", "body": {"threadId": 1}}));
|
||||
}
|
||||
@@ -365,11 +405,19 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}})
|
||||
);
|
||||
}
|
||||
"threads" if script.stop_during_threads => {
|
||||
send!(
|
||||
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 2, "allThreadsStopped": true}})
|
||||
);
|
||||
// Lets the client handle the stop before it reads the reply.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
ok!(msg, json!({"threads": [{"id": 1, "name": "\"main\"@1"}]}));
|
||||
}
|
||||
"threads" => ok!(msg, json!({"threads": [{"id": 1, "name": "MainThread"}]})),
|
||||
"stackTrace" => ok!(
|
||||
msg,
|
||||
json!({"stackFrames": [
|
||||
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
|
||||
{"id": 10, "name": script.frame_name.unwrap_or("main"), "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
|
||||
{"id": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}},
|
||||
]})
|
||||
),
|
||||
@@ -393,6 +441,12 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
]})
|
||||
),
|
||||
"continue" if script.continue_reply.is_some() => ok!(msg, script.continue_reply.clone().unwrap()),
|
||||
"continue" if script.stop_after_continue => {
|
||||
ok!(msg, json!({"allThreadsContinued": true}));
|
||||
send!(
|
||||
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
|
||||
);
|
||||
}
|
||||
"continue" => {
|
||||
ok!(msg, json!({"allThreadsContinued": true}));
|
||||
send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}}));
|
||||
@@ -1418,6 +1472,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
framework_files: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
@@ -2041,20 +2096,20 @@ async fn stop_kills_the_program_started_by_the_reported_launcher() {
|
||||
fn delve_leftovers_are_removed_without_touching_other_files() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
|
||||
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
|
||||
std::fs::write(dir.join(name), b"bin").unwrap();
|
||||
}
|
||||
// Lookalikes that are not Delve's own binaries stay: sources, a future
|
||||
// in-progress build, and a directory that happens to share the name.
|
||||
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go"] {
|
||||
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go"] {
|
||||
std::fs::write(dir.join(name), b"keep").unwrap();
|
||||
}
|
||||
std::fs::create_dir(dir.join("__debug_bin.dir")).unwrap();
|
||||
remove_delve_binaries(&dir);
|
||||
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
|
||||
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
|
||||
assert!(!dir.join(name).exists(), "{name} should be gone");
|
||||
}
|
||||
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go", "__debug_bin.dir"] {
|
||||
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go", "__debug_bin.dir"] {
|
||||
assert!(dir.join(name).exists(), "{name} should stay");
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
@@ -2401,6 +2456,13 @@ fn kotlin_attach(project: &std::path::Path, host: &str, port: u16) -> LaunchConf
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_options_hold_only_for_fwcd() {
|
||||
assert!(kotlin::link_options(kotlin::ADAPTER).hold_resumes);
|
||||
assert!(!kotlin::link_options(kotlin_lsp::ADAPTER).hold_resumes);
|
||||
assert!(!kotlin::link_options("x").hold_resumes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_session_installs_link_inspector() {
|
||||
let h = harness(Script::default());
|
||||
@@ -2472,6 +2534,229 @@ async fn closing_the_session_drops_the_link() {
|
||||
assert!(session.jvm_inspector.lock().is_none());
|
||||
}
|
||||
|
||||
/// kotlin-lsp's debugger, reached through the language server's `debugServer` port (the fake ignores it).
|
||||
fn kotlin_lsp_attach(project: &std::path::Path) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
adapter: kotlin_lsp::ADAPTER.into(),
|
||||
request: "attach".into(),
|
||||
cwd: Some(project.to_owned().into()),
|
||||
extra_json: Some(
|
||||
json!({"debugServer": 4711, "hostName": "127.0.0.1", "port": 5005, "projectRoot": project}).to_string(),
|
||||
),
|
||||
..config()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_lsp_initializes_as_intellij_jvm() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
for (config, adapter_id, invalidated) in [
|
||||
(kotlin_lsp_attach(project.path()), "intellij_jvm", true),
|
||||
(kotlin_attach(project.path(), "127.0.0.1", 5005), "kotlin-debug-adapter", false),
|
||||
] {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(config).await.unwrap();
|
||||
let init = &h.request("initialize")["arguments"];
|
||||
assert_eq!(init["adapterID"], adapter_id);
|
||||
assert_eq!(init["supportsInvalidatedEvent"], invalidated, "{adapter_id}");
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_lsp_gets_real_paths_and_its_link_holds_nothing() {
|
||||
let h = harness(Script::default());
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let path = project.path().join("Main.kt");
|
||||
std::fs::write(&path, "package org.example\nfun main() {}\n").unwrap();
|
||||
h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], json!(path));
|
||||
let sent = &h.request("attach")["arguments"];
|
||||
assert_eq!(sent["projectRoot"], json!(project.path()));
|
||||
assert!(sent.get("debugServer").is_none());
|
||||
assert_eq!(sent["hostName"], "127.0.0.1");
|
||||
let link_port = sent["port"].as_u64().unwrap();
|
||||
assert!(link_port != 0 && link_port != 5005);
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert!(session.kotlin_mirror.lock().is_none());
|
||||
assert!(session.jdwp_link.lock().is_some() && session.jvm_inspector.lock().is_some());
|
||||
assert_eq!(session.attach_target.get(), Some(&("127.0.0.1".to_owned(), 5005)));
|
||||
assert!(!kotlin::link_options(&session.adapter).hold_resumes);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_lsp_waits_for_the_configuration_done_reply() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
// fwcd never answers it (`kotlin_configures_without_configuration_done_response`).
|
||||
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
|
||||
let fwcd = h.start(kotlin_attach(project.path(), "127.0.0.1", 5005));
|
||||
let id = tokio::time::timeout(Duration::from_secs(2), fwcd).await.unwrap().unwrap();
|
||||
h.manager.stop(id).await.unwrap();
|
||||
|
||||
tokio::time::pause();
|
||||
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
|
||||
let started = tokio::time::Instant::now();
|
||||
let err = h.start(kotlin_lsp_attach(project.path())).await.unwrap_err();
|
||||
assert!(started.elapsed() >= REQUEST_TIMEOUT, "{:?}", started.elapsed());
|
||||
assert!(err.message.contains("configurationDone"), "{err:?}");
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_lsp_sessions_have_the_memory_tab() {
|
||||
let h = harness(Script::default());
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
assert!(h.manager.capabilities(id).unwrap().jvm_memory);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn losing_the_kotlin_lsp_connection_ends_the_session() {
|
||||
let h = harness(Script { hang_up_after_stop: true, ..Script::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert!(session.jdwp_link.lock().is_some());
|
||||
let _ = session.conn.request_timeout("threads", Value::Null, Duration::from_secs(2)).await;
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
assert!(session.jdwp_link.lock().is_none());
|
||||
assert!(session.jvm_inspector.lock().is_none());
|
||||
}
|
||||
|
||||
/// The reasons of the first `count` stops, once that many arrived.
|
||||
async fn stop_reasons(h: &Harness, count: usize) -> Vec<String> {
|
||||
for _ in 0..200 {
|
||||
let reasons: Vec<String> = h
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Debug { event: DebugEvent::Stopped { reason, .. }, .. } => Some(reason.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if reasons.len() >= count {
|
||||
return reasons;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("{count} stops not received; got {:?}", h.events.lock());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_step_stop_is_reported_as_a_step() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
for (config, reason) in [(kotlin_lsp_attach(project.path()), "step"), (config(), "breakpoint")] {
|
||||
let h = harness(Script { step_reason: Some("breakpoint"), ..Script::default() });
|
||||
let id = h.start(config).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
h.manager.step(id, 1, StepKind::Over).await.unwrap();
|
||||
assert_eq!(stop_reasons(&h, 2).await, ["breakpoint", reason]);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_breakpoint_after_continue_stays_a_breakpoint() {
|
||||
let h = harness(Script { step_reason: Some("breakpoint"), stop_after_continue: true, ..Script::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
h.manager.step(id, 1, StepKind::Over).await.unwrap();
|
||||
stop_reasons(&h, 2).await;
|
||||
// The fake ends `stepIn` with no stop: the `continue` after it decides the next one.
|
||||
h.manager.step(id, 1, StepKind::Into).await.unwrap();
|
||||
h.manager.resume(id, 1).await.unwrap();
|
||||
assert_eq!(stop_reasons(&h, 3).await, ["breakpoint", "step", "breakpoint"]);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_breakpoint_after_a_refused_step_stays_a_breakpoint() {
|
||||
let h = harness(Script { refuse_next: true, ..Script::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert!(h.manager.step(id, 1, StepKind::Over).await.is_err());
|
||||
assert_eq!(stop_reasons(&h, 2).await, ["breakpoint", "breakpoint"]);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_thread_from_an_earlier_stop_is_refused() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
for (config, kotlin_lsp) in [(kotlin_lsp_attach(project.path()), true), (config(), false)] {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(config).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert_eq!(h.manager.threads(id).await.unwrap().len(), 1);
|
||||
let started = tokio::time::Instant::now();
|
||||
let stale = h.manager.stack_trace(id, 99).await;
|
||||
let asked =
|
||||
|command: &str| h.log.lock().iter().any(|m| m["command"] == command && m["arguments"]["threadId"] == 99);
|
||||
if kotlin_lsp {
|
||||
assert!(stale.is_err() && started.elapsed() < Duration::from_millis(100), "{stale:?}");
|
||||
assert!(h.manager.step(id, 99, StepKind::Over).await.is_err());
|
||||
assert!(h.manager.resume(id, 99).await.is_err());
|
||||
assert!(!asked("stackTrace") && !asked("next") && !asked("continue"));
|
||||
} else {
|
||||
assert!(stale.is_ok() && asked("stackTrace"), "{stale:?}");
|
||||
}
|
||||
assert!(h.manager.stack_trace(id, 1).await.is_ok());
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_threads_reply_from_before_a_stop_does_not_vouch_for_its_ids() {
|
||||
let h = harness(Script { stop_during_threads: true, ..Script::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let threads = h.manager.threads(id).await.unwrap();
|
||||
// M3 maps kotlin-lsp's threads by these names.
|
||||
assert_eq!((threads[0].id, threads[0].name.as_str()), (1, "\"main\"@1"));
|
||||
stop_reasons(&h, 2).await;
|
||||
assert!(h.manager.stack_trace(id, 1).await.is_err());
|
||||
assert!(h.manager.stack_trace(id, 2).await.is_ok());
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_lsp_frames_get_real_paths() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let at = "at com.x.AppKt.main(App.kt:13)";
|
||||
for (config, path, name) in [
|
||||
(kotlin_lsp_attach(project.path()), "/p/App.kt", "com.x.AppKt.main(App.kt:13)"),
|
||||
(java_config("launch"), "file:///p/App.kt", at),
|
||||
] {
|
||||
let script = Script { source_path: Some("file:///p/App.kt".into()), frame_name: Some(at), ..Script::default() };
|
||||
let h = harness(script);
|
||||
let id = h.start(config).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let frames = h.manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(frames[0].path.as_ref().map(|p| p.0.clone()), Some(PathBuf::from(path)));
|
||||
assert_eq!(frames[0].name, name);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidated_events_reach_the_client() {
|
||||
let h = harness(Script { invalidated_after_stop: true, ..Script::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
|
||||
match h.wait_event(|e| matches!(e, DebugEvent::Invalidated { .. })).await {
|
||||
DebugEvent::Invalidated { areas } => assert_eq!(areas, ["variables"]),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
|
||||
|
||||
@@ -17,6 +17,7 @@ blake3.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
notify.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
tiberius.workspace = true
|
||||
@@ -29,8 +30,13 @@ ide-http.workspace = true
|
||||
reqwest = { workspace = true, features = ["query"] }
|
||||
rustls.workspace = true
|
||||
uuid.workspace = true
|
||||
base64.workspace = true
|
||||
flate2 = "1"
|
||||
rsa = { version = "0.10.0-rc.18", features = ["sha2", "pkcs5"] }
|
||||
zeroize = "1"
|
||||
open = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net"] }
|
||||
tempfile.workspace = true
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
@@ -0,0 +1,188 @@
|
||||
//! The introspected catalog of a data source, kept on disk: the schemas, tables and views of
|
||||
//! its databases, so completion knows them from the start of the next run without reading the
|
||||
//! server again. Names only — no data, no credentials. One file per project and data source in
|
||||
//! the app data directory.
|
||||
|
||||
use crate::TableInfo;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A schema of a database and its tables and views.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbCatalogSchema")]
|
||||
pub struct CatalogSchema {
|
||||
pub name: String,
|
||||
pub tables: Vec<TableInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbCatalogDatabase")]
|
||||
pub struct CatalogDatabase {
|
||||
pub name: String,
|
||||
/// When this database was read, in milliseconds since the Unix epoch.
|
||||
pub read_at: f64,
|
||||
pub schemas: Vec<CatalogSchema>,
|
||||
}
|
||||
|
||||
/// What introspection has read of a data source.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
#[serde(rename = "DbSourceCatalog")]
|
||||
pub struct SourceCatalog {
|
||||
pub databases: Vec<CatalogDatabase>,
|
||||
}
|
||||
|
||||
pub(crate) struct CatalogStore {
|
||||
dir: PathBuf,
|
||||
project: String,
|
||||
/// One writer at a time: an update reads the file, changes a database and writes it back.
|
||||
writing: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
fn now_ms() -> f64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64)
|
||||
}
|
||||
|
||||
impl CatalogStore {
|
||||
pub(crate) fn new(dir: PathBuf, root: &Path) -> Self {
|
||||
Self { dir, project: root.to_string_lossy().into_owned(), writing: tokio::sync::Mutex::new(()) }
|
||||
}
|
||||
|
||||
fn file(&self, id: &str) -> PathBuf {
|
||||
let name = blake3::hash(format!("{}\0{id}", self.project).as_bytes()).to_hex();
|
||||
self.dir.join(format!("{}.json", &name.as_str()[..32]))
|
||||
}
|
||||
|
||||
fn read(file: &Path) -> SourceCatalog {
|
||||
std::fs::read(file).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write(file: &Path, catalog: &SourceCatalog) -> IdeResult<()> {
|
||||
if let Some(dir) = file.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
// Written beside the file and moved over it, so a crash leaves the old catalog whole.
|
||||
let partial = file.with_extension("json.part");
|
||||
let bytes = serde_json::to_vec(catalog).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::fs::write(&partial, bytes)?;
|
||||
std::fs::rename(&partial, file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(task: impl FnOnce() -> T + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(task).await.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// The saved catalog of data source `id` (`None`: never introspected).
|
||||
pub(crate) async fn load(&self, id: &str) -> IdeResult<Option<SourceCatalog>> {
|
||||
let file = self.file(id);
|
||||
Self::blocking(move || file.exists().then(|| Self::read(&file))).await
|
||||
}
|
||||
|
||||
/// Saves what was just read of `database`, replacing what was known of it.
|
||||
pub(crate) async fn put(&self, id: &str, database: &str, schemas: Vec<CatalogSchema>) -> IdeResult<()> {
|
||||
let _writing = self.writing.lock().await;
|
||||
let (file, name) = (self.file(id), database.to_owned());
|
||||
Self::blocking(move || {
|
||||
let mut catalog = Self::read(&file);
|
||||
catalog.databases.retain(|d| d.name != name);
|
||||
catalog.databases.push(CatalogDatabase { name, read_at: now_ms(), schemas });
|
||||
catalog.databases.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Self::write(&file, &catalog)
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Forgets the databases not in `keep` (dropped on the server, or no longer shown).
|
||||
pub(crate) async fn retain(&self, id: &str, keep: Vec<String>) -> IdeResult<()> {
|
||||
let _writing = self.writing.lock().await;
|
||||
let file = self.file(id);
|
||||
Self::blocking(move || {
|
||||
if !file.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut catalog = Self::read(&file);
|
||||
let before = catalog.databases.len();
|
||||
catalog.databases.retain(|d| keep.contains(&d.name));
|
||||
if catalog.databases.len() == before { Ok(()) } else { Self::write(&file, &catalog) }
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Deletes the saved catalog of data source `id` (the data source was removed).
|
||||
pub(crate) fn forget(&self, id: &str) {
|
||||
let _ = std::fs::remove_file(self.file(id));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DbTableKind;
|
||||
|
||||
fn schema(name: &str, tables: &[&str]) -> CatalogSchema {
|
||||
CatalogSchema {
|
||||
name: name.into(),
|
||||
tables: tables.iter().map(|t| TableInfo { name: (*t).into(), kind: DbTableKind::Table }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_catalog_survives_a_restart_per_project_and_data_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = CatalogStore::new(dir.path().join("db-catalogs"), Path::new("/projects/shop"));
|
||||
assert_eq!(store.load("sf").await.unwrap(), None);
|
||||
store.put("sf", "RAW", vec![schema("PUBLIC", &["EVENTS"])]).await.unwrap();
|
||||
store
|
||||
.put("sf", "ANALYTICS", vec![schema("PUBLIC", &["ORDERS", "USERS"]), schema("STAGING", &[])])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// A new store over the same directory: what the next run of the app sees.
|
||||
let reopened = CatalogStore::new(dir.path().join("db-catalogs"), Path::new("/projects/shop"));
|
||||
let saved = reopened.load("sf").await.unwrap().unwrap();
|
||||
assert_eq!(saved.databases.iter().map(|d| d.name.as_str()).collect::<Vec<_>>(), ["ANALYTICS", "RAW"]);
|
||||
assert_eq!(saved.databases[0].schemas, vec![schema("PUBLIC", &["ORDERS", "USERS"]), schema("STAGING", &[])]);
|
||||
assert!(saved.databases.iter().all(|d| d.read_at > 0.0));
|
||||
|
||||
// Another data source and another project keep their own.
|
||||
assert_eq!(reopened.load("other").await.unwrap(), None);
|
||||
let elsewhere = CatalogStore::new(dir.path().join("db-catalogs"), Path::new("/projects/blog"));
|
||||
assert_eq!(elsewhere.load("sf").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rereading_replaces_a_database_and_dropped_ones_are_forgotten() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = CatalogStore::new(dir.path().to_path_buf(), Path::new("/p"));
|
||||
store.put("sf", "RAW", vec![schema("PUBLIC", &["OLD"])]).await.unwrap();
|
||||
store.put("sf", "GONE", vec![schema("PUBLIC", &["T"])]).await.unwrap();
|
||||
store.put("sf", "RAW", vec![schema("PUBLIC", &["NEW"])]).await.unwrap();
|
||||
store.retain("sf", vec!["RAW".into()]).await.unwrap();
|
||||
let saved = store.load("sf").await.unwrap().unwrap();
|
||||
assert_eq!(saved.databases.len(), 1);
|
||||
assert_eq!(saved.databases[0].schemas, vec![schema("PUBLIC", &["NEW"])]);
|
||||
// Nothing half-written is left beside the file.
|
||||
let files: Vec<_> = std::fs::read_dir(dir.path()).unwrap().map(|f| f.unwrap().file_name()).collect();
|
||||
assert_eq!(files.len(), 1);
|
||||
|
||||
store.forget("sf");
|
||||
assert_eq!(store.load("sf").await.unwrap(), None);
|
||||
store.retain("sf", vec![]).await.unwrap();
|
||||
assert_eq!(store.load("sf").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_damaged_file_reads_as_an_empty_catalog() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = CatalogStore::new(dir.path().to_path_buf(), Path::new("/p"));
|
||||
std::fs::write(store.file("sf"), b"{ not json").unwrap();
|
||||
assert_eq!(store.load("sf").await.unwrap(), Some(SourceCatalog::default()));
|
||||
store.put("sf", "RAW", vec![schema("PUBLIC", &["T"])]).await.unwrap();
|
||||
assert_eq!(store.load("sf").await.unwrap().unwrap().databases.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,13 @@ pub enum Pool {
|
||||
SqlServer(Bb8Pool<MssqlConnectionManager>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(crate::oracle::OraclePool),
|
||||
Snowflake(crate::snowflake::SnowflakePool),
|
||||
}
|
||||
|
||||
/// Some servers and proxies (e.g. Aliyun RDS for MySQL 5.6) advertise `CLIENT_DEPRECATE_EOF` but
|
||||
/// still end result sets with legacy EOF packets, which sqlx then fails to read as OK packets.
|
||||
fn legacy_eof(e: &sqlx::Error) -> bool {
|
||||
matches!(e, sqlx::Error::Protocol(msg) if msg.starts_with("OK_Packet too short"))
|
||||
}
|
||||
|
||||
pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
|
||||
@@ -283,6 +290,16 @@ fn mssql_pool_err(e: bb8::RunError<tiberius::error::Error>) -> IdeError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiberius requires TLS when a TLS backend is enabled. `trust_cert` only skips
|
||||
/// certificate checks, so Disable has to turn encryption off.
|
||||
fn apply_sqlserver_ssl(config: &mut tiberius::Config, ssl: DbSslMode) {
|
||||
match ssl {
|
||||
DbSslMode::Disable => config.encryption(tiberius::EncryptionLevel::NotSupported),
|
||||
DbSslMode::Prefer => config.trust_cert(),
|
||||
DbSslMode::Require | DbSslMode::VerifyCa | DbSslMode::VerifyFull => {}
|
||||
}
|
||||
}
|
||||
|
||||
const DAYS_1900_01_01: i64 = 693_595;
|
||||
|
||||
/// Formats a proleptic Gregorian date given days since `0001-01-01`.
|
||||
@@ -693,6 +710,9 @@ impl Setup {
|
||||
if source.driver == DbDriver::Clickhouse && !o.time_zone.trim().is_empty() {
|
||||
s.params.push(("session_timezone".into(), o.time_zone.trim().into()));
|
||||
}
|
||||
if source.driver == DbDriver::Snowflake && !o.time_zone.trim().is_empty() {
|
||||
s.params.push(("TIMEZONE".into(), o.time_zone.trim().into()));
|
||||
}
|
||||
let script = o.startup_script.trim();
|
||||
if !script.is_empty() {
|
||||
s.statements.extend(crate::split::split(script, source.driver).into_iter().map(|st| st.text));
|
||||
@@ -710,6 +730,7 @@ impl Pool {
|
||||
Pool::SqlServer(_) => DbDriver::SqlServer,
|
||||
Pool::Clickhouse(_) => DbDriver::Clickhouse,
|
||||
Pool::Oracle(_) => DbDriver::Oracle,
|
||||
Pool::Snowflake(_) => DbDriver::Snowflake,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,6 +752,9 @@ impl Pool {
|
||||
if source.driver == DbDriver::Oracle {
|
||||
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
|
||||
}
|
||||
if source.driver == DbDriver::Snowflake {
|
||||
return crate::snowflake::SnowflakePool::connect(source, password, setup, root).await.map(Pool::Snowflake);
|
||||
}
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let init = Arc::new(setup.statements);
|
||||
// Runs the session statements (startup script…) on every new connection.
|
||||
@@ -855,9 +879,16 @@ impl Pool {
|
||||
if !tz.is_empty() {
|
||||
opts = opts.timezone(Some(tz.to_owned()));
|
||||
}
|
||||
let manager = MysqlManager { options: opts, init: init.clone(), timeout };
|
||||
let mut manager = MysqlManager { options: opts, init: init.clone(), timeout };
|
||||
// Open once directly so authentication failures retain their database error.
|
||||
let first = manager.connect().await.map_err(db_err)?;
|
||||
let first = match manager.connect().await {
|
||||
Err(e) if legacy_eof(&e) => {
|
||||
manager.options = manager.options.clone().deprecate_eof(false);
|
||||
manager.connect().await
|
||||
}
|
||||
first => first,
|
||||
}
|
||||
.map_err(db_err)?;
|
||||
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
|
||||
let _ = pool.add(first);
|
||||
Ok(Pool::Mysql(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
|
||||
@@ -876,9 +907,7 @@ impl Pool {
|
||||
if !source.database.is_empty() {
|
||||
config.database(&source.database);
|
||||
}
|
||||
if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) {
|
||||
config.trust_cert();
|
||||
}
|
||||
apply_sqlserver_ssl(&mut config, source.ssl);
|
||||
let manager = MssqlConnectionManager(config);
|
||||
let pool = bb8::Pool::builder()
|
||||
.max_size(MAX)
|
||||
@@ -891,7 +920,7 @@ impl Pool {
|
||||
Ok(Pool::SqlServer(pool))
|
||||
}
|
||||
DbDriver::Clickhouse => unreachable!(),
|
||||
DbDriver::Oracle => unreachable!(),
|
||||
DbDriver::Oracle | DbDriver::Snowflake => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -899,6 +928,7 @@ impl Pool {
|
||||
match self {
|
||||
Pool::SqlServer(_) | Pool::Oracle(_) => {}
|
||||
Pool::Clickhouse(p) => p.close(),
|
||||
Pool::Snowflake(p) => p.close().await,
|
||||
Pool::Sqlite(p) => p.close().await,
|
||||
Pool::Postgres(p) => p.closed.store(true, Ordering::Relaxed),
|
||||
Pool::Mysql(p) => p.closed.store(true, Ordering::Relaxed),
|
||||
@@ -910,6 +940,7 @@ impl Pool {
|
||||
Pool::SqlServer(_) => false,
|
||||
Pool::Oracle(p) => p.is_closed(),
|
||||
Pool::Clickhouse(p) => p.is_closed(),
|
||||
Pool::Snowflake(p) => p.is_closed(),
|
||||
Pool::Sqlite(p) => p.is_closed(),
|
||||
Pool::Postgres(p) => p.closed.load(Ordering::Relaxed),
|
||||
Pool::Mysql(p) => p.closed.load(Ordering::Relaxed),
|
||||
@@ -921,6 +952,9 @@ impl Pool {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
|
||||
}
|
||||
if let Pool::Snowflake(p) = self {
|
||||
return Ok(p.metadata().execute(sql, max, &CancellationToken::new()).await?.rows);
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
conn.apply_context(None).await?;
|
||||
let fetched = conn.fetch_rows(sql, max).await;
|
||||
@@ -945,6 +979,9 @@ impl Pool {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return p.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
if let Pool::Snowflake(p) = self {
|
||||
return p.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
let r = match conn.apply_context(None).await {
|
||||
Ok(()) => conn.execute_from(self, sql, skip, max_rows, cancel).await,
|
||||
@@ -962,6 +999,7 @@ impl Pool {
|
||||
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
|
||||
match self {
|
||||
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
Pool::Snowflake(p) => p.transaction(statements).await,
|
||||
Pool::SqlServer(_) => {
|
||||
let mut held = Held::acquire(self).await?;
|
||||
held.apply_context(None).await?;
|
||||
@@ -1043,6 +1081,8 @@ pub enum Held {
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(Box<crate::oracle::OracleConnection>),
|
||||
/// The shared session, and the schema this holder's statements run in.
|
||||
Snowflake(crate::snowflake::SnowflakePool, Option<String>),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -1059,6 +1099,12 @@ impl Held {
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
Pool::Oracle(p) => Held::Oracle(Box::new(p.get().await?)),
|
||||
Pool::Snowflake(p) => {
|
||||
if p.is_closed() {
|
||||
return Err(IdeError::new(ErrorKind::Io, "Snowflake connection is closed"));
|
||||
}
|
||||
Held::Snowflake(p.clone(), None)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1103,6 +1149,11 @@ impl Held {
|
||||
}
|
||||
|
||||
pub async fn apply_context(&mut self, schema: Option<&str>) -> IdeResult<()> {
|
||||
if let Held::Snowflake(_, wanted) = self {
|
||||
// Applied with each statement, while it holds the shared session.
|
||||
*wanted = schema.filter(|s| !s.is_empty()).map(str::to_owned);
|
||||
return Ok(());
|
||||
}
|
||||
let driver = match self {
|
||||
Held::Postgres(_) => DbDriver::Postgres,
|
||||
Held::Mysql(_) => DbDriver::Mysql,
|
||||
@@ -1158,7 +1209,7 @@ impl Held {
|
||||
Held::Mysql(c) => fetch!(&mut ****c, sql, max, Cells::Text),
|
||||
Held::SqlServer(c) => mssql_fetch(c, sql, 0, max).await,
|
||||
Held::Oracle(c) => crate::oracle::run(c, sql, 0, max, Cells::Text, &CancellationToken::new()).await,
|
||||
Held::Clickhouse(_) => unreachable!(),
|
||||
Held::Clickhouse(_) | Held::Snowflake(..) => unreachable!(),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.after_statement(sql);
|
||||
@@ -1180,6 +1231,12 @@ impl Held {
|
||||
match self {
|
||||
Held::SqlServer(c) => mssql_command(c, sql).await,
|
||||
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
Held::Snowflake(c, schema) => {
|
||||
if sql.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
c.execute_in(schema.as_deref(), sql, 0, 0, &CancellationToken::new()).await.map(drop)
|
||||
}
|
||||
Held::Oracle(c) => {
|
||||
let sql = sql.trim();
|
||||
if sql.is_empty() {
|
||||
@@ -1193,7 +1250,9 @@ impl Held {
|
||||
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut ****c).await.map(drop),
|
||||
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut ****c).await.map(drop),
|
||||
Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(),
|
||||
Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) | Held::Snowflake(..) => {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
.map_err(db_err)
|
||||
}
|
||||
@@ -1218,7 +1277,7 @@ impl Held {
|
||||
Held::SqlServer(mut c) => {
|
||||
c.0.take();
|
||||
}
|
||||
Held::Clickhouse(_) => {}
|
||||
Held::Clickhouse(_) | Held::Snowflake(..) => {}
|
||||
Held::Oracle(mut c) => c.discard_inner(),
|
||||
}
|
||||
}
|
||||
@@ -1248,6 +1307,9 @@ impl Held {
|
||||
if let Held::Clickhouse(c) = self {
|
||||
return c.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
if let Held::Snowflake(c, schema) = self {
|
||||
return c.execute_in(schema.as_deref(), sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
self.before_statement(sql);
|
||||
let started = Instant::now();
|
||||
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
|
||||
@@ -1292,7 +1354,7 @@ impl Held {
|
||||
}
|
||||
}
|
||||
Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?,
|
||||
Held::Clickhouse(_) => unreachable!(),
|
||||
Held::Clickhouse(_) | Held::Snowflake(..) => unreachable!(),
|
||||
};
|
||||
self.after_statement(sql);
|
||||
let (columns, rows, affected, has_more, first) = fetched;
|
||||
@@ -1315,7 +1377,7 @@ pub fn begin_statement(driver: DbDriver) -> &'static str {
|
||||
DbDriver::Mysql => "START TRANSACTION",
|
||||
DbDriver::SqlServer => "BEGIN TRAN",
|
||||
DbDriver::Oracle => "", // opens implicitly with autocommit off
|
||||
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN",
|
||||
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse | DbDriver::Snowflake => "BEGIN",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1331,6 +1393,33 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::source::{DbOptions, DbProperty};
|
||||
|
||||
fn sqlserver_debug(ssl: DbSslMode) -> String {
|
||||
let mut config = tiberius::Config::new();
|
||||
apply_sqlserver_ssl(&mut config, ssl);
|
||||
format!("{config:?}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_disable_does_not_require_tls() {
|
||||
let debug = sqlserver_debug(DbSslMode::Disable);
|
||||
assert!(debug.contains("encryption: NotSupported"), "{debug}");
|
||||
assert!(debug.contains("bypass: false"), "{debug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_prefer_trusts_the_server_certificate() {
|
||||
let debug = sqlserver_debug(DbSslMode::Prefer);
|
||||
assert!(debug.contains("encryption: Required"), "{debug}");
|
||||
assert!(debug.contains("bypass: true"), "{debug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_require_keeps_validated_tls() {
|
||||
let debug = sqlserver_debug(DbSslMode::Require);
|
||||
assert!(debug.contains("encryption: Required"), "{debug}");
|
||||
assert!(debug.contains("bypass: false"), "{debug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_sqlserver_connections_are_broken() {
|
||||
let manager = MssqlConnectionManager(tiberius::Config::new());
|
||||
|
||||
+261
-23
@@ -2,6 +2,7 @@
|
||||
//! execution ([`conn`]), introspection, table data paging / editing ([`sql`])
|
||||
//! and statement splitting ([`split`]), for SQLite, PostgreSQL and MySQL/MariaDB.
|
||||
|
||||
pub mod catalog;
|
||||
mod clickhouse;
|
||||
pub mod conn;
|
||||
pub mod history;
|
||||
@@ -9,15 +10,20 @@ mod oracle;
|
||||
pub mod plan;
|
||||
pub mod plugin;
|
||||
pub mod secrets;
|
||||
mod snowflake;
|
||||
pub mod source;
|
||||
pub mod split;
|
||||
pub mod sql;
|
||||
mod watch;
|
||||
pub use watch::SourcesWatch;
|
||||
|
||||
pub use catalog::{CatalogDatabase, CatalogSchema, SourceCatalog};
|
||||
pub use conn::{Held, Pool, QueryResult, ResultColumn};
|
||||
pub use history::HistoryEntry;
|
||||
pub use plan::{PlanNode, PlanProperty};
|
||||
pub use source::{
|
||||
DataSource, DbColor, DbDriver, DbIntrospection, DbOptions, DbProperty, DbSsh, DbSshAuth, DbSslMode, DbTxMode,
|
||||
DataSource, DbColor, DbDriver, DbIntrospection, DbOptions, DbProperty, DbSnowflake, DbSnowflakeAuth, DbSsh,
|
||||
DbSshAuth, DbSslMode, DbTxMode,
|
||||
};
|
||||
pub use sql::{CellValue, DbRowEdit, SortKey};
|
||||
|
||||
@@ -109,6 +115,21 @@ pub struct SchemaObjects {
|
||||
pub routines: Vec<RoutineInfo>,
|
||||
pub packages: Vec<PackageInfo>,
|
||||
pub sequences: Vec<SequenceInfo>,
|
||||
/// Objects of kinds only some databases have (Snowflake stages, streams, tasks…).
|
||||
#[serde(default)]
|
||||
pub others: Vec<SchemaObject>,
|
||||
}
|
||||
|
||||
/// A schema object the tree lists by name under a folder of its kind.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSchemaObject")]
|
||||
pub struct SchemaObject {
|
||||
/// `stage`, `stream`, `task`, `pipe`, `fileFormat`.
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
/// What the list says about it (a stage's type, a stream's table, a task's schedule).
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -204,6 +225,18 @@ pub struct TablePage {
|
||||
pub offset: f64,
|
||||
}
|
||||
|
||||
/// What a Snowflake session computes with and acts as, and what it may switch to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSessionContext")]
|
||||
pub struct SessionContext {
|
||||
/// Empty: no warehouse is in use.
|
||||
pub warehouse: String,
|
||||
pub role: String,
|
||||
pub warehouses: Vec<String>,
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbDatabases")]
|
||||
@@ -228,6 +261,8 @@ pub struct DbManager {
|
||||
/// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]).
|
||||
global: Option<(PathBuf, SecretStore)>,
|
||||
history: Arc<HistoryStore>,
|
||||
/// What introspection read of each data source, kept between runs.
|
||||
catalogs: catalog::CatalogStore,
|
||||
/// Records executed statements in the history off the async threads, in order.
|
||||
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
|
||||
/// Passwords entered this session (not saved, or not yet), by credential key.
|
||||
@@ -235,6 +270,9 @@ pub struct DbManager {
|
||||
/// Manual-commit data sources, each with the connection of its open
|
||||
/// transaction (taken by the first statement after a commit / rollback).
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
/// The data sources as last read from disk, for [`DbManager::reconcile_sources`]
|
||||
/// (`None` until [`DbManager::watch_sources`]).
|
||||
on_disk: Mutex<Option<Vec<DataSource>>>,
|
||||
}
|
||||
|
||||
type Session = Arc<tokio::sync::Mutex<Option<Held>>>;
|
||||
@@ -336,6 +374,9 @@ impl DbManager {
|
||||
/// `passwords` / `history` are the app-wide files of saved passwords
|
||||
/// ([`SecretStore`]) and query history ([`HistoryStore`]).
|
||||
pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf, ssh: Arc<SshManager>) -> Self {
|
||||
// Beside the query history, in the app data directory.
|
||||
let catalogs = history.parent().unwrap_or(&root).join("db-catalogs");
|
||||
let catalogs = catalog::CatalogStore::new(catalogs, &root);
|
||||
let history = Arc::new(HistoryStore::new(history, &root));
|
||||
let (recorder, statements) = std::sync::mpsc::channel::<(String, String, f64)>();
|
||||
{
|
||||
@@ -355,6 +396,7 @@ impl DbManager {
|
||||
saved_passwords: SecretStore::new(passwords, &root),
|
||||
global: None,
|
||||
history,
|
||||
catalogs,
|
||||
recorder,
|
||||
root,
|
||||
pools: Mutex::default(),
|
||||
@@ -362,6 +404,7 @@ impl DbManager {
|
||||
tunnel_open: tokio::sync::Mutex::new(()),
|
||||
ssh,
|
||||
session_passwords: Mutex::default(),
|
||||
on_disk: Mutex::default(),
|
||||
sessions: Mutex::default(),
|
||||
}
|
||||
}
|
||||
@@ -417,6 +460,10 @@ impl DbManager {
|
||||
.ok_or_else(|| IdeError::not_found(format!("no data source {id}")))?
|
||||
.resolved()?;
|
||||
if let Some(db) = database {
|
||||
// The configured schema belongs to the configured database.
|
||||
if source.driver == DbDriver::Snowflake && !source.database.eq_ignore_ascii_case(db) {
|
||||
source.options.default_schema.clear();
|
||||
}
|
||||
source.database = db.to_owned();
|
||||
}
|
||||
Ok(source)
|
||||
@@ -451,9 +498,10 @@ impl DbManager {
|
||||
match sources.iter().find(|s| s.id == o.id) {
|
||||
Some(n) if *n == o => {}
|
||||
gone_or_changed => {
|
||||
let keys = [o.id.clone(), secrets::ssh_key(&o.id)];
|
||||
let keys = [o.id.clone(), secrets::ssh_key(&o.id), Self::sso_credential(&o.id)];
|
||||
match gone_or_changed {
|
||||
None => {
|
||||
self.catalogs.forget(&o.id);
|
||||
for k in &keys {
|
||||
self.session_passwords.lock().remove(k);
|
||||
self.store_of(&o).delete(k);
|
||||
@@ -474,9 +522,45 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Our own write is not reported as an edit on disk.
|
||||
if let Some(known) = self.on_disk.lock().as_mut() {
|
||||
*known = self.sources().unwrap_or(sources);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Watches the data source files (the project's and the global one) for edits made
|
||||
/// outside the IDE; `on_change` runs on a background thread once they settle, and
|
||||
/// should call [`DbManager::reconcile_sources`]. Stops when the result is dropped.
|
||||
pub fn watch_sources(&self, on_change: Box<dyn Fn() + Send>) -> IdeResult<SourcesWatch> {
|
||||
*self.on_disk.lock() = Some(self.sources().unwrap_or_default());
|
||||
SourcesWatch::start(&self.root, self.global.as_ref().map(|(dir, _)| dir.as_path()), on_change)
|
||||
}
|
||||
|
||||
/// Re-reads the data sources after their files changed on disk: connections of
|
||||
/// removed or changed sources close, as on [`DbManager::save_sources`] (saved
|
||||
/// passwords stay, the source may come back). Returns whether the list the tree
|
||||
/// shows changed, or the files no longer parse (the tree reports the error).
|
||||
pub async fn reconcile_sources(&self) -> bool {
|
||||
let now = match self.sources() {
|
||||
Ok(now) => now,
|
||||
Err(e) => {
|
||||
tracing::warn!("data sources: {}", e.message);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
let before = self.on_disk.lock().replace(now.clone()).unwrap_or_default();
|
||||
if before == now {
|
||||
return false;
|
||||
}
|
||||
for o in &before {
|
||||
if now.iter().find(|s| s.id == o.id) != Some(o) {
|
||||
self.disconnect_source(&o.id).await;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Remembers `password` for the session, and in the saved passwords when
|
||||
/// the data source saves passwords (`None` forgets it).
|
||||
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
|
||||
@@ -571,7 +655,9 @@ impl DbManager {
|
||||
ssh_secret: Option<String>,
|
||||
shared: bool,
|
||||
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
|
||||
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && source.driver != DbDriver::Sqlite) else {
|
||||
// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
|
||||
let tunneled = !matches!(source.driver, DbDriver::Sqlite | DbDriver::Snowflake);
|
||||
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled) else {
|
||||
return Ok((source.clone(), None));
|
||||
};
|
||||
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
|
||||
@@ -625,7 +711,10 @@ impl DbManager {
|
||||
None => self.password(source).await,
|
||||
};
|
||||
let (target, tunnel) = self.endpoint(source, ssh_secret, false).await?;
|
||||
let pool = Pool::connect(&target, password.as_deref(), Some(&self.root)).await.map_err(|e| {
|
||||
self.restore_sso(source).await;
|
||||
let connected = Pool::connect(&target, password.as_deref(), Some(&self.root)).await;
|
||||
self.keep_sso(source).await;
|
||||
let pool = connected.map_err(|e| {
|
||||
through_tunnel(
|
||||
e,
|
||||
source,
|
||||
@@ -638,10 +727,16 @@ impl DbManager {
|
||||
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
|
||||
DbDriver::SqlServer => "SELECT @@VERSION",
|
||||
DbDriver::Oracle => "SELECT banner FROM v$version WHERE banner LIKE 'Oracle%' AND ROWNUM = 1",
|
||||
DbDriver::Snowflake => "SELECT 'Snowflake ' || CURRENT_VERSION()",
|
||||
};
|
||||
let result = match &pool {
|
||||
// On the session the login opened: metadata has its own, which would sign in again.
|
||||
Pool::Snowflake(p) => p.execute(version, 1, &CancellationToken::new()).await.map(|r| r.rows),
|
||||
_ => pool.rows(version, 1).await,
|
||||
};
|
||||
let result = pool.rows(version, 1).await;
|
||||
pool.close().await;
|
||||
let rows = if source.driver == DbDriver::Clickhouse { result? } else { result.unwrap_or_default() };
|
||||
let strict = matches!(source.driver, DbDriver::Clickhouse | DbDriver::Snowflake);
|
||||
let rows = if strict { result? } else { result.unwrap_or_default() };
|
||||
let v = rows.into_iter().next().and_then(|r| r.into_iter().next().flatten());
|
||||
Ok(v.unwrap_or_else(|| "Connected".into()))
|
||||
}
|
||||
@@ -668,7 +763,10 @@ impl DbManager {
|
||||
let source = self.source(id)?;
|
||||
let password = self.password(&source).await;
|
||||
let (target, tunnel) = self.endpoint(&source, None, true).await?;
|
||||
let pool = match Pool::connect(&target, password.as_deref(), Some(&self.root)).await {
|
||||
self.restore_sso(&source).await;
|
||||
let connected = Pool::connect(&target, password.as_deref(), Some(&self.root)).await;
|
||||
self.keep_sso(&source).await;
|
||||
let pool = match connected {
|
||||
Ok(p) => Arc::new(p),
|
||||
Err(e) => {
|
||||
self.drop_unused_tunnel(source_id);
|
||||
@@ -681,9 +779,10 @@ impl DbManager {
|
||||
};
|
||||
// Another caller may have connected meanwhile; keep theirs.
|
||||
let kept = self.pools.lock().entry(id.to_owned()).or_insert_with(|| pool.clone()).clone();
|
||||
if Arc::ptr_eq(&kept, &pool)
|
||||
&& let Some(secs) = source.options.keep_alive_secs.filter(|s| *s > 0)
|
||||
{
|
||||
if !Arc::ptr_eq(&kept, &pool) {
|
||||
// Ours lost the race: close it rather than leave its session open on the server.
|
||||
pool.close().await;
|
||||
} else if let Some(secs) = source.options.keep_alive_secs.filter(|s| *s > 0) {
|
||||
keep_alive(&pool, Duration::from_secs(secs.into()));
|
||||
}
|
||||
Ok(kept)
|
||||
@@ -733,6 +832,23 @@ impl DbManager {
|
||||
let names = first(pool.rows(sql::sqlserver_databases(), MAX_META_ROWS).await?).collect();
|
||||
return Ok(Databases { current, names });
|
||||
}
|
||||
if pool.driver() == DbDriver::Snowflake {
|
||||
let current = pool
|
||||
.rows("SELECT CURRENT_DATABASE()", 1)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|r| r.into_iter().next().flatten())
|
||||
.unwrap_or_default();
|
||||
// `SHOW` needs no running warehouse and lists what the role may see. Columns: created_on, name…
|
||||
let names = pool
|
||||
.rows("SHOW TERSE DATABASES", MAX_META_ROWS)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|r| r.into_iter().nth(1).flatten())
|
||||
.collect();
|
||||
return Ok(Databases { current, names });
|
||||
}
|
||||
if pool.driver() != DbDriver::Postgres {
|
||||
return Err(IdeError::invalid("this data source does not list databases"));
|
||||
}
|
||||
@@ -747,17 +863,95 @@ impl DbManager {
|
||||
Ok(Databases { current, names })
|
||||
}
|
||||
|
||||
/// The schemas of `database` of data source `id` with their tables and views (Snowflake):
|
||||
/// what completion needs to follow `database.schema.table` without the tree being opened.
|
||||
/// The result is also saved ([`DbManager::saved_catalog`]) for the next run.
|
||||
pub async fn database_catalog(&self, id: &str, database: &str) -> IdeResult<Vec<CatalogSchema>> {
|
||||
let id = split_target(id).0;
|
||||
let schemas = match &*self.pool(id).await? {
|
||||
Pool::Snowflake(p) => snowflake::meta::catalog(p, database).await?,
|
||||
_ => return Err(IdeError::invalid("this data source does not list other databases")),
|
||||
};
|
||||
if let Err(e) = self.catalogs.put(id, database, schemas.clone()).await {
|
||||
// Completion still has it for this run.
|
||||
tracing::warn!("could not save the catalog of {database}: {}", e.message);
|
||||
}
|
||||
Ok(schemas)
|
||||
}
|
||||
|
||||
/// What earlier introspection of data source `id` saved (`None`: never introspected).
|
||||
/// Read from disk: the data source is not connected to.
|
||||
pub async fn saved_catalog(&self, id: &str) -> IdeResult<Option<SourceCatalog>> {
|
||||
self.catalogs.load(split_target(id).0).await
|
||||
}
|
||||
|
||||
/// Drops the saved databases of data source `id` that are not in `databases` any more.
|
||||
pub async fn retain_catalog(&self, id: &str, databases: Vec<String>) -> IdeResult<()> {
|
||||
self.catalogs.retain(split_target(id).0, databases).await
|
||||
}
|
||||
|
||||
/// Credential key of the single sign-on ID token of data source `id`.
|
||||
fn sso_credential(id: &str) -> String {
|
||||
format!("{id}#sso")
|
||||
}
|
||||
|
||||
/// Hands the saved single sign-on token of `source` to the driver, so it signs in without the browser.
|
||||
async fn restore_sso(&self, source: &DataSource) {
|
||||
let Some(key) = snowflake::sso_key(source) else { return };
|
||||
if snowflake::sso_token(&key).is_none()
|
||||
&& let Some(saved) = self.store_of(source).get(&Self::sso_credential(&source.id))
|
||||
{
|
||||
snowflake::sso_seed(&key, &saved);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the token the last sign-in of `source` left in the credential store (or forgets a refused one).
|
||||
async fn keep_sso(&self, source: &DataSource) {
|
||||
let Some(key) = snowflake::sso_key(source) else { return };
|
||||
if source.id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let credential = Self::sso_credential(&source.id);
|
||||
let store = self.store_of(source);
|
||||
let (now, saved) = (snowflake::sso_token(&key), store.get(&credential));
|
||||
if now == saved {
|
||||
return;
|
||||
}
|
||||
match now {
|
||||
Some(token) => {
|
||||
if let Err(e) = store.set(&credential, &token) {
|
||||
tracing::warn!("could not save the single sign-on token: {}", e.message);
|
||||
}
|
||||
}
|
||||
None => store.delete(&credential),
|
||||
}
|
||||
}
|
||||
|
||||
/// The warehouse and role of the session of data source `id` (Snowflake), and its choices.
|
||||
pub async fn session_context(&self, id: &str) -> IdeResult<SessionContext> {
|
||||
match &*self.pool(id).await? {
|
||||
Pool::Snowflake(p) => snowflake::meta::session(p).await,
|
||||
_ => Err(IdeError::invalid("this data source has no warehouse or role")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every schema (the Schemas tab picks the ones the tree shows); system schemas
|
||||
/// only with [`DbIntrospection::show_system`].
|
||||
pub async fn schemas(&self, id: &str) -> IdeResult<Vec<String>> {
|
||||
let system = self.source(id)?.introspection.show_system;
|
||||
let pool = self.pool(id).await?;
|
||||
if let Pool::Snowflake(p) = &*pool {
|
||||
return snowflake::meta::schemas(p, system).await;
|
||||
}
|
||||
let rows = pool.rows(sql::schemas(pool.driver(), system), MAX_META_ROWS).await?;
|
||||
Ok(rows.into_iter().filter_map(|r| r.into_iter().next().flatten()).collect())
|
||||
}
|
||||
|
||||
pub async fn tables(&self, id: &str, schema: &str) -> IdeResult<Vec<TableInfo>> {
|
||||
let pool = self.pool(id).await?;
|
||||
if let Pool::Snowflake(p) = &*pool {
|
||||
return snowflake::meta::tables(p, schema).await;
|
||||
}
|
||||
let rows = pool.rows(&sql::tables(pool.driver(), schema), MAX_META_ROWS).await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
@@ -771,6 +965,9 @@ impl DbManager {
|
||||
|
||||
pub async fn table_details(&self, id: &str, schema: &str, table: &str) -> IdeResult<TableDetails> {
|
||||
let pool = self.pool(id).await?;
|
||||
if let Pool::Snowflake(p) = &*pool {
|
||||
return snowflake::meta::table_details(p, schema, table).await;
|
||||
}
|
||||
let driver = pool.driver();
|
||||
let columns = pool
|
||||
.rows(&sql::columns(driver, schema, table), MAX_META_ROWS)
|
||||
@@ -798,7 +995,7 @@ impl DbManager {
|
||||
_ => indexes.push(IndexInfo { name, unique: flag(&unique), columns: vec![col] }),
|
||||
}
|
||||
}
|
||||
let fk_rows = if driver == DbDriver::Clickhouse {
|
||||
let fk_rows = if matches!(driver, DbDriver::Clickhouse | DbDriver::Snowflake) {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default()
|
||||
@@ -815,7 +1012,7 @@ impl DbManager {
|
||||
.collect();
|
||||
}
|
||||
foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len());
|
||||
let trigger_rows = if driver == DbDriver::Clickhouse {
|
||||
let trigger_rows = if matches!(driver, DbDriver::Clickhouse | DbDriver::Snowflake) {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
@@ -842,6 +1039,9 @@ impl DbManager {
|
||||
/// The routines and sequences of `schema`.
|
||||
pub async fn schema_objects(&self, id: &str, schema: &str) -> IdeResult<SchemaObjects> {
|
||||
let pool = self.pool(id).await?;
|
||||
if let Pool::Snowflake(p) = &*pool {
|
||||
return snowflake::meta::schema_objects(p, schema).await;
|
||||
}
|
||||
let driver = pool.driver();
|
||||
let mut out = SchemaObjects::default();
|
||||
if let Some(q) = sql::routines(driver, schema) {
|
||||
@@ -891,6 +1091,14 @@ impl DbManager {
|
||||
/// The definition of a routine (every overload), package (spec and body), trigger or sequence.
|
||||
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
|
||||
let pool = self.pool(id).await?;
|
||||
if let Pool::Snowflake(p) = &*pool {
|
||||
let defs = snowflake::meta::object_ddl(p, schema, kind, name).await?;
|
||||
if defs.is_empty() {
|
||||
return Err(IdeError::not_found(format!("{schema}.{name}")));
|
||||
}
|
||||
let defs: Vec<String> = defs.iter().map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).collect();
|
||||
return Ok(defs.join("\n"));
|
||||
}
|
||||
if kind == DbObjectKind::Package {
|
||||
let mut defs: Vec<String> = Vec::new();
|
||||
for body in [false, true] {
|
||||
@@ -991,6 +1199,26 @@ impl DbManager {
|
||||
.next();
|
||||
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::Snowflake => {
|
||||
let path = match &*pool {
|
||||
Pool::Snowflake(p) => p.database_ident().map(|db| format!("{db}.")).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let target = sql::quote_literal(driver, &format!("{path}{}", sql::qualified(driver, schema, name)));
|
||||
let mut ddl = None;
|
||||
// Views answer to their own object type on some accounts.
|
||||
for kind in ["TABLE", "VIEW"] {
|
||||
match pool.rows(&format!("SELECT GET_DDL('{kind}', {target})"), 1).await {
|
||||
Ok(rows) => {
|
||||
ddl = cells(rows, 0).into_iter().next();
|
||||
break;
|
||||
}
|
||||
Err(_) if kind == "TABLE" => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::Postgres => {
|
||||
let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next();
|
||||
let (oid, kind) = match rel.as_deref() {
|
||||
@@ -1039,7 +1267,14 @@ impl DbManager {
|
||||
let pool = self.pool(id).await?;
|
||||
let driver = pool.driver();
|
||||
let mut tables: Vec<CompletionTable> = Vec::new();
|
||||
for r in pool.rows(sql::completion_columns(driver), MAX_META_ROWS).await? {
|
||||
let rows = match &*pool {
|
||||
Pool::Snowflake(p) => {
|
||||
tables = snowflake::meta::completion(p).await?;
|
||||
Vec::new()
|
||||
}
|
||||
_ => pool.rows(sql::completion_columns(driver), MAX_META_ROWS).await?,
|
||||
};
|
||||
for r in rows {
|
||||
let mut it = r.into_iter().map(Option::unwrap_or_default);
|
||||
let mut next = || it.next().unwrap_or_default();
|
||||
let (schema, name, kind) = (next(), next(), next());
|
||||
@@ -1064,7 +1299,7 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
// Foreign keys are a nicety (JOIN completion): a server that refuses the catalog query still completes.
|
||||
let fk_rows = if driver == DbDriver::Clickhouse {
|
||||
let fk_rows = if matches!(driver, DbDriver::Clickhouse | DbDriver::Snowflake) {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
@@ -1256,18 +1491,21 @@ impl DbManager {
|
||||
let Some(session) = session else { return Ok(()) };
|
||||
let mut held = session.lock().await;
|
||||
let Some(mut conn) = held.take() else { return Ok(()) };
|
||||
let result = async {
|
||||
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
|
||||
conn.restore_default().await
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
if let Err(e) = conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
|
||||
// Discarding a Snowflake connection doesn't end its session, which the data source's
|
||||
// other targets share: the transaction stays open, so it stays held to be ended again.
|
||||
if matches!(conn, conn::Held::Snowflake(..)) {
|
||||
*held = Some(conn);
|
||||
} else {
|
||||
conn.discard().await;
|
||||
Err(e)
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
if let Err(e) = conn.restore_default().await {
|
||||
conn.discard().await;
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -39,6 +39,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
|
||||
DbDriver::Mysql => format!("EXPLAIN {s}"),
|
||||
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
|
||||
DbDriver::Oracle => format!("EXPLAIN PLAN FOR {s}"),
|
||||
DbDriver::Snowflake => format!("EXPLAIN USING TABULAR {s}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +78,10 @@ pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>>
|
||||
Ok(mysql(&names, &result.rows))
|
||||
}
|
||||
DbDriver::SqlServer => Ok(Vec::new()),
|
||||
DbDriver::Snowflake => {
|
||||
let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
Ok(snowflake(&names, &result.rows))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +202,42 @@ fn oracle(rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Snowflake's `EXPLAIN USING TABULAR`: one row per operation with its parents' ids; the
|
||||
/// first row totals the partitions and bytes the statement would scan.
|
||||
fn snowflake(names: &[&str], rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
let column = |name: &str| names.iter().position(|n| n.eq_ignore_ascii_case(name));
|
||||
let cell = |r: &[Option<String>], name: &str| column(name).and_then(|i| r.get(i).cloned().flatten());
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let operation = cell(r, "operation").unwrap_or_default();
|
||||
let id = cell(r, "id").unwrap_or_default();
|
||||
// `[0]`, `[1, 2]`: the first is the operation this one feeds.
|
||||
let parent = cell(r, "parentOperators")
|
||||
.and_then(|p| p.trim_matches(['[', ']']).split(',').next().map(|p| p.trim().to_owned()))
|
||||
.and_then(|p| ids.iter().position(|x| *x == p))
|
||||
.map(|i| i as u32);
|
||||
let mut params = cell(r, "objects").unwrap_or_default();
|
||||
if let Some(alias) = cell(r, "alias").filter(|a| !a.is_empty()) {
|
||||
params = format!("{params} {alias}").trim().to_owned();
|
||||
}
|
||||
let mut properties = Vec::new();
|
||||
for (label, name) in [
|
||||
("Expressions", "expressions"),
|
||||
("Partitions scanned", "partitionsAssigned"),
|
||||
("Partitions total", "partitionsTotal"),
|
||||
("Bytes scanned", "bytesAssigned"),
|
||||
] {
|
||||
if let Some(value) = cell(r, name).filter(|v| !v.is_empty()) {
|
||||
properties.push(prop(label, value));
|
||||
}
|
||||
}
|
||||
out.push(PlanNode { parent, operation, params, properties });
|
||||
ids.push(id);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// MySQL's tabular `EXPLAIN`: one row per table access, flat.
|
||||
fn mysql(names: &[&str], rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
let col = |r: &[Option<String>], n: &str| {
|
||||
@@ -377,4 +418,61 @@ mod tests {
|
||||
assert_eq!((plan[1].operation.as_str(), plan[1].params.as_str()), ("Index Lookup", "table: c; key: PRIMARY;"));
|
||||
assert!(plan[0].properties.iter().any(|p| p.name == "rows" && p.value == "20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_snowflake_tabular_plans() {
|
||||
let names = [
|
||||
"step",
|
||||
"id",
|
||||
"parentOperators",
|
||||
"operation",
|
||||
"objects",
|
||||
"alias",
|
||||
"expressions",
|
||||
"partitionsTotal",
|
||||
"partitionsAssigned",
|
||||
"bytesAssigned",
|
||||
];
|
||||
let row = |cells: [Option<&'static str>; 10]| cells.to_vec();
|
||||
let r = result(
|
||||
&names,
|
||||
vec![
|
||||
row([None, None, None, Some("GlobalStats"), None, None, None, Some("12"), Some("3"), Some("4096")]),
|
||||
row([Some("1"), Some("0"), None, Some("Result"), None, None, Some("O.ID"), None, None, None]),
|
||||
row([
|
||||
Some("1"),
|
||||
Some("1"),
|
||||
Some("[0]"),
|
||||
Some("Filter"),
|
||||
None,
|
||||
None,
|
||||
Some("O.ID > 3"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
]),
|
||||
row([
|
||||
Some("1"),
|
||||
Some("2"),
|
||||
Some("[1]"),
|
||||
Some("TableScan"),
|
||||
Some("DB.PUBLIC.ORDERS"),
|
||||
Some("O"),
|
||||
Some("ID"),
|
||||
Some("12"),
|
||||
Some("3"),
|
||||
Some("4096"),
|
||||
]),
|
||||
],
|
||||
);
|
||||
let plan = parse(DbDriver::Snowflake, &r).unwrap();
|
||||
assert_eq!(
|
||||
plan.iter().map(|n| n.operation.as_str()).collect::<Vec<_>>(),
|
||||
["GlobalStats", "Result", "Filter", "TableScan"]
|
||||
);
|
||||
assert_eq!(plan.iter().map(|n| n.parent).collect::<Vec<_>>(), [None, None, Some(1), Some(2)]);
|
||||
assert_eq!(plan[3].params, "DB.PUBLIC.ORDERS O");
|
||||
assert!(plan[3].properties.contains(&PlanProperty { name: "Partitions scanned".into(), value: "3".into() }));
|
||||
assert!(plan[0].properties.contains(&PlanProperty { name: "Bytes scanned".into(), value: "4096".into() }));
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ impl DbManager {
|
||||
}
|
||||
/// Completes cleanup after removal from the file.
|
||||
pub async fn forget_plugin_source(&self, id: &str) {
|
||||
for key in [id.to_owned(), crate::secrets::ssh_key(id)] {
|
||||
for key in [id.to_owned(), crate::secrets::ssh_key(id), format!("{id}#sso")] {
|
||||
self.session_passwords.lock().remove(&key);
|
||||
self.saved_passwords.delete(&key);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Where a Snowflake account is reached, and the account name its login and JWT carry.
|
||||
|
||||
use crate::source::{DataSource, DbSslMode};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use reqwest::Url;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Endpoint {
|
||||
pub base: Url,
|
||||
/// Upper-case account name: the first label of the account identifier.
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
/// Reads the data source's host: an account identifier (`myorg-myaccount`,
|
||||
/// `xy12345.eu-central-1`), a host name (`myorg-myaccount.snowflakecomputing.com`) or the
|
||||
/// account URL.
|
||||
pub(crate) fn endpoint(source: &DataSource) -> IdeResult<Endpoint> {
|
||||
let raw = source.host.trim();
|
||||
let raw = raw.strip_prefix("https://").or_else(|| raw.strip_prefix("http://")).unwrap_or(raw);
|
||||
let raw = raw.split(['/', '?', '#']).next().unwrap_or_default().trim_end_matches('.');
|
||||
let (host, port) = match raw.rsplit_once(':') {
|
||||
Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) && !port.is_empty() => {
|
||||
(host, port.parse::<u16>().ok())
|
||||
}
|
||||
_ => (raw, None),
|
||||
};
|
||||
let valid = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.');
|
||||
if host.is_empty() || host == "localhost" && source.ssl != DbSslMode::Disable || !host.chars().all(valid) {
|
||||
return Err(IdeError::invalid(
|
||||
"enter the Snowflake account identifier, for example myorg-myaccount or xy12345.eu-central-1",
|
||||
));
|
||||
}
|
||||
let account = host.split('.').next().unwrap_or_default().to_ascii_uppercase();
|
||||
let literal = host.parse::<std::net::IpAddr>().is_ok() || host == "localhost";
|
||||
let full = if literal || host.to_ascii_lowercase().contains(".snowflakecomputing.") {
|
||||
host.to_owned()
|
||||
} else {
|
||||
// Host names cannot hold underscores; Snowflake serves such accounts under the dashed name.
|
||||
format!("{}.snowflakecomputing.com", host.replace('_', "-"))
|
||||
};
|
||||
let scheme = if source.ssl == DbSslMode::Disable { "http" } else { "https" };
|
||||
let port = port.or(source.port).filter(|p| *p != 443 || scheme == "http");
|
||||
let authority = match port {
|
||||
Some(port) => format!("{full}:{port}"),
|
||||
None => full,
|
||||
};
|
||||
let base = Url::parse(&format!("{scheme}://{authority}/"))
|
||||
.map_err(|_| IdeError::invalid("invalid Snowflake account identifier"))?;
|
||||
Ok(Endpoint { base, account })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::source::DbDriver;
|
||||
|
||||
fn of(host: &str) -> Endpoint {
|
||||
endpoint(&DataSource { driver: DbDriver::Snowflake, host: host.into(), ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_identifiers_become_hosts() {
|
||||
let e = of("myorg-myaccount");
|
||||
assert_eq!(e.base.as_str(), "https://myorg-myaccount.snowflakecomputing.com/");
|
||||
assert_eq!(e.account, "MYORG-MYACCOUNT");
|
||||
let e = of("xy12345.eu-central-1");
|
||||
assert_eq!(e.base.as_str(), "https://xy12345.eu-central-1.snowflakecomputing.com/");
|
||||
assert_eq!(e.account, "XY12345");
|
||||
let e = of("xy12345.us-east-2.aws");
|
||||
assert_eq!(e.base.as_str(), "https://xy12345.us-east-2.aws.snowflakecomputing.com/");
|
||||
assert_eq!(e.account, "XY12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underscores_stay_in_the_account_name_but_not_in_the_host() {
|
||||
let e = of("my_org-my_account");
|
||||
assert_eq!(e.base.as_str(), "https://my-org-my-account.snowflakecomputing.com/");
|
||||
assert_eq!(e.account, "MY_ORG-MY_ACCOUNT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosts_and_urls_are_accepted() {
|
||||
for host in [
|
||||
"myorg-myaccount.snowflakecomputing.com",
|
||||
"https://myorg-myaccount.snowflakecomputing.com",
|
||||
"https://myorg-myaccount.snowflakecomputing.com/console/login?x=1",
|
||||
"MyOrg-MyAccount.snowflakecomputing.com:443",
|
||||
] {
|
||||
let e = of(host);
|
||||
assert_eq!(e.base.host_str().unwrap().to_ascii_lowercase(), "myorg-myaccount.snowflakecomputing.com");
|
||||
assert_eq!(e.base.port(), None);
|
||||
assert_eq!(e.account, "MYORG-MYACCOUNT");
|
||||
}
|
||||
let e = of("xy12345.eu-west-1.privatelink.snowflakecomputing.com");
|
||||
assert_eq!(e.base.as_str(), "https://xy12345.eu-west-1.privatelink.snowflakecomputing.com/");
|
||||
assert_eq!(e.account, "XY12345");
|
||||
assert_eq!(of("acct.snowflakecomputing.cn").base.as_str(), "https://acct.snowflakecomputing.cn/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_and_malformed_identifiers() {
|
||||
for host in ["", " ", "localhost", "my account", "user@host", "/path"] {
|
||||
let source = DataSource { driver: DbDriver::Snowflake, host: host.into(), ..Default::default() };
|
||||
let error = endpoint(&source).unwrap_err();
|
||||
assert!(error.message.contains("account identifier"), "{host}: {}", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_http_is_only_for_an_explicit_ssl_off() {
|
||||
let source = DataSource {
|
||||
driver: DbDriver::Snowflake,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(8080),
|
||||
ssl: DbSslMode::Disable,
|
||||
..Default::default()
|
||||
};
|
||||
let e = endpoint(&source).unwrap();
|
||||
assert_eq!(e.base.as_str(), "http://127.0.0.1:8080/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Single sign-on in the web browser (Snowflake's `externalbrowser` authenticator): Snowflake
|
||||
//! names the identity provider's page, the browser signs in there and is sent back to a
|
||||
//! listener on this computer with a one-time token, which the login request then presents
|
||||
//! together with the proof key Snowflake issued for this attempt.
|
||||
|
||||
use super::client::{Api, Request};
|
||||
use super::{APP, error, uuid};
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const AUTHENTICATOR: &str = "session/authenticator-request";
|
||||
/// How long the user has to finish signing in.
|
||||
const WAIT: Duration = Duration::from_secs(120);
|
||||
const DONE_PAGE: &str = "<!doctype html><html><head><meta charset=\"utf-8\"><title>OpenIDEA</title></head>\
|
||||
<body style=\"font-family: sans-serif; margin: 3em\"><h3>Signed in to Snowflake</h3>\
|
||||
<p>You can close this tab and return to OpenIDEA.</p></body></html>";
|
||||
|
||||
/// One browser sign-in at a time: connections opened together share its outcome through [`cached`].
|
||||
pub(crate) static FLOW: LazyLock<tokio::sync::Mutex<()>> = LazyLock::new(|| tokio::sync::Mutex::new(()));
|
||||
|
||||
/// ID tokens Snowflake issued after a browser sign-in, by account address and user. They let
|
||||
/// further sessions of this run open without the browser. Kept in memory only.
|
||||
static ID_TOKENS: LazyLock<Mutex<HashMap<String, String>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
pub(crate) fn cached(key: &str) -> Option<String> {
|
||||
ID_TOKENS.lock().get(key).cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn remember(key: &str, token: &str) {
|
||||
ID_TOKENS.lock().insert(key.to_owned(), token.to_owned());
|
||||
}
|
||||
|
||||
pub(crate) fn forget(key: &str) {
|
||||
ID_TOKENS.lock().remove(key);
|
||||
}
|
||||
|
||||
/// What opens the sign-in page; tests put a stand-in for the browser here.
|
||||
#[cfg(test)]
|
||||
type Opener = dyn Fn(&str, u16) -> IdeResult<()> + Send + Sync;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static TEST_OPENER: LazyLock<Mutex<Option<Box<Opener>>>> = LazyLock::new(|| Mutex::new(None));
|
||||
|
||||
fn open(url: &str, port: u16) -> IdeResult<()> {
|
||||
#[cfg(test)]
|
||||
if let Some(opener) = TEST_OPENER.lock().as_ref() {
|
||||
return opener(url, port);
|
||||
}
|
||||
let _ = port;
|
||||
open::that_detached(url)
|
||||
.map_err(|e| IdeError::new(ErrorKind::Io, format!("Snowflake: cannot open the web browser: {e}")))
|
||||
}
|
||||
|
||||
/// The one-time token and proof key of a completed browser sign-in.
|
||||
pub(crate) async fn authenticate(
|
||||
api: &Api,
|
||||
account: &str,
|
||||
user: &str,
|
||||
timeout: Duration,
|
||||
) -> IdeResult<(String, String)> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| IdeError::new(ErrorKind::Io, format!("Snowflake: cannot listen for the browser sign-in: {e}")))?;
|
||||
let port = listener.local_addr().map_err(|e| IdeError::new(ErrorKind::Io, e.to_string()))?.port();
|
||||
let body = json!({ "data": {
|
||||
"ACCOUNT_NAME": account,
|
||||
"LOGIN_NAME": user,
|
||||
"AUTHENTICATOR": "EXTERNALBROWSER",
|
||||
"BROWSER_MODE_REDIRECT_PORT": port.to_string(),
|
||||
"CLIENT_APP_ID": APP,
|
||||
"CLIENT_APP_VERSION": env!("CARGO_PKG_VERSION"),
|
||||
} });
|
||||
let envelope = api.send(Request::post(AUTHENTICATOR, body).query("requestId", uuid()).timeout(timeout)).await?;
|
||||
if !envelope.success {
|
||||
return Err(api.scrub.clean(error::server(envelope.code.as_deref(), envelope.message.as_deref())));
|
||||
}
|
||||
let text = |name: &str| envelope.data.get(name).and_then(Value::as_str).map(str::to_owned);
|
||||
let (Some(url), Some(proof_key)) = (text("ssoUrl"), text("proofKey")) else {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Protocol,
|
||||
"Snowflake did not name a sign-in page; check that the account allows browser sign-in",
|
||||
));
|
||||
};
|
||||
if !(url.starts_with("https://") || url.starts_with("http://")) {
|
||||
return Err(IdeError::new(ErrorKind::Protocol, "Snowflake named a sign-in page that is not a web address"));
|
||||
}
|
||||
api.scrub.add(&proof_key);
|
||||
open(&url, port)?;
|
||||
let token = tokio::time::timeout(WAIT, callback(&listener)).await.map_err(|_| {
|
||||
IdeError::new(ErrorKind::TimedOut, "Snowflake: the browser sign-in was not finished within 2 minutes")
|
||||
})??;
|
||||
api.scrub.add(&token);
|
||||
Ok((token, proof_key))
|
||||
}
|
||||
|
||||
/// Waits for the browser to come back with the token (other requests, like the tab's icon, are refused).
|
||||
async fn callback(listener: &TcpListener) -> IdeResult<String> {
|
||||
loop {
|
||||
let (mut stream, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.map_err(|e| IdeError::new(ErrorKind::Io, format!("Snowflake: browser sign-in failed: {e}")))?;
|
||||
let Some(request) = read(&mut stream).await else { continue };
|
||||
let line = request.head.lines().next().unwrap_or_default();
|
||||
let mut parts = line.split_whitespace();
|
||||
let (method, target) = (parts.next().unwrap_or_default(), parts.next().unwrap_or_default());
|
||||
let origin = request.header("origin").unwrap_or("*").to_owned();
|
||||
let cors = format!(
|
||||
"Access-Control-Allow-Origin: {origin}\r\nVary: Origin\r\nAccess-Control-Allow-Methods: POST, GET\r\n\
|
||||
Access-Control-Allow-Headers: {}\r\n",
|
||||
request.header("access-control-request-headers").unwrap_or("content-type")
|
||||
);
|
||||
if method == "OPTIONS" {
|
||||
respond(&mut stream, "200 OK", &cors, "").await;
|
||||
continue;
|
||||
}
|
||||
let token = match method {
|
||||
"GET" => target.split_once('?').and_then(|(_, query)| parameter(query, "token")),
|
||||
"POST" => serde_json::from_str::<Value>(&request.body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("token").and_then(Value::as_str).map(str::to_owned))
|
||||
.or_else(|| parameter(&request.body, "token")),
|
||||
_ => None,
|
||||
};
|
||||
match token.filter(|t| !t.is_empty()) {
|
||||
Some(token) => {
|
||||
respond(&mut stream, "200 OK", &format!("{cors}Content-Type: text/html; charset=utf-8\r\n"), DONE_PAGE)
|
||||
.await;
|
||||
return Ok(token);
|
||||
}
|
||||
None => respond(&mut stream, "404 Not Found", "", "").await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Received {
|
||||
head: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl Received {
|
||||
fn header(&self, name: &str) -> Option<&str> {
|
||||
self.head.lines().skip(1).find_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
key.eq_ignore_ascii_case(name).then(|| value.trim())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn read(stream: &mut TcpStream) -> Option<Received> {
|
||||
const LIMIT: usize = 256 * 1024;
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0; 8192];
|
||||
loop {
|
||||
let count = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut buffer)).await.ok()?.ok()?;
|
||||
if count == 0 || bytes.len() > LIMIT {
|
||||
return None;
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let head = String::from_utf8_lossy(&bytes[..end]).into_owned();
|
||||
let length: usize = head
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.to_ascii_lowercase().strip_prefix("content-length:").and_then(|v| v.trim().parse().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if length > LIMIT {
|
||||
return None;
|
||||
}
|
||||
if bytes.len() >= end + 4 + length {
|
||||
return Some(Received { head, body: String::from_utf8_lossy(&bytes[end + 4..]).into_owned() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn respond(stream: &mut TcpStream, status: &str, headers: &str, body: &str) {
|
||||
let response =
|
||||
format!("HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.shutdown().await;
|
||||
}
|
||||
|
||||
/// The value of `name` in a URL query or form body.
|
||||
fn parameter(query: &str, name: &str) -> Option<String> {
|
||||
query.split('&').find_map(|pair| {
|
||||
let (key, value) = pair.split_once('=')?;
|
||||
(key == name).then(|| decode(value))
|
||||
})
|
||||
}
|
||||
|
||||
fn decode(text: &str) -> String {
|
||||
let bytes = text.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let hex = |i: usize| bytes.get(i).and_then(|&c| (c as char).to_digit(16));
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%'
|
||||
&& let (Some(high), Some(low)) = (hex(i + 1), hex(i + 2))
|
||||
{
|
||||
out.push((high * 16 + low) as u8);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] });
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_the_token_from_a_query_or_form() {
|
||||
assert_eq!(parameter("token=abc%2Bdef%3D%3D&x=1", "token").as_deref(), Some("abc+def=="));
|
||||
assert_eq!(parameter("x=1&token=t", "token").as_deref(), Some("t"));
|
||||
assert_eq!(parameter("tokens=1", "token"), None);
|
||||
assert_eq!(parameter("", "token"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! The HTTP side of the Snowflake session protocol: one JSON envelope per response.
|
||||
//! Every error leaves through [`Scrubber::clean`], so no credential reaches a message or a log.
|
||||
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::RwLock;
|
||||
use reqwest::{Client, Method, StatusCode, Url};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Replaces known secrets (passphrase, password, tokens) in text that leaves the driver.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Scrubber(Arc<RwLock<Vec<String>>>);
|
||||
|
||||
impl Scrubber {
|
||||
pub(crate) fn add(&self, secret: &str) {
|
||||
// Short values would blank out ordinary words.
|
||||
if secret.len() >= 4 {
|
||||
let mut secrets = self.0.write();
|
||||
if !secrets.iter().any(|s| s == secret) {
|
||||
secrets.push(secret.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clean(&self, mut error: IdeError) -> IdeError {
|
||||
for secret in self.0.read().iter() {
|
||||
if error.message.contains(secret.as_str()) {
|
||||
error.message = error.message.replace(secret.as_str(), "[redacted]");
|
||||
}
|
||||
}
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
/// A response body: `data` on success, `code` and `message` on failure.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub(crate) struct Envelope {
|
||||
pub success: bool,
|
||||
#[serde(deserialize_with = "code")]
|
||||
pub code: Option<String>,
|
||||
pub message: Option<String>,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
/// Codes arrive as strings, numbers or null.
|
||||
fn code<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
|
||||
Ok(match Value::deserialize(deserializer)? {
|
||||
Value::String(s) if !s.is_empty() => Some(s),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
impl Envelope {
|
||||
pub(crate) fn has_code(&self, codes: &[&str]) -> bool {
|
||||
self.code.as_deref().is_some_and(|code| codes.contains(&code))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Api {
|
||||
http: Client,
|
||||
base: Url,
|
||||
pub scrub: Scrubber,
|
||||
}
|
||||
|
||||
pub(crate) struct Request<'a> {
|
||||
pub method: Method,
|
||||
pub path: &'a str,
|
||||
pub query: Vec<(&'a str, String)>,
|
||||
/// The session or master token.
|
||||
pub token: Option<&'a str>,
|
||||
pub body: Option<Value>,
|
||||
pub timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl<'a> Request<'a> {
|
||||
pub(crate) fn post(path: &'a str, body: Value) -> Self {
|
||||
Self { method: Method::POST, path, query: Vec::new(), token: None, body: Some(body), timeout: None }
|
||||
}
|
||||
|
||||
pub(crate) fn get(path: &'a str) -> Self {
|
||||
Self { method: Method::GET, path, query: Vec::new(), token: None, body: None, timeout: None }
|
||||
}
|
||||
|
||||
pub(crate) fn token(mut self, token: &'a str) -> Self {
|
||||
self.token = Some(token);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn query(mut self, name: &'a str, value: impl Into<String>) -> Self {
|
||||
self.query.push((name, value.into()));
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub(crate) fn new(http: Client, base: Url) -> Self {
|
||||
Self { http, base, scrub: Scrubber::default() }
|
||||
}
|
||||
|
||||
/// The account's address, for keys that tell accounts apart.
|
||||
pub(crate) fn address(&self) -> String {
|
||||
format!("{}:{}", self.host(), self.base.port_or_known_default().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn host(&self) -> &str {
|
||||
self.base.host_str().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Sends `request` and reads the envelope, whatever it reports.
|
||||
pub(crate) async fn send(&self, request: Request<'_>) -> IdeResult<Envelope> {
|
||||
let url = self.base.join(request.path).map_err(|_| IdeError::internal("invalid Snowflake request path"))?;
|
||||
let mut builder = self
|
||||
.http
|
||||
.request(request.method, url)
|
||||
.query(&request.query)
|
||||
.header(reqwest::header::ACCEPT, "application/snowflake");
|
||||
if let Some(token) = request.token {
|
||||
builder = builder.header(reqwest::header::AUTHORIZATION, format!("Snowflake Token=\"{token}\""));
|
||||
}
|
||||
if let Some(body) = &request.body {
|
||||
builder = builder.header(reqwest::header::CONTENT_TYPE, "application/json").body(body.to_string());
|
||||
}
|
||||
if let Some(timeout) = request.timeout {
|
||||
builder = builder.timeout(timeout);
|
||||
}
|
||||
let response = builder.send().await.map_err(|e| self.transport(e))?;
|
||||
let status = response.status();
|
||||
let body = response.bytes().await.map_err(|e| self.transport(e))?;
|
||||
match serde_json::from_slice::<Envelope>(&body) {
|
||||
Ok(envelope) if status.is_success() || envelope.code.is_some() || envelope.message.is_some() => {
|
||||
Ok(envelope)
|
||||
}
|
||||
_ => Err(self.scrub.clean(self.status_error(status))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads a result chunk from cloud storage (no Snowflake credentials: the URL is presigned).
|
||||
pub(crate) async fn chunk(&self, url: &str, headers: &[(String, String)]) -> IdeResult<Vec<u8>> {
|
||||
let mut builder = self.http.get(url);
|
||||
for (name, value) in headers {
|
||||
builder = builder.header(name.as_str(), value.as_str());
|
||||
}
|
||||
let response = builder.send().await.map_err(|e| self.transport(e))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("Snowflake: cannot download a result chunk (HTTP {})", status.as_u16()),
|
||||
));
|
||||
}
|
||||
Ok(response.bytes().await.map_err(|e| self.transport(e))?.to_vec())
|
||||
}
|
||||
|
||||
fn status_error(&self, status: StatusCode) -> IdeError {
|
||||
let host = self.host();
|
||||
match status {
|
||||
StatusCode::FORBIDDEN | StatusCode::NOT_FOUND => IdeError::invalid(format!(
|
||||
"Snowflake has no account at {host} (HTTP {}): check the account identifier",
|
||||
status.as_u16()
|
||||
)),
|
||||
s if s.is_server_error() || s == StatusCode::TOO_MANY_REQUESTS => IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("Snowflake is temporarily unavailable (HTTP {}); try again", s.as_u16()),
|
||||
),
|
||||
s => IdeError::new(ErrorKind::Protocol, format!("Snowflake: unexpected response (HTTP {})", s.as_u16())),
|
||||
}
|
||||
}
|
||||
|
||||
fn transport(&self, error: reqwest::Error) -> IdeError {
|
||||
use std::error::Error;
|
||||
let kind = if error.is_timeout() { ErrorKind::TimedOut } else { ErrorKind::Io };
|
||||
let connect = error.is_connect();
|
||||
let error = error.without_url();
|
||||
let mut message = error.to_string();
|
||||
let mut source = error.source();
|
||||
while let Some(error) = source {
|
||||
let detail = error.to_string();
|
||||
if !message.contains(&detail) {
|
||||
message.push_str(": ");
|
||||
message.push_str(&detail);
|
||||
}
|
||||
source = error.source();
|
||||
}
|
||||
let hint = if connect { "; check the account identifier and the network connection" } else { "" };
|
||||
self.scrub.clean(IdeError::new(kind, format!("Snowflake ({}): {message}{hint}", self.host())))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn envelopes_read_any_code_shape() {
|
||||
let e: Envelope =
|
||||
serde_json::from_str(r#"{"success":false,"code":"390100","message":"no","data":null}"#).unwrap();
|
||||
assert_eq!(e.code.as_deref(), Some("390100"));
|
||||
assert!(e.has_code(&["390100"]));
|
||||
let e: Envelope = serde_json::from_str(r#"{"success":false,"code":390100}"#).unwrap();
|
||||
assert_eq!(e.code.as_deref(), Some("390100"));
|
||||
let e: Envelope = serde_json::from_str(r#"{"success":true,"code":null,"data":{"a":1}}"#).unwrap();
|
||||
assert!(e.success && e.code.is_none());
|
||||
assert_eq!(e.data["a"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_removes_every_known_secret() {
|
||||
let scrub = Scrubber::default();
|
||||
scrub.add("s3cret-passphrase");
|
||||
scrub.add("eyJ.token.value");
|
||||
scrub.add("ab");
|
||||
let e = scrub.clean(IdeError::invalid("failed with s3cret-passphrase and eyJ.token.value, ab stays"));
|
||||
assert_eq!(e.message, "failed with [redacted] and [redacted], ab stays");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Every SQL command of Snowflake's reference
|
||||
//! (<https://docs.snowflake.com/en/sql-reference/sql-all>) through the parts of the client a
|
||||
//! statement passes: the script splitter, the read-only guard and the wire. The samples are in
|
||||
//! `tests/fixtures/snowflake/commands.json`, one per documented command; the SQL assistance of
|
||||
//! the UI reads the same file (`src/features/database/sql/snowflakeCommands.test.ts`).
|
||||
//!
|
||||
//! The samples exercise the client, not Snowflake's grammar: a server is needed to prove a
|
||||
//! statement valid, which the ignored live test does for the commands that only read.
|
||||
|
||||
use super::tests::{CLOSED_OK, LOGIN_OK, bind, ok, result, serve, source};
|
||||
use super::{first_word, is_write};
|
||||
use crate::conn::Pool;
|
||||
use crate::source::DbDriver;
|
||||
use crate::split::split;
|
||||
use ide_core::CancellationToken;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct Command {
|
||||
pub command: String,
|
||||
pub sql: String,
|
||||
/// Runs on a read-only data source: it reads, or changes the session only.
|
||||
pub reads: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Fixture {
|
||||
commands: Vec<Command>,
|
||||
}
|
||||
|
||||
pub(crate) fn commands() -> Vec<Command> {
|
||||
let fixture: Fixture =
|
||||
serde_json::from_str(include_str!("../../tests/fixtures/snowflake/commands.json")).expect("the fixture parses");
|
||||
fixture.commands
|
||||
}
|
||||
|
||||
/// `PUT` and `GET` move files on the client's side.
|
||||
fn transfers(c: &Command) -> bool {
|
||||
matches!(first_word(&c.sql).as_str(), "PUT" | "GET")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fixture_covers_the_reference() {
|
||||
let commands = commands();
|
||||
assert!(commands.len() >= 786, "{} commands", commands.len());
|
||||
let mut names: Vec<&str> = commands.iter().map(|c| c.command.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), commands.len(), "a command is listed twice");
|
||||
for family in ["ALTER", "CREATE", "DESCRIBE", "DROP", "SHOW", "UNDROP", "USE", "GRANT", "REVOKE", "EXECUTE"] {
|
||||
assert!(names.iter().any(|n| n.starts_with(family)), "no {family} command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_is_one_statement() {
|
||||
let commands = commands();
|
||||
for c in &commands {
|
||||
let alone = split(&c.sql, DbDriver::Snowflake);
|
||||
assert_eq!(alone.len(), 1, "{}: split into {} statements", c.command, alone.len());
|
||||
assert_eq!(alone[0].text, c.sql.trim(), "{}", c.command);
|
||||
// Terminated, commented and followed by another statement.
|
||||
let script = format!("-- {}; a comment\n{};\n// next; one\nselect 1;", c.command, c.sql);
|
||||
let parts: Vec<String> = split(&script, DbDriver::Snowflake).into_iter().map(|s| s.text).collect();
|
||||
assert_eq!(parts.len(), 2, "{}: {parts:?}", c.command);
|
||||
assert!(parts[0].ends_with(c.sql.trim()), "{}: {:?}", c.command, parts[0]);
|
||||
assert_eq!(parts[1].trim_start_matches("// next; one\n"), "select 1", "{}", c.command);
|
||||
}
|
||||
// The whole reference as one script.
|
||||
let script = commands.iter().map(|c| format!("{};\n", c.sql)).collect::<String>();
|
||||
let parts = split(&script, DbDriver::Snowflake);
|
||||
assert_eq!(parts.len(), commands.len());
|
||||
for (part, c) in parts.iter().zip(&commands) {
|
||||
assert_eq!(part.text, c.sql.trim(), "{}", c.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_is_known_to_read_or_to_write() {
|
||||
for c in commands() {
|
||||
assert_eq!(!is_write(&c.sql), c.reads, "{}: {}", c.command, c.sql);
|
||||
assert!(!first_word(&c.sql).is_empty(), "{}", c.command);
|
||||
// Comments before a statement do not change what it is.
|
||||
let commented = format!("/* note; */ -- one\n// two\n{}", c.sql);
|
||||
assert_eq!(!is_write(&commented), c.reads, "{} after comments", c.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_command_reaches_the_server_as_written_and_its_answer_is_read() {
|
||||
let commands = commands();
|
||||
let (listener, port) = bind().await;
|
||||
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string();
|
||||
let rows = result(
|
||||
json!([{ "name": "name", "type": "text" }, { "name": "n", "type": "fixed", "scale": 0 }]),
|
||||
json!([["a", "1"], ["b", null]]),
|
||||
);
|
||||
let changed =
|
||||
json!({ "success": true, "data": { "rowtype": [{ "name": "number of rows inserted", "type": "fixed" }],
|
||||
"rowset": [["3"]], "queryResultFormat": "json", "statementTypeId": 0x3100, "total": 1 } })
|
||||
.to_string();
|
||||
// Login (which names the schema), then one answer per command.
|
||||
let mut replies = vec![ok(LOGIN_OK)];
|
||||
let mut entered = true;
|
||||
for c in commands.iter().filter(|c| !transfers(c)) {
|
||||
if !entered && first_word(&c.sql) != "USE" {
|
||||
replies.push(ok(status.clone()));
|
||||
}
|
||||
// These leave the session wherever they went: its schema is entered again afterwards.
|
||||
entered = !matches!(first_word(&c.sql).as_str(), "USE" | "CALL" | "EXECUTE");
|
||||
let dml = matches!(first_word(&c.sql).as_str(), "INSERT" | "UPDATE" | "DELETE" | "MERGE");
|
||||
replies.push(ok(if c.reads && first_word(&c.sql) != "USE" {
|
||||
rows.clone()
|
||||
} else if dml {
|
||||
changed.clone()
|
||||
} else {
|
||||
status.clone()
|
||||
}));
|
||||
}
|
||||
replies.push(ok(CLOSED_OK));
|
||||
let server = serve(listener, replies);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
for c in &commands {
|
||||
if transfers(c) {
|
||||
// Refused before anything is sent: the files would not move.
|
||||
let refused = pool.execute(&c.sql, 100, &cancel).await.unwrap_err();
|
||||
assert!(refused.message.contains("Snowflake CLI"), "{}: {}", c.command, refused.message);
|
||||
continue;
|
||||
}
|
||||
let answer = pool.execute(&c.sql, 100, &cancel).await;
|
||||
let answer = answer.unwrap_or_else(|e| panic!("{}: {}", c.command, e.message));
|
||||
let word = first_word(&c.sql);
|
||||
if c.reads && word != "USE" {
|
||||
assert_eq!(
|
||||
answer.rows,
|
||||
[[Some("a".to_owned()), Some("1".to_owned())], [Some("b".to_owned()), None]],
|
||||
"{}",
|
||||
c.command
|
||||
);
|
||||
assert_eq!(answer.affected, None, "{}", c.command);
|
||||
} else if matches!(word.as_str(), "INSERT" | "UPDATE" | "DELETE" | "MERGE") {
|
||||
assert_eq!(answer.affected, Some(3.0), "{}", c.command);
|
||||
} else {
|
||||
assert!(answer.columns.is_empty() && answer.rows.is_empty(), "{}: a command shows no grid", c.command);
|
||||
}
|
||||
}
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
let sent = seen[1..seen.len() - 1]
|
||||
.iter()
|
||||
.map(|r| r.json()["sqlText"].as_str().unwrap_or_default().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let sent: Vec<&str> = sent.iter().map(String::as_str).collect();
|
||||
let context = "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"";
|
||||
let written: Vec<&str> = sent.iter().copied().filter(|sql| *sql != context).collect();
|
||||
assert_eq!(written, commands.iter().filter(|c| !transfers(c)).map(|c| c.sql.as_str()).collect::<Vec<_>>());
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Snowflake error codes as errors the UI can act on: `PermissionDenied` asks for the
|
||||
//! data source's secret again, everything else shows its message.
|
||||
|
||||
use ide_core::{IdeError, error::ErrorKind};
|
||||
|
||||
/// The session token expired; the master token renews it.
|
||||
pub(crate) const SESSION_EXPIRED: &str = "390112";
|
||||
/// The session is gone (master token expired, session closed by the server): log in again.
|
||||
pub(crate) const SESSION_GONE: &[&str] = &["390111", "390114", "390104"];
|
||||
/// The statement still runs; its result is polled.
|
||||
pub(crate) const IN_PROGRESS: &[&str] = &["333333", "333334"];
|
||||
|
||||
const KEY_HINT: &str = "Check that the public key of this private key is assigned to the user \
|
||||
(ALTER USER … SET RSA_PUBLIC_KEY), and that the account identifier and user name are right.";
|
||||
|
||||
/// What to do about a failed login or statement, by Snowflake error code.
|
||||
fn hint(code: &str, message: &str) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
"390144" => KEY_HINT,
|
||||
"394300" => "Snowflake found no such user: check the user name and the account identifier.",
|
||||
"394302" => "The login token arrived too late: check this computer's clock.",
|
||||
"394303" => "The login token had expired: check this computer's clock.",
|
||||
"394304" => {
|
||||
"The public key assigned to the user does not match this private key: compare the fingerprint below \
|
||||
with RSA_PUBLIC_KEY_FP of DESC USER."
|
||||
}
|
||||
"394307" => "The account in the login token does not match: check the account identifier.",
|
||||
"390189" => "The role is not granted to this user: change the role of the data source.",
|
||||
"390201" => "Check the database, schema and warehouse of the data source, and that the role may use them.",
|
||||
"000606" => "Set a warehouse in the data source, or run USE WAREHOUSE <name>.",
|
||||
"390422" | "390420" => "A network policy of the account blocks this address.",
|
||||
_ if message.contains("is not allowed to access Snowflake") => {
|
||||
"A network policy of the account blocks this address."
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the login was refused for reasons a different private key or account setting
|
||||
/// fixes, so the key's fingerprint helps.
|
||||
pub(crate) fn key_rejected(code: &str) -> bool {
|
||||
code == "390144" || code.starts_with("3943")
|
||||
}
|
||||
|
||||
fn kind(code: &str) -> ErrorKind {
|
||||
match code {
|
||||
// Wrong password or token; expired or revoked tokens.
|
||||
"390100" | "390303" | "390318" | "394400" | "394401" | "394402" => ErrorKind::PermissionDenied,
|
||||
"000604" | "000605" => ErrorKind::Cancelled,
|
||||
"000630" => ErrorKind::TimedOut,
|
||||
_ => ErrorKind::InvalidInput,
|
||||
}
|
||||
}
|
||||
|
||||
/// The error of a response that reports failure.
|
||||
pub(crate) fn server(code: Option<&str>, message: Option<&str>) -> IdeError {
|
||||
let code = code.unwrap_or_default();
|
||||
let message = message.map(str::trim).filter(|m| !m.is_empty()).unwrap_or("the request failed");
|
||||
let mut text =
|
||||
if code.is_empty() { format!("Snowflake: {message}") } else { format!("Snowflake [{code}]: {message}") };
|
||||
if let Some(hint) = hint(code, message) {
|
||||
text.push('\n');
|
||||
text.push_str(hint);
|
||||
}
|
||||
IdeError::new(kind(code), text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrong_credentials_ask_again_and_other_failures_explain() {
|
||||
let e = server(Some("390100"), Some("Incorrect username or password was specified."));
|
||||
assert_eq!(e.kind, ErrorKind::PermissionDenied);
|
||||
assert_eq!(e.message, "Snowflake [390100]: Incorrect username or password was specified.");
|
||||
let e = server(Some("390144"), Some("JWT token is invalid. [abc]"));
|
||||
assert_eq!(e.kind, ErrorKind::InvalidInput);
|
||||
assert!(e.message.starts_with("Snowflake [390144]: JWT token is invalid. [abc]\nCheck that the public key"));
|
||||
assert!(key_rejected("390144") && key_rejected("394304") && !key_rejected("390100"));
|
||||
let e = server(Some("000606"), Some("No active warehouse selected in the current session."));
|
||||
assert!(e.message.ends_with("run USE WAREHOUSE <name>."), "{}", e.message);
|
||||
let e = server(Some("002003"), Some("SQL compilation error:\nObject 'T' does not exist or not authorized."));
|
||||
assert_eq!(e.kind, ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
e.message,
|
||||
"Snowflake [002003]: SQL compilation error:\nObject 'T' does not exist or not authorized."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_policy_and_bare_failures() {
|
||||
let e = server(Some("390432"), Some("IP 203.0.113.9 is not allowed to access Snowflake."));
|
||||
assert!(e.message.ends_with("A network policy of the account blocks this address."));
|
||||
assert_eq!(server(None, None).message, "Snowflake: the request failed");
|
||||
assert_eq!(server(Some("000604"), Some("SQL execution canceled")).kind, ErrorKind::Cancelled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Key-pair authentication: reads the user's RSA private key file and signs the login JWT.
|
||||
//! Parsing, decryption and signing are RustCrypto's (`rsa`, `pkcs8`, `pkcs5`).
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use rsa::RsaPrivateKey;
|
||||
use rsa::pkcs1::DecodeRsaPrivateKey;
|
||||
use rsa::pkcs1v15::SigningKey;
|
||||
use rsa::pkcs8::{EncodePublicKey, EncryptedPrivateKeyInfoRef, PrivateKeyInfoRef, SecretDocument};
|
||||
use rsa::sha2::{Digest, Sha256};
|
||||
use rsa::signature::{SignatureEncoding, Signer};
|
||||
use rsa::traits::PublicKeyParts;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Snowflake rejects a JWT that lives longer than an hour.
|
||||
const LIFETIME_SECS: u64 = 59 * 60;
|
||||
const MIN_BITS: usize = 2048;
|
||||
const CONVERT: &str = "openssl pkcs8 -topk8 -v2 aes-256-cbc -in <key file> -out rsa_key.p8";
|
||||
|
||||
pub(crate) struct KeyPair {
|
||||
key: RsaPrivateKey,
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> IdeError {
|
||||
IdeError::invalid(message)
|
||||
}
|
||||
|
||||
/// The UI answers `PermissionDenied` by asking for the secret, here the key's passphrase.
|
||||
fn passphrase_error(message: &str) -> IdeError {
|
||||
IdeError::new(ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
|
||||
/// Reads the private key file at `path` (`~/` is the home directory).
|
||||
pub(crate) fn load(path: &str, passphrase: Option<&str>) -> IdeResult<KeyPair> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
return Err(invalid("select the private key file of the Snowflake user"));
|
||||
}
|
||||
let file = ide_ssh::expand(path);
|
||||
let bytes = Zeroizing::new(
|
||||
std::fs::read(&file)
|
||||
.map_err(|e| invalid(format!("cannot read the private key file {}: {e}", file.display())))?,
|
||||
);
|
||||
let Ok(text) = std::str::from_utf8(&bytes) else {
|
||||
return Err(invalid(format!(
|
||||
"{} is not a PEM file; convert a DER key with: openssl pkcs8 -inform DER -outform PEM -in <key file> -out rsa_key.p8",
|
||||
file.display()
|
||||
)));
|
||||
};
|
||||
parse(text, passphrase)
|
||||
}
|
||||
|
||||
pub(crate) fn parse(pem: &str, passphrase: Option<&str>) -> IdeResult<KeyPair> {
|
||||
let pem = pem.trim();
|
||||
if pem.contains("Proc-Type: 4,ENCRYPTED") {
|
||||
return Err(invalid(format!(
|
||||
"the private key uses the legacy encrypted PEM format; convert it to PKCS#8 with: {CONVERT}"
|
||||
)));
|
||||
}
|
||||
if pem.contains("BEGIN OPENSSH PRIVATE KEY") {
|
||||
return Err(invalid(
|
||||
"the private key is in OpenSSH format; convert it to PKCS#8 with: ssh-keygen -p -m PKCS8 -f <key file>",
|
||||
));
|
||||
}
|
||||
let (label, document) = SecretDocument::from_pem(pem).map_err(|_| {
|
||||
if pem.contains("PRIVATE KEY-----") {
|
||||
invalid("the private key file is damaged")
|
||||
} else {
|
||||
invalid("the private key file holds no PEM private key (expected -----BEGIN PRIVATE KEY-----)")
|
||||
}
|
||||
})?;
|
||||
let key = match label {
|
||||
"PRIVATE KEY" => from_pkcs8(document.as_bytes(), "the private key file is damaged")?,
|
||||
"ENCRYPTED PRIVATE KEY" => {
|
||||
let Some(passphrase) = passphrase.filter(|p| !p.is_empty()) else {
|
||||
return Err(passphrase_error("the private key is encrypted; enter its passphrase"));
|
||||
};
|
||||
let encrypted = EncryptedPrivateKeyInfoRef::try_from(document.as_bytes())
|
||||
.map_err(|_| invalid("the encrypted private key file is damaged"))?;
|
||||
let decrypted = encrypted.decrypt(passphrase).map_err(|error| match error {
|
||||
rsa::pkcs8::Error::EncryptedPrivateKey(rsa::pkcs8::pkcs5::Error::DecryptFailed) => {
|
||||
passphrase_error("incorrect passphrase for the private key")
|
||||
}
|
||||
rsa::pkcs8::Error::EncryptedPrivateKey(_) => invalid(format!(
|
||||
"the private key is encrypted with an unsupported algorithm; re-encrypt it with: {CONVERT}"
|
||||
)),
|
||||
_ => invalid("the encrypted private key file is damaged"),
|
||||
})?;
|
||||
// A wrong passphrase can leave valid padding around bytes that are no key.
|
||||
from_pkcs8(decrypted.as_bytes(), "incorrect passphrase for the private key").map_err(|error| {
|
||||
if error.message.starts_with("incorrect passphrase") { passphrase_error(&error.message) } else { error }
|
||||
})?
|
||||
}
|
||||
"RSA PRIVATE KEY" => RsaPrivateKey::from_pkcs1_der(document.as_bytes())
|
||||
.map_err(|_| invalid("the private key file is damaged"))?,
|
||||
"PUBLIC KEY" | "RSA PUBLIC KEY" => {
|
||||
return Err(invalid("this file holds the public key; select the private key file"));
|
||||
}
|
||||
"EC PRIVATE KEY" => return Err(not_rsa()),
|
||||
other => return Err(invalid(format!("unsupported private key format: {other}"))),
|
||||
};
|
||||
let bits = key.size() * 8;
|
||||
if bits < MIN_BITS {
|
||||
return Err(invalid(format!("the RSA key has {bits} bits; Snowflake requires at least {MIN_BITS}")));
|
||||
}
|
||||
Ok(KeyPair { key })
|
||||
}
|
||||
|
||||
fn not_rsa() -> IdeError {
|
||||
invalid("the private key is not an RSA key; Snowflake key-pair authentication needs an RSA key")
|
||||
}
|
||||
|
||||
fn from_pkcs8(der: &[u8], malformed: &str) -> IdeResult<RsaPrivateKey> {
|
||||
let info = PrivateKeyInfoRef::try_from(der).map_err(|_| invalid(malformed))?;
|
||||
if info.algorithm.oid != rsa::pkcs1::ALGORITHM_OID {
|
||||
return Err(not_rsa());
|
||||
}
|
||||
RsaPrivateKey::try_from(info).map_err(|_| invalid(malformed))
|
||||
}
|
||||
|
||||
impl KeyPair {
|
||||
/// `SHA256:<base64>` of the public key, as `DESC USER` shows it in `RSA_PUBLIC_KEY_FP`.
|
||||
pub(crate) fn fingerprint(&self) -> IdeResult<String> {
|
||||
let der = self
|
||||
.key
|
||||
.to_public_key()
|
||||
.to_public_key_der()
|
||||
.map_err(|e| IdeError::internal(format!("cannot encode the public key: {e}")))?;
|
||||
Ok(format!("SHA256:{}", STANDARD.encode(Sha256::digest(der.as_bytes()))))
|
||||
}
|
||||
|
||||
/// The login token for `user` of `account` (both as Snowflake stores them: upper case).
|
||||
pub(crate) fn jwt(&self, account: &str, user: &str, now_secs: u64) -> IdeResult<String> {
|
||||
let subject = format!("{}.{}", account.to_uppercase(), user.to_uppercase());
|
||||
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"RS256","typ":"JWT"}"#);
|
||||
let claims = serde_json::json!({
|
||||
"iss": format!("{subject}.{}", self.fingerprint()?),
|
||||
"sub": subject,
|
||||
"iat": now_secs,
|
||||
"exp": now_secs + LIFETIME_SECS,
|
||||
});
|
||||
let message = format!("{header}.{}", URL_SAFE_NO_PAD.encode(claims.to_string()));
|
||||
let signature = SigningKey::<Sha256>::new(self.key.clone())
|
||||
.try_sign(message.as_bytes())
|
||||
.map_err(|e| IdeError::internal(format!("cannot sign the login token: {e}")))?;
|
||||
Ok(format!("{message}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rsa::pkcs1v15::{Signature, VerifyingKey};
|
||||
use rsa::signature::Verifier;
|
||||
|
||||
const PLAIN: &str = include_str!("../../tests/fixtures/snowflake/rsa_key.p8");
|
||||
const ENCRYPTED: &str = include_str!("../../tests/fixtures/snowflake/rsa_key_encrypted.p8");
|
||||
const SCRYPT: &str = include_str!("../../tests/fixtures/snowflake/rsa_key_scrypt.p8");
|
||||
const PKCS1: &str = include_str!("../../tests/fixtures/snowflake/rsa_key_pkcs1.pem");
|
||||
const LEGACY: &str = include_str!("../../tests/fixtures/snowflake/rsa_key_legacy_encrypted.pem");
|
||||
const SHORT: &str = include_str!("../../tests/fixtures/snowflake/rsa_key_1024.p8");
|
||||
const EC: &str = include_str!("../../tests/fixtures/snowflake/ec_key.p8");
|
||||
const PUBLIC: &str = include_str!("../../tests/fixtures/snowflake/rsa_key.pub");
|
||||
/// Computed with OpenSSL (see the fixtures' README).
|
||||
const FINGERPRINT: &str = "SHA256:ajjL6Bf6l1FIIyTcJli4i2AWPYDVJGQnSZr+9q9ClgI=";
|
||||
const PASSPHRASE: &str = "test-passphrase";
|
||||
|
||||
fn error(pem: &str, passphrase: Option<&str>) -> IdeError {
|
||||
match parse(pem, passphrase) {
|
||||
Ok(_) => panic!("the key was accepted"),
|
||||
Err(error) => error,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_supported_format_yields_the_same_key() {
|
||||
for (pem, passphrase) in [
|
||||
(PLAIN, None),
|
||||
(PLAIN, Some("ignored")),
|
||||
(ENCRYPTED, Some(PASSPHRASE)),
|
||||
(SCRYPT, Some(PASSPHRASE)),
|
||||
(PKCS1, None),
|
||||
] {
|
||||
assert_eq!(parse(pem, passphrase).unwrap().fingerprint().unwrap(), FINGERPRINT);
|
||||
}
|
||||
assert_eq!(parse(&format!("\n\n{PLAIN}\n\n"), None).unwrap().fingerprint().unwrap(), FINGERPRINT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encrypted_key_asks_for_its_passphrase() {
|
||||
for passphrase in [None, Some("")] {
|
||||
let e = error(ENCRYPTED, passphrase);
|
||||
assert_eq!(e.kind, ErrorKind::PermissionDenied);
|
||||
assert!(e.message.contains("enter its passphrase"), "{}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_passphrase_is_named_and_asks_again() {
|
||||
for pem in [ENCRYPTED, SCRYPT] {
|
||||
for wrong in ["wrong", "test-passphrase ", "Test-Passphrase"] {
|
||||
let e = error(pem, Some(wrong));
|
||||
assert_eq!(e.kind, ErrorKind::PermissionDenied, "{}", e.message);
|
||||
assert_eq!(e.message, "incorrect passphrase for the private key");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_files_say_what_is_wrong() {
|
||||
let cases = [
|
||||
(LEGACY, "legacy encrypted PEM"),
|
||||
(SHORT, "1024 bits"),
|
||||
(EC, "not an RSA key"),
|
||||
(PUBLIC, "public key"),
|
||||
("", "no PEM private key"),
|
||||
("not a key", "no PEM private key"),
|
||||
("-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----", "OpenSSH format"),
|
||||
("-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----", "damaged"),
|
||||
];
|
||||
for (pem, expected) in cases {
|
||||
let e = error(pem, Some(PASSPHRASE));
|
||||
assert_eq!(e.kind, ErrorKind::InvalidInput, "{expected}: {}", e.message);
|
||||
assert!(e.message.contains(expected), "{expected}: {}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_unreadable_files_are_reported_with_their_path() {
|
||||
let e = load("", None).err().unwrap();
|
||||
assert!(e.message.contains("select the private key file"));
|
||||
let e = load("/nonexistent/openidea/rsa_key.p8", None).err().unwrap();
|
||||
assert_eq!(e.kind, ErrorKind::InvalidInput);
|
||||
assert!(e.message.contains("/nonexistent/openidea/rsa_key.p8"), "{}", e.message);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let der = dir.path().join("key.der");
|
||||
std::fs::write(&der, [0x30, 0x82, 0xff, 0xfe, 0x00]).unwrap();
|
||||
let e = load(der.to_str().unwrap(), None).err().unwrap();
|
||||
assert!(e.message.contains("not a PEM file"), "{}", e.message);
|
||||
let good = dir.path().join("rsa_key.p8");
|
||||
std::fs::write(&good, ENCRYPTED).unwrap();
|
||||
assert_eq!(load(good.to_str().unwrap(), Some(PASSPHRASE)).unwrap().fingerprint().unwrap(), FINGERPRINT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_carries_snowflake_claims_and_a_valid_rs256_signature() {
|
||||
let pair = parse(PLAIN, None).unwrap();
|
||||
let token = pair.jwt("myorg-myaccount", "[email protected]", 1_700_000_000).unwrap();
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
assert_eq!(parts.len(), 3);
|
||||
let decode = |part: &str| URL_SAFE_NO_PAD.decode(part).unwrap();
|
||||
let header: serde_json::Value = serde_json::from_slice(&decode(parts[0])).unwrap();
|
||||
assert_eq!(header, serde_json::json!({"alg": "RS256", "typ": "JWT"}));
|
||||
let claims: serde_json::Value = serde_json::from_slice(&decode(parts[1])).unwrap();
|
||||
assert_eq!(claims["sub"], "[email protected]");
|
||||
assert_eq!(claims["iss"], format!("[email protected].{FINGERPRINT}"));
|
||||
assert_eq!(claims["iat"], 1_700_000_000u64);
|
||||
assert_eq!(claims["exp"], 1_700_000_000u64 + 59 * 60);
|
||||
let verifier = VerifyingKey::<Sha256>::new(pair.key.to_public_key());
|
||||
let signature = Signature::try_from(decode(parts[2]).as_slice()).unwrap();
|
||||
verifier.verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &signature).unwrap();
|
||||
assert!(verifier.verify(format!("{}.{}x", parts[0], parts[1]).as_bytes(), &signature).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
//! The Database tree's metadata, read with `SHOW` and `DESCRIBE`: they need no running
|
||||
//! warehouse, list only what the session's role may see, and are asked one level at a time.
|
||||
|
||||
use super::SnowflakePool;
|
||||
use crate::sql::{quote_ident, quote_literal};
|
||||
use crate::{
|
||||
ColumnInfo, CompletionColumn, CompletionTable, DbDriver, DbObjectKind, DbTableKind, ForeignKey, RoutineInfo,
|
||||
SchemaObject, SchemaObjects, SequenceInfo, TableDetails, TableInfo,
|
||||
};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `SHOW … LIMIT` takes at most this many rows; longer lists continue `FROM` the last name.
|
||||
const PAGE: usize = 10_000;
|
||||
const MAX_ROWS: usize = 100_000;
|
||||
const DRIVER: DbDriver = DbDriver::Snowflake;
|
||||
|
||||
/// A `SHOW` / `DESCRIBE` result, read by column name (their column order varies by version).
|
||||
struct Shown {
|
||||
columns: Vec<String>,
|
||||
rows: Vec<Vec<Option<String>>>,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
fn index(&self, name: &str) -> Option<usize> {
|
||||
self.columns.iter().position(|c| c.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn values<'a>(&'a self, name: &str) -> impl Iterator<Item = Option<&'a str>> + 'a {
|
||||
let index = self.index(name);
|
||||
self.rows.iter().map(move |row| index.and_then(|i| row.get(i)).and_then(|v| v.as_deref()))
|
||||
}
|
||||
|
||||
fn records(&self) -> impl Iterator<Item = Record<'_>> {
|
||||
self.rows.iter().map(|row| Record { shown: self, row })
|
||||
}
|
||||
}
|
||||
|
||||
struct Record<'a> {
|
||||
shown: &'a Shown,
|
||||
row: &'a [Option<String>],
|
||||
}
|
||||
|
||||
impl<'a> Record<'a> {
|
||||
fn get(&self, name: &str) -> Option<&'a str> {
|
||||
self.shown.index(name).and_then(|i| self.row.get(i)).and_then(|v| v.as_deref())
|
||||
}
|
||||
|
||||
fn text(&self, name: &str) -> String {
|
||||
self.get(name).unwrap_or_default().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
async fn show(pool: &SnowflakePool, sql: &str) -> IdeResult<Shown> {
|
||||
let result = pool.metadata().execute(sql, MAX_ROWS, &CancellationToken::new()).await?;
|
||||
Ok(Shown { columns: result.columns.into_iter().map(|c| c.name).collect(), rows: result.rows })
|
||||
}
|
||||
|
||||
/// A `SHOW` that lists by name, page after page.
|
||||
async fn show_all(pool: &SnowflakePool, sql: &str) -> IdeResult<Shown> {
|
||||
let mut all = show(pool, &format!("{sql} LIMIT {PAGE}")).await?;
|
||||
let mut last_page = all.rows.len();
|
||||
while last_page >= PAGE && all.rows.len() < MAX_ROWS {
|
||||
let Some(last) = all.values("name").last().flatten().map(str::to_owned) else { break };
|
||||
let next = show(pool, &format!("{sql} LIMIT {PAGE} FROM {}", quote_literal(DRIVER, &last))).await?;
|
||||
last_page = next.rows.len();
|
||||
all.rows.extend(next.rows);
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
/// ` IN DATABASE "DB"`, or nothing for the session's current database.
|
||||
fn in_database(pool: &SnowflakePool) -> String {
|
||||
pool.database_ident().map(|db| format!(" IN DATABASE {db}")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `"DB"."SCHEMA"` (the schema as the tree names it: exactly).
|
||||
fn schema_path(pool: &SnowflakePool, schema: &str) -> String {
|
||||
match pool.database_ident() {
|
||||
Some(db) => format!("{db}.{}", quote_ident(DRIVER, schema)),
|
||||
None => quote_ident(DRIVER, schema),
|
||||
}
|
||||
}
|
||||
|
||||
fn object_path(pool: &SnowflakePool, schema: &str, name: &str) -> String {
|
||||
format!("{}.{}", schema_path(pool, schema), quote_ident(DRIVER, name))
|
||||
}
|
||||
|
||||
pub(crate) async fn schemas(pool: &SnowflakePool, system: bool) -> IdeResult<Vec<String>> {
|
||||
let shown = show_all(pool, &format!("SHOW TERSE SCHEMAS{}", in_database(pool))).await?;
|
||||
Ok(shown
|
||||
.values("name")
|
||||
.flatten()
|
||||
.filter(|name| system || *name != "INFORMATION_SCHEMA")
|
||||
.map(str::to_owned)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn table_kind(kind: &str) -> DbTableKind {
|
||||
let kind = kind.to_ascii_uppercase().replace('_', " ");
|
||||
if kind == "MATERIALIZED VIEW" {
|
||||
DbTableKind::MaterializedView
|
||||
} else if kind.ends_with("VIEW") {
|
||||
DbTableKind::View
|
||||
} else {
|
||||
DbTableKind::Table
|
||||
}
|
||||
}
|
||||
|
||||
/// Tables of every kind (dynamic, external, hybrid…) and views of `schema`.
|
||||
pub(crate) async fn tables(pool: &SnowflakePool, schema: &str) -> IdeResult<Vec<TableInfo>> {
|
||||
let shown = show_all(pool, &format!("SHOW TERSE OBJECTS IN SCHEMA {}", schema_path(pool, schema))).await?;
|
||||
Ok(shown
|
||||
.records()
|
||||
.filter_map(|r| {
|
||||
Some(TableInfo { name: r.get("name")?.to_owned(), kind: table_kind(r.get("kind").unwrap_or("TABLE")) })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn table_details(pool: &SnowflakePool, schema: &str, table: &str) -> IdeResult<TableDetails> {
|
||||
let path = object_path(pool, schema, table);
|
||||
let described = show(pool, &format!("DESCRIBE TABLE {path}")).await?;
|
||||
let yes = |v: Option<&str>| v.is_some_and(|v| v.eq_ignore_ascii_case("Y"));
|
||||
let columns = described
|
||||
.records()
|
||||
.filter(|r| r.get("kind").is_none_or(|kind| kind.eq_ignore_ascii_case("COLUMN")))
|
||||
.filter_map(|r| {
|
||||
Some(ColumnInfo {
|
||||
name: r.get("name")?.to_owned(),
|
||||
data_type: r.text("type"),
|
||||
nullable: yes(r.get("null?")),
|
||||
default: r.get("default").filter(|d| !d.is_empty()).map(str::to_owned),
|
||||
primary_key: yes(r.get("primary key")),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Views have no keys; a role without the privilege still sees the columns.
|
||||
let foreign_keys = match show(pool, &format!("SHOW IMPORTED KEYS IN TABLE {path}")).await {
|
||||
Ok(keys) => foreign_keys(&keys),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
Ok(TableDetails { columns, indexes: Vec::new(), foreign_keys, triggers: Vec::new() })
|
||||
}
|
||||
|
||||
fn foreign_keys(shown: &Shown) -> Vec<ForeignKey> {
|
||||
let mut rows: Vec<Record<'_>> = shown.records().collect();
|
||||
let sequence = |r: &Record<'_>| r.get("key_sequence").and_then(|n| n.parse::<u32>().ok()).unwrap_or(0);
|
||||
rows.sort_by_key(|a| (a.text("fk_name"), sequence(a)));
|
||||
let mut keys: Vec<(String, ForeignKey)> = Vec::new();
|
||||
for r in rows {
|
||||
let name = r.text("fk_name");
|
||||
if !keys.last().is_some_and(|(last, _)| *last == name) {
|
||||
keys.push((
|
||||
name,
|
||||
ForeignKey {
|
||||
schema: r.text("fk_schema_name"),
|
||||
table: r.text("fk_table_name"),
|
||||
columns: Vec::new(),
|
||||
ref_schema: r.text("pk_schema_name"),
|
||||
ref_table: r.text("pk_table_name"),
|
||||
ref_columns: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
let key = &mut keys.last_mut().expect("pushed above").1;
|
||||
key.columns.push(r.text("fk_column_name"));
|
||||
key.ref_columns.push(r.text("pk_column_name"));
|
||||
}
|
||||
keys.into_iter().map(|(_, key)| key).collect()
|
||||
}
|
||||
|
||||
/// Rows of `SHOW TERSE OBJECTS` for database `db` (quoted; `None`: the session's), each with
|
||||
/// its `schema_name`. One listing for the database when that answers in full; schema by schema
|
||||
/// (as the tree lists them) when it is refused or is cut off at a page, since a page
|
||||
/// continues by object name and names repeat across schemas.
|
||||
async fn database_objects(pool: &SnowflakePool, db: Option<&str>, schemas: &[String]) -> IdeResult<Shown> {
|
||||
let scope = db.map(|db| format!(" IN DATABASE {db}")).unwrap_or_default();
|
||||
match show(pool, &format!("SHOW TERSE OBJECTS{scope} LIMIT {PAGE}")).await {
|
||||
Ok(whole) if whole.rows.len() < PAGE && whole.index("schema_name").is_some() => return Ok(whole),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!("listing a whole database failed, listing its schemas instead: {}", e.message),
|
||||
}
|
||||
let mut all = Shown { columns: vec!["name".into(), "kind".into(), "schema_name".into()], rows: Vec::new() };
|
||||
let (mut listed, mut refused) = (0, None);
|
||||
for schema in schemas {
|
||||
let path = match db {
|
||||
Some(db) => format!("{db}.{}", quote_ident(DRIVER, schema)),
|
||||
None => quote_ident(DRIVER, schema),
|
||||
};
|
||||
// A schema the role may not list: the others still complete.
|
||||
let shown = match show_all(pool, &format!("SHOW TERSE OBJECTS IN SCHEMA {path}")).await {
|
||||
Ok(shown) => shown,
|
||||
Err(e) => {
|
||||
refused.get_or_insert(e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
listed += 1;
|
||||
let (Some(name), Some(kind)) = (shown.index("name"), shown.index("kind")) else { continue };
|
||||
all.rows.extend(shown.rows.into_iter().map(|mut row| {
|
||||
let kind = row.get_mut(kind).and_then(Option::take);
|
||||
let name = row.get_mut(name).and_then(Option::take);
|
||||
vec![name, kind, Some(schema.clone())]
|
||||
}));
|
||||
if all.rows.len() >= MAX_ROWS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Nothing could be listed at all: that is a failure, not an empty database.
|
||||
match refused {
|
||||
Some(e) if listed == 0 => Err(e),
|
||||
_ => Ok(all),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every schema of `database` with its tables and views, for completion across databases.
|
||||
pub(crate) async fn catalog(pool: &SnowflakePool, database: &str) -> IdeResult<Vec<crate::CatalogSchema>> {
|
||||
let db = quote_ident(DRIVER, database);
|
||||
let schemas = show_all(pool, &format!("SHOW TERSE SCHEMAS IN DATABASE {db}")).await?;
|
||||
let names: Vec<String> =
|
||||
schemas.values("name").flatten().filter(|name| *name != "INFORMATION_SCHEMA").map(str::to_owned).collect();
|
||||
let mut out: Vec<crate::CatalogSchema> =
|
||||
names.iter().map(|name| crate::CatalogSchema { name: name.clone(), tables: Vec::new() }).collect();
|
||||
let at: HashMap<String, usize> = out.iter().enumerate().map(|(i, s)| (s.name.clone(), i)).collect();
|
||||
let objects = database_objects(pool, Some(&db), &names).await?;
|
||||
for r in objects.records() {
|
||||
let (Some(&index), Some(name)) = (at.get(r.get("schema_name").unwrap_or_default()), r.get("name")) else {
|
||||
continue;
|
||||
};
|
||||
out[index].tables.push(TableInfo { name: name.to_owned(), kind: table_kind(r.get("kind").unwrap_or("TABLE")) });
|
||||
}
|
||||
for schema in &mut out {
|
||||
schema.tables.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
schema.tables.dedup_by(|a, b| a.name == b.name);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The session's warehouse and role, and the ones it may switch to.
|
||||
pub(crate) async fn session(pool: &SnowflakePool) -> IdeResult<crate::SessionContext> {
|
||||
// The consoles' session: `USE WAREHOUSE` / `USE ROLE` there is what the pickers show.
|
||||
let sql = "SELECT CURRENT_WAREHOUSE(), CURRENT_ROLE(), CURRENT_AVAILABLE_ROLES()";
|
||||
let current = pool.execute(sql, 1, &CancellationToken::new()).await?.rows;
|
||||
let cell = |i: usize| current.first().and_then(|r| r.get(i).cloned().flatten()).unwrap_or_default();
|
||||
let mut roles: Vec<String> = serde_json::from_str(&cell(2)).unwrap_or_default();
|
||||
roles.sort();
|
||||
let warehouses = match show(pool, "SHOW WAREHOUSES").await {
|
||||
Ok(shown) => shown.values("name").flatten().map(str::to_owned).collect(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
Ok(crate::SessionContext { warehouse: cell(0), role: cell(1), warehouses, roles })
|
||||
}
|
||||
|
||||
/// `NAME(NUMBER, VARCHAR) RETURN NUMBER` as its argument types and result type.
|
||||
fn signature(arguments: &str) -> (String, Option<String>) {
|
||||
let inner = arguments.split_once('(').map_or(arguments, |(_, rest)| rest);
|
||||
// The result type may hold parentheses of its own (`TABLE (…)`): split at the marker.
|
||||
let (types, returns) = match inner.split_once(") RETURN ") {
|
||||
Some((types, returns)) => (types, Some(returns.trim().to_owned())),
|
||||
None => (inner.strip_suffix(')').unwrap_or(inner), None),
|
||||
};
|
||||
(types.trim().to_owned(), returns.filter(|r| !r.is_empty()))
|
||||
}
|
||||
|
||||
async fn routines(
|
||||
pool: &SnowflakePool,
|
||||
schema: &str,
|
||||
kind: DbObjectKind,
|
||||
like: Option<&str>,
|
||||
) -> IdeResult<Vec<RoutineInfo>> {
|
||||
let what = if kind == DbObjectKind::Procedure { "PROCEDURES" } else { "USER FUNCTIONS" };
|
||||
let like = like.map(|name| format!(" LIKE {}", quote_literal(DRIVER, &escape_like(name)))).unwrap_or_default();
|
||||
let shown = show(pool, &format!("SHOW {what}{like} IN SCHEMA {}", schema_path(pool, schema))).await?;
|
||||
Ok(shown
|
||||
.records()
|
||||
.filter(|r| !r.get("is_builtin").is_some_and(|b| b.eq_ignore_ascii_case("Y")))
|
||||
.filter_map(|r| {
|
||||
let (arguments, returns) = signature(r.get("arguments").unwrap_or_default());
|
||||
Some(RoutineInfo {
|
||||
name: r.get("name")?.to_owned(),
|
||||
kind,
|
||||
arguments,
|
||||
returns: returns.filter(|_| kind == DbObjectKind::Function),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `LIKE` patterns match `_` and `%` loosely; escape them to match the name itself.
|
||||
fn escape_like(name: &str) -> String {
|
||||
name.replace('\\', "\\\\").replace('_', "\\_").replace('%', "\\%")
|
||||
}
|
||||
|
||||
/// Kinds of the tree's other folders, the `SHOW` that lists them, and the column that says most about one.
|
||||
const OTHERS: &[(&str, &str, &str)] = &[
|
||||
("stage", "STAGES", "type"),
|
||||
("stream", "STREAMS", "table_name"),
|
||||
("task", "TASKS", "schedule"),
|
||||
("pipe", "PIPES", "definition"),
|
||||
("fileFormat", "FILE FORMATS", "type"),
|
||||
];
|
||||
|
||||
pub(crate) async fn schema_objects(pool: &SnowflakePool, schema: &str) -> IdeResult<SchemaObjects> {
|
||||
let mut out = SchemaObjects::default();
|
||||
// A role may see a schema's tables and none of these: each list stands alone.
|
||||
for kind in [DbObjectKind::Function, DbObjectKind::Procedure] {
|
||||
out.routines.extend(routines(pool, schema, kind, None).await.unwrap_or_default());
|
||||
}
|
||||
out.routines.sort_by(|a, b| (&a.name, &a.arguments).cmp(&(&b.name, &b.arguments)));
|
||||
if let Ok(shown) = show(pool, &format!("SHOW SEQUENCES IN SCHEMA {}", schema_path(pool, schema))).await {
|
||||
out.sequences = shown
|
||||
.values("name")
|
||||
.flatten()
|
||||
.map(|name| SequenceInfo { name: name.to_owned(), data_type: "NUMBER".into() })
|
||||
.collect();
|
||||
}
|
||||
for (kind, what, detail) in OTHERS {
|
||||
let Ok(shown) = show(pool, &format!("SHOW {what} IN SCHEMA {}", schema_path(pool, schema))).await else {
|
||||
continue;
|
||||
};
|
||||
out.others.extend(shown.records().filter_map(|r| {
|
||||
let detail: String = r.text(detail).split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
Some(SchemaObject {
|
||||
kind: (*kind).to_owned(),
|
||||
name: r.get("name")?.to_owned(),
|
||||
detail: detail.chars().take(120).collect(),
|
||||
})
|
||||
}));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The definitions of a routine's overloads, or of a sequence.
|
||||
pub(crate) async fn object_ddl(
|
||||
pool: &SnowflakePool,
|
||||
schema: &str,
|
||||
kind: DbObjectKind,
|
||||
name: &str,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let path = object_path(pool, schema, name);
|
||||
let mut out = Vec::new();
|
||||
match kind {
|
||||
DbObjectKind::Sequence => out.extend(ddl(pool, "SEQUENCE", path).await?),
|
||||
DbObjectKind::Function | DbObjectKind::Procedure => {
|
||||
let object = if kind == DbObjectKind::Procedure { "PROCEDURE" } else { "FUNCTION" };
|
||||
for routine in routines(pool, schema, kind, Some(name)).await?.into_iter().filter(|r| r.name == name) {
|
||||
// Overloads are told apart by their argument types (names and defaults dropped).
|
||||
let types: Vec<&str> = routine
|
||||
.arguments
|
||||
.split(',')
|
||||
.map(|a| a.trim().trim_start_matches("DEFAULT ").trim())
|
||||
.filter(|a| !a.is_empty())
|
||||
.collect();
|
||||
out.extend(ddl(pool, object, format!("{path}({})", types.join(", "))).await?);
|
||||
}
|
||||
}
|
||||
DbObjectKind::Trigger | DbObjectKind::Package => {
|
||||
return Err(IdeError::invalid("Snowflake has no triggers or packages"));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn ddl(pool: &SnowflakePool, object: &str, target: String) -> IdeResult<Option<String>> {
|
||||
let sql = format!("SELECT GET_DDL('{object}', {})", quote_literal(DRIVER, &target));
|
||||
let rows = pool.metadata().execute(&sql, 1, &CancellationToken::new()).await?.rows;
|
||||
Ok(rows.into_iter().next().and_then(|r| r.into_iter().next().flatten()))
|
||||
}
|
||||
|
||||
/// A column type of `SHOW COLUMNS` (`{"type":"FIXED","precision":38,"scale":0,…}`) the way SQL spells it.
|
||||
fn column_type(json: &str) -> String {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else { return json.to_owned() };
|
||||
let kind = value.get("type").and_then(|t| t.as_str()).unwrap_or_default().to_ascii_uppercase();
|
||||
let number = |name: &str| value.get(name).and_then(|n| n.as_i64());
|
||||
match kind.as_str() {
|
||||
"FIXED" => match (number("precision"), number("scale")) {
|
||||
(Some(p), Some(s)) => format!("NUMBER({p},{s})"),
|
||||
_ => "NUMBER".into(),
|
||||
},
|
||||
"REAL" => "FLOAT".into(),
|
||||
"TEXT" => match number("length") {
|
||||
Some(n) if n > 0 && n < 16_777_216 => format!("VARCHAR({n})"),
|
||||
_ => "VARCHAR".into(),
|
||||
},
|
||||
"TIME" | "TIMESTAMP_NTZ" | "TIMESTAMP_LTZ" | "TIMESTAMP_TZ" => match number("scale") {
|
||||
Some(s) => format!("{kind}({s})"),
|
||||
None => kind,
|
||||
},
|
||||
_ => kind,
|
||||
}
|
||||
}
|
||||
|
||||
/// Above this many tables, a database's columns are too many to read at once.
|
||||
const SMALL_DATABASE: usize = 1_000;
|
||||
|
||||
/// Every table of the target's database for SQL completion, with the columns of all of them
|
||||
/// when the database is small, else of the session's schema only. A table listed without
|
||||
/// columns is one whose columns were not read.
|
||||
pub(crate) async fn completion(pool: &SnowflakePool) -> IdeResult<Vec<CompletionTable>> {
|
||||
let names = schemas(pool, false).await?;
|
||||
let objects = database_objects(pool, pool.database_ident().as_deref(), &names).await?;
|
||||
let mut tables: Vec<CompletionTable> = objects
|
||||
.records()
|
||||
.filter(|r| r.get("schema_name") != Some("INFORMATION_SCHEMA"))
|
||||
.filter_map(|r| {
|
||||
Some(CompletionTable {
|
||||
schema: r.text("schema_name"),
|
||||
name: r.get("name")?.to_owned(),
|
||||
kind: table_kind(r.get("kind").unwrap_or("TABLE")),
|
||||
columns: Vec::new(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
tables.sort_by(|a, b| (&a.schema, &a.name).cmp(&(&b.schema, &b.name)));
|
||||
tables.dedup_by(|a, b| a.schema == b.schema && a.name == b.name);
|
||||
let scope = if tables.len() <= SMALL_DATABASE {
|
||||
Some(in_database(pool))
|
||||
} else {
|
||||
let current = show(pool, "SELECT CURRENT_SCHEMA()").await?;
|
||||
let schema = current.rows.first().and_then(|r| r.first().cloned().flatten()).filter(|s| !s.is_empty());
|
||||
schema.map(|schema| format!(" IN SCHEMA {}", schema_path(pool, &schema)))
|
||||
};
|
||||
let Some(scope) = scope else { return Ok(tables) };
|
||||
// Columns are a help, not a need: without them the tables still complete.
|
||||
let Ok(shown) = show(pool, &format!("SHOW COLUMNS{scope}")).await else { return Ok(tables) };
|
||||
let at: HashMap<(String, String), usize> =
|
||||
tables.iter().enumerate().map(|(i, t)| ((t.schema.clone(), t.name.clone()), i)).collect();
|
||||
for r in shown.records() {
|
||||
let Some(&index) = at.get(&(r.text("schema_name"), r.text("table_name"))) else { continue };
|
||||
tables[index].columns.push(CompletionColumn {
|
||||
name: r.text("column_name"),
|
||||
data_type: column_type(r.get("data_type").unwrap_or_default()),
|
||||
nullable: !r
|
||||
.get("null?")
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("NOT_NULL") || v.eq_ignore_ascii_case("N")),
|
||||
primary_key: false,
|
||||
has_default: r.get("default").is_some_and(|d| !d.is_empty())
|
||||
|| r.get("autoincrement").is_some_and(|a| !a.is_empty()),
|
||||
});
|
||||
}
|
||||
Ok(tables)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signatures_split_into_arguments_and_result() {
|
||||
assert_eq!(signature("ADD_ONE(NUMBER) RETURN NUMBER"), ("NUMBER".into(), Some("NUMBER".into())));
|
||||
assert_eq!(
|
||||
signature("PARSE(VARCHAR, NUMBER(38,0)) RETURN TABLE (A NUMBER)"),
|
||||
("VARCHAR, NUMBER(38,0)".into(), Some("TABLE (A NUMBER)".into()))
|
||||
);
|
||||
assert_eq!(signature("NOW_UTC() RETURN TIMESTAMP_NTZ"), (String::new(), Some("TIMESTAMP_NTZ".into())));
|
||||
assert_eq!(signature(""), (String::new(), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_types_read_like_sql() {
|
||||
assert_eq!(column_type(r#"{"type":"FIXED","precision":38,"scale":0,"nullable":true}"#), "NUMBER(38,0)");
|
||||
assert_eq!(column_type(r#"{"type":"TEXT","length":100,"byteLength":400,"nullable":false}"#), "VARCHAR(100)");
|
||||
assert_eq!(column_type(r#"{"type":"TEXT","length":16777216}"#), "VARCHAR");
|
||||
assert_eq!(column_type(r#"{"type":"TIMESTAMP_NTZ","precision":0,"scale":9}"#), "TIMESTAMP_NTZ(9)");
|
||||
assert_eq!(column_type(r#"{"type":"VARIANT","nullable":true}"#), "VARIANT");
|
||||
assert_eq!(column_type(r#"{"type":"REAL"}"#), "FLOAT");
|
||||
assert_eq!(column_type("not json"), "not json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_kinds_become_tree_kinds() {
|
||||
for kind in ["TABLE", "DYNAMIC TABLE", "EXTERNAL TABLE", "EVENT TABLE", "HYBRID TABLE"] {
|
||||
assert_eq!(table_kind(kind), DbTableKind::Table, "{kind}");
|
||||
}
|
||||
assert_eq!(table_kind("VIEW"), DbTableKind::View);
|
||||
assert_eq!(table_kind("MATERIALIZED_VIEW"), DbTableKind::MaterializedView);
|
||||
assert_eq!(table_kind("MATERIALIZED VIEW"), DbTableKind::MaterializedView);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_keys_group_by_constraint_in_column_order() {
|
||||
let columns = [
|
||||
"pk_schema_name",
|
||||
"pk_table_name",
|
||||
"pk_column_name",
|
||||
"fk_schema_name",
|
||||
"fk_table_name",
|
||||
"fk_column_name",
|
||||
"key_sequence",
|
||||
"fk_name",
|
||||
];
|
||||
let row = |values: [&str; 8]| values.iter().map(|v| Some((*v).to_owned())).collect::<Vec<_>>();
|
||||
let shown = Shown {
|
||||
columns: columns.iter().map(|c| (*c).to_owned()).collect(),
|
||||
rows: vec![
|
||||
row(["PUBLIC", "REGIONS", "CODE", "PUBLIC", "ORDERS", "REGION_CODE", "2", "FK_REGION"]),
|
||||
row(["PUBLIC", "CUSTOMERS", "ID", "PUBLIC", "ORDERS", "CUSTOMER_ID", "1", "FK_CUSTOMER"]),
|
||||
row(["PUBLIC", "REGIONS", "COUNTRY", "PUBLIC", "ORDERS", "COUNTRY", "1", "FK_REGION"]),
|
||||
],
|
||||
};
|
||||
let keys = foreign_keys(&shown);
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert_eq!(
|
||||
(keys[0].ref_table.as_str(), keys[0].columns.as_slice()),
|
||||
("CUSTOMERS", &["CUSTOMER_ID".to_owned()][..])
|
||||
);
|
||||
assert_eq!(keys[1].columns, ["COUNTRY", "REGION_CODE"]);
|
||||
assert_eq!(keys[1].ref_columns, ["COUNTRY", "CODE"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
//! Snowflake over HTTPS: the session protocol of Snowflake's own drivers (login, statements,
|
||||
//! cancellation, token renewal) with results in JSON. One session per pool; statements run
|
||||
//! one at a time on it, so session state (`USE`, `ALTER SESSION`, transactions) holds.
|
||||
|
||||
mod account;
|
||||
mod browser;
|
||||
mod client;
|
||||
mod error;
|
||||
mod keypair;
|
||||
pub(crate) mod meta;
|
||||
mod rows;
|
||||
|
||||
use crate::conn::{QueryResult, Setup};
|
||||
use crate::source::{DataSource, DbSnowflakeAuth};
|
||||
use client::{Api, Envelope, Request};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::RwLock;
|
||||
use rows::RowType;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, LazyLock, Weak};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const APP: &str = "OpenIDEA";
|
||||
const LOGIN: &str = "session/v1/login-request";
|
||||
const QUERY: &str = "queries/v1/query-request";
|
||||
const ABORT: &str = "queries/v1/abort-request";
|
||||
const RENEW: &str = "session/token-request";
|
||||
const SESSION: &str = "session";
|
||||
/// DML statement types (`INSERT`, `UPDATE`, `DELETE`, `MERGE`…): their result is a row of counts.
|
||||
const DML: std::ops::Range<i64> = 0x3000..0x4000;
|
||||
const SELECT: i64 = 0x1000;
|
||||
|
||||
/// A connection target (a database of a data source) on the Snowflake session its data source's
|
||||
/// targets share: one login serves every database, and each statement runs under its target's
|
||||
/// database and schema.
|
||||
#[derive(Clone)]
|
||||
pub struct SnowflakePool {
|
||||
shared: Arc<State>,
|
||||
/// The target's database and the data source's schema (empty: the user's defaults).
|
||||
database: String,
|
||||
schema: String,
|
||||
read_only: Arc<AtomicBool>,
|
||||
closed: CancellationToken,
|
||||
/// Which of the data source's sessions this handle runs on.
|
||||
lane: usize,
|
||||
}
|
||||
|
||||
static CONNECTING: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
/// The key the single sign-on ID token of `source` is cached under (`None`: another method).
|
||||
pub(crate) fn sso_key(source: &DataSource) -> Option<String> {
|
||||
if source.snowflake().auth != DbSnowflakeAuth::Browser {
|
||||
return None;
|
||||
}
|
||||
let endpoint = account::endpoint(source).ok()?;
|
||||
let address = format!(
|
||||
"{}:{}",
|
||||
endpoint.base.host_str().unwrap_or_default(),
|
||||
endpoint.base.port_or_known_default().unwrap_or_default()
|
||||
);
|
||||
Some(format!("{address}|{}", source.user.trim().to_uppercase()))
|
||||
}
|
||||
|
||||
/// The ID token a browser sign-in left, which signs in again without the browser while Snowflake accepts it.
|
||||
pub(crate) fn sso_token(key: &str) -> Option<String> {
|
||||
browser::cached(key)
|
||||
}
|
||||
|
||||
/// Offers a saved ID token for the next sign-in (one already in use is kept).
|
||||
pub(crate) fn sso_seed(key: &str, token: &str) {
|
||||
if browser::cached(key).is_none() {
|
||||
browser::remember(key, token);
|
||||
}
|
||||
}
|
||||
|
||||
/// Live sessions by data source and connection settings, so the targets of one data source (its
|
||||
/// databases, the tree, consoles) opened with the same settings share one.
|
||||
static SESSIONS: LazyLock<parking_lot::Mutex<HashMap<String, Weak<State>>>> =
|
||||
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
|
||||
|
||||
struct State {
|
||||
api: Api,
|
||||
login: Login,
|
||||
/// The consoles' session and the tree's ([`CONSOLE`], [`METADATA`]): a long statement in a
|
||||
/// console does not hold up the tree. The second one logs in when it is first used.
|
||||
lanes: [Lane; 2],
|
||||
/// Open targets on this session; the last one to close ends it.
|
||||
users: AtomicUsize,
|
||||
key: String,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
|
||||
/// One Snowflake session.
|
||||
#[derive(Default)]
|
||||
struct Lane {
|
||||
tokens: RwLock<Option<Tokens>>,
|
||||
/// Statements share the session, so they take turns.
|
||||
turn: Mutex<()>,
|
||||
sequence: AtomicU64,
|
||||
/// The `USE` statement the session is known to be under (`None`: a statement may have
|
||||
/// moved it; empty: as the login left it, without a database or schema).
|
||||
context: parking_lot::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
/// What [`SnowflakePool::statement`] does when the session is gone and it signs in again.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Lost<'a> {
|
||||
/// Runs the statement again on the new session, after this `USE` when there is one.
|
||||
Enter(Option<&'a str>),
|
||||
/// Fails: the statement was part of a transaction that ended with the session.
|
||||
Fail,
|
||||
}
|
||||
|
||||
const CONSOLE: usize = 0;
|
||||
const METADATA: usize = 1;
|
||||
|
||||
struct Login {
|
||||
account: String,
|
||||
user: String,
|
||||
credentials: Credentials,
|
||||
database: String,
|
||||
schema: String,
|
||||
warehouse: String,
|
||||
role: String,
|
||||
parameters: Vec<(String, String)>,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
enum Credentials {
|
||||
KeyPair { file: String, passphrase: Option<String> },
|
||||
Password(String),
|
||||
Token(String),
|
||||
OAuth(String),
|
||||
Browser,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Tokens {
|
||||
session: String,
|
||||
master: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct ResultData {
|
||||
rowtype: Vec<RowType>,
|
||||
rowset: Vec<Vec<Value>>,
|
||||
rowset_base64: Option<String>,
|
||||
chunks: Vec<Chunk>,
|
||||
chunk_headers: BTreeMap<String, String>,
|
||||
qrmk: Option<String>,
|
||||
query_result_format: Option<String>,
|
||||
statement_type_id: Option<i64>,
|
||||
get_result_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct Chunk {
|
||||
url: String,
|
||||
row_count: usize,
|
||||
}
|
||||
|
||||
fn uuid() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
fn closed() -> IdeError {
|
||||
IdeError::new(ErrorKind::Io, "Snowflake connection is closed")
|
||||
}
|
||||
|
||||
/// A session parameter as JSON: Snowflake types booleans and numbers.
|
||||
fn parameter(value: &str) -> Value {
|
||||
if value.eq_ignore_ascii_case("true") {
|
||||
Value::Bool(true)
|
||||
} else if value.eq_ignore_ascii_case("false") {
|
||||
Value::Bool(false)
|
||||
} else if let Ok(n) = value.parse::<i64>() {
|
||||
json!(n)
|
||||
} else {
|
||||
Value::String(value.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings of the Advanced tab the driver sets itself.
|
||||
fn reserved(name: &str) -> bool {
|
||||
matches!(
|
||||
name.to_ascii_uppercase().as_str(),
|
||||
"ROWS_PER_RESULTSET" | "MULTI_STATEMENT_COUNT" | "CLIENT_STORE_TEMPORARY_CREDENTIAL"
|
||||
)
|
||||
}
|
||||
|
||||
/// A name typed in the dialog, as SQL: typed in one case it is an unquoted name, which folds
|
||||
/// to upper case; anything else is taken as the exact name.
|
||||
fn typed(name: &str) -> String {
|
||||
let plain = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
|
||||
&& !name.starts_with(|c: char| c.is_ascii_digit());
|
||||
let one_case = !name.chars().any(|c| c.is_ascii_lowercase()) || !name.chars().any(|c| c.is_ascii_uppercase());
|
||||
let name = if plain && one_case { name.to_ascii_uppercase() } else { name.to_owned() };
|
||||
crate::sql::quote_ident(crate::DbDriver::Snowflake, &name)
|
||||
}
|
||||
|
||||
fn first_word(sql: &str) -> String {
|
||||
let mut sql = sql.trim_start();
|
||||
loop {
|
||||
if let Some(rest) = sql.strip_prefix("--").or_else(|| sql.strip_prefix("//")) {
|
||||
sql = rest.split_once('\n').map_or("", |(_, rest)| rest).trim_start();
|
||||
} else if let Some(rest) = sql.strip_prefix("/*") {
|
||||
sql = rest.split_once("*/").map_or("", |(_, rest)| rest).trim_start();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sql.split(|c: char| !c.is_ascii_alphanumeric() && c != '_').next().unwrap_or_default().to_ascii_uppercase()
|
||||
}
|
||||
|
||||
/// Whether `sql` may change data or objects (a read-only data source refuses it).
|
||||
/// Snowflake answers a `USE` it will not perform with one message for every cause; this adds
|
||||
/// the causes.
|
||||
fn explain_use(sql: &str, mut error: IdeError) -> IdeError {
|
||||
if first_word(sql) == "USE" && error.message.contains("002043") {
|
||||
error.message.push_str(
|
||||
"\nThe role, warehouse, database or schema does not exist, or is not granted: a role must be \
|
||||
granted to your user (SHOW GRANTS TO USER <name>), the others to the current role.",
|
||||
);
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
/// Whether `sql` may change the account. Statements that read, and those that change only the
|
||||
/// session (`USE`, `SET`, `UNSET`), run on a read-only data source.
|
||||
fn is_write(sql: &str) -> bool {
|
||||
match first_word(sql).as_str() {
|
||||
"SELECT" | "SHOW" | "DESCRIBE" | "DESC" | "EXPLAIN" | "LIST" | "LS" | "USE" | "SET" | "UNSET" | "VALUES"
|
||||
| "TABLE" | "" => false,
|
||||
// `WITH name AS PROCEDURE (…) … CALL name(…)` runs a procedure; other `WITH`s select.
|
||||
"WITH" => {
|
||||
sql.to_ascii_uppercase().split_whitespace().collect::<Vec<_>>().windows(2).any(|w| w == ["AS", "PROCEDURE"])
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn gunzip(bytes: Vec<u8>) -> IdeResult<Vec<u8>> {
|
||||
if !bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
std::io::Read::read_to_end(&mut flate2::read::GzDecoder::new(bytes.as_slice()), &mut out)
|
||||
.map_err(|e| IdeError::new(ErrorKind::Protocol, format!("Snowflake: cannot read a result chunk: {e}")))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl SnowflakePool {
|
||||
pub(crate) async fn connect(
|
||||
source: &DataSource,
|
||||
secret: Option<&str>,
|
||||
setup: Setup,
|
||||
root: Option<&std::path::Path>,
|
||||
) -> IdeResult<Self> {
|
||||
let settings = source.snowflake();
|
||||
let endpoint = account::endpoint(source)?;
|
||||
let user = source.user.trim();
|
||||
if user.is_empty() {
|
||||
return Err(IdeError::invalid("enter the Snowflake user name"));
|
||||
}
|
||||
for (name, _) in &setup.params {
|
||||
if reserved(name) {
|
||||
return Err(IdeError::invalid(format!("the Snowflake driver manages the {name} parameter")));
|
||||
}
|
||||
}
|
||||
let secret = secret.filter(|s| !s.is_empty());
|
||||
let ask = |what: &str| IdeError::new(ErrorKind::PermissionDenied, format!("enter the {what}"));
|
||||
let credentials = match settings.auth {
|
||||
DbSnowflakeAuth::KeyPair => {
|
||||
Credentials::KeyPair { file: settings.private_key_file.clone(), passphrase: secret.map(str::to_owned) }
|
||||
}
|
||||
DbSnowflakeAuth::Password => Credentials::Password(secret.ok_or_else(|| ask("password"))?.to_owned()),
|
||||
DbSnowflakeAuth::Token => {
|
||||
Credentials::Token(secret.ok_or_else(|| ask("programmatic access token"))?.to_owned())
|
||||
}
|
||||
DbSnowflakeAuth::OAuth => Credentials::OAuth(secret.ok_or_else(|| ask("OAuth access token"))?.to_owned()),
|
||||
DbSnowflakeAuth::Browser => Credentials::Browser,
|
||||
};
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let builder = reqwest::Client::builder()
|
||||
.connect_timeout(timeout)
|
||||
.pool_max_idle_per_host(4)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(concat!("OpenIDEA/", env!("CARGO_PKG_VERSION")));
|
||||
let proxy = ide_core::proxy_settings::proxy();
|
||||
let http = ide_http::client::with_proxy(builder, &proxy)
|
||||
.build()
|
||||
.map_err(|e| IdeError::new(ErrorKind::Io, format!("Snowflake: cannot start the HTTP client: {e}")))?;
|
||||
let api = Api::new(http, endpoint.base);
|
||||
if let Some(secret) = secret {
|
||||
api.scrub.add(secret);
|
||||
}
|
||||
let database = source.database.trim().to_owned();
|
||||
let schema = source.options.default_schema.trim().to_owned();
|
||||
let target = |shared: Arc<State>| Self {
|
||||
shared,
|
||||
database: database.clone(),
|
||||
schema: schema.clone(),
|
||||
read_only: Arc::new(AtomicBool::new(source.read_only)),
|
||||
closed: CancellationToken::new(),
|
||||
lane: CONSOLE,
|
||||
};
|
||||
// Everything that makes a session what it is, the secret included (hashed: the key is only compared).
|
||||
// The data source and its project too: its databases share a session, but another data source
|
||||
// with the same settings, or the same one in another project (a plugin's source has the same id
|
||||
// in every project), gets its own: its statements and transactions are not this one's. A browser
|
||||
// sign-in is still shared through the cached ID token.
|
||||
let key = blake3::hash(
|
||||
format!(
|
||||
"{:?}\0{}\0{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
|
||||
root,
|
||||
source.id,
|
||||
api.address(),
|
||||
settings.auth,
|
||||
secret.unwrap_or_default(),
|
||||
settings.private_key_file,
|
||||
settings.warehouse.trim(),
|
||||
settings.role.trim(),
|
||||
setup.params,
|
||||
setup.statements
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.to_hex()
|
||||
.to_string();
|
||||
// One connect at a time: targets opened together (the tree, consoles, completion) find the
|
||||
// session the first of them signs in to, instead of each signing in (and each opening the
|
||||
// browser with single sign-on).
|
||||
let _connecting = CONNECTING.lock().await;
|
||||
let live = SESSIONS.lock().get(&key).and_then(Weak::upgrade).filter(|s| !s.closed.is_cancelled());
|
||||
if let Some(shared) = live {
|
||||
shared.users.fetch_add(1, Ordering::SeqCst);
|
||||
return Ok(target(shared));
|
||||
}
|
||||
let pool = target(Arc::new(State {
|
||||
api,
|
||||
login: Login {
|
||||
account: endpoint.account,
|
||||
user: user.to_owned(),
|
||||
credentials,
|
||||
database: database.clone(),
|
||||
schema: schema.clone(),
|
||||
warehouse: settings.warehouse.trim().to_owned(),
|
||||
role: settings.role.trim().to_owned(),
|
||||
parameters: setup.params,
|
||||
timeout,
|
||||
},
|
||||
lanes: Default::default(),
|
||||
users: AtomicUsize::new(1),
|
||||
key: key.clone(),
|
||||
closed: CancellationToken::new(),
|
||||
}));
|
||||
pool.login().await?;
|
||||
// The login asked for this target's database and schema.
|
||||
*pool.lane().context.lock() = Some(pool.context(None).unwrap_or_default());
|
||||
let cancel = CancellationToken::new();
|
||||
// The startup script may write whatever the read-only setting says.
|
||||
pool.read_only.store(false, Ordering::Relaxed);
|
||||
for sql in setup.statements {
|
||||
if let Err(error) = pool.execute(&sql, 0, &cancel).await {
|
||||
pool.close().await;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
pool.read_only.store(source.read_only, Ordering::Relaxed);
|
||||
SESSIONS.lock().insert(key, Arc::downgrade(&pool.shared));
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// The target's database as SQL (`None`: the session's current database).
|
||||
pub(crate) fn database_ident(&self) -> Option<String> {
|
||||
(!self.database.is_empty()).then(|| typed(&self.database))
|
||||
}
|
||||
|
||||
/// The `USE` statement for this target with `schema` active (`None`: the data source's schema).
|
||||
fn context(&self, schema: Option<&str>) -> Option<String> {
|
||||
let quote = |name: &str| crate::sql::quote_ident(crate::DbDriver::Snowflake, name);
|
||||
let schema = match schema.filter(|s| !s.is_empty()) {
|
||||
// Picked from the list of schemas: the exact name.
|
||||
Some(picked) => Some(quote(picked)),
|
||||
None if self.schema.is_empty() => None,
|
||||
None => Some(typed(&self.schema)),
|
||||
};
|
||||
match (self.database.is_empty(), schema) {
|
||||
(false, Some(schema)) => Some(format!("USE SCHEMA {}.{schema}", typed(&self.database))),
|
||||
(false, None) => Some(format!("USE DATABASE {}", typed(&self.database))),
|
||||
(true, Some(schema)) => Some(format!("USE SCHEMA {schema}")),
|
||||
(true, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the session on the server (best effort) and refuses further statements.
|
||||
pub async fn close(&self) {
|
||||
if self.closed.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
self.closed.cancel();
|
||||
if self.shared.users.fetch_sub(1, Ordering::SeqCst) != 1 {
|
||||
return;
|
||||
}
|
||||
self.shared.closed.cancel();
|
||||
{
|
||||
let mut sessions = SESSIONS.lock();
|
||||
if sessions.get(&self.shared.key).is_some_and(|s| std::ptr::eq(s.as_ptr(), Arc::as_ptr(&self.shared))) {
|
||||
sessions.remove(&self.shared.key);
|
||||
}
|
||||
}
|
||||
for lane in &self.shared.lanes {
|
||||
let tokens = lane.tokens.write().take();
|
||||
if let Some(tokens) = tokens {
|
||||
let request = Request::post(SESSION, json!({}))
|
||||
.query("delete", "true")
|
||||
.query("requestId", uuid())
|
||||
.token(&tokens.session)
|
||||
.timeout(Duration::from_secs(5));
|
||||
let _ = self.shared.api.send(request).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lane(&self) -> &Lane {
|
||||
&self.shared.lanes[self.lane]
|
||||
}
|
||||
|
||||
/// This target on the session the tree reads metadata with.
|
||||
pub(crate) fn metadata(&self) -> Self {
|
||||
// A second browser sign-in would interrupt the user: without an ID token to sign in
|
||||
// with, metadata takes turns with the consoles.
|
||||
let second = !matches!(self.shared.login.credentials, Credentials::Browser)
|
||||
|| browser::cached(&self.browser_key()).is_some();
|
||||
Self { lane: if second { METADATA } else { CONSOLE }, ..self.clone() }
|
||||
}
|
||||
|
||||
fn browser_key(&self) -> String {
|
||||
format!("{}|{}", self.shared.api.address(), self.shared.login.user.to_uppercase())
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.closed.is_cancelled() || self.shared.closed.is_cancelled()
|
||||
}
|
||||
|
||||
/// The fields of the login request that prove who the user is.
|
||||
async fn proof(&self) -> IdeResult<(Vec<(&'static str, Value)>, Option<String>)> {
|
||||
let login = &self.shared.login;
|
||||
Ok(match &login.credentials {
|
||||
Credentials::KeyPair { file, passphrase } => {
|
||||
let (file, passphrase) = (file.clone(), passphrase.clone());
|
||||
let (account, user) = (login.account.clone(), login.user.clone());
|
||||
// Decrypting the key derives its encryption key, which takes a while by design.
|
||||
let (token, fingerprint) = tokio::task::spawn_blocking(move || {
|
||||
let pair = keypair::load(&file, passphrase.as_deref())?;
|
||||
Ok::<_, IdeError>((pair.jwt(&account, &user, now_secs())?, pair.fingerprint()?))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
self.shared.api.scrub.add(&token);
|
||||
(vec![("AUTHENTICATOR", json!("SNOWFLAKE_JWT")), ("TOKEN", json!(token))], Some(fingerprint))
|
||||
}
|
||||
Credentials::Password(password) => (vec![("PASSWORD", json!(password))], None),
|
||||
Credentials::Token(token) => {
|
||||
(vec![("AUTHENTICATOR", json!("PROGRAMMATIC_ACCESS_TOKEN")), ("TOKEN", json!(token))], None)
|
||||
}
|
||||
Credentials::OAuth(token) => (vec![("AUTHENTICATOR", json!("OAUTH")), ("TOKEN", json!(token))], None),
|
||||
Credentials::Browser => unreachable!("browser sign-in has its own login"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn login(&self) -> IdeResult<Tokens> {
|
||||
if matches!(self.shared.login.credentials, Credentials::Browser) {
|
||||
return self.browser_login().await;
|
||||
}
|
||||
let (proof, fingerprint) = self.proof().await?;
|
||||
self.sign_in(proof, fingerprint, None).await
|
||||
}
|
||||
|
||||
/// Signs in through the web browser, or without it while an ID token of an earlier
|
||||
/// sign-in of this run is still accepted.
|
||||
async fn browser_login(&self) -> IdeResult<Tokens> {
|
||||
let login = &self.shared.login;
|
||||
let key = self.browser_key();
|
||||
let _flow = browser::FLOW.lock().await;
|
||||
if let Some(id_token) = browser::cached(&key) {
|
||||
self.shared.api.scrub.add(&id_token);
|
||||
let proof = vec![("AUTHENTICATOR", json!("ID_TOKEN")), ("TOKEN", json!(id_token))];
|
||||
match self.sign_in(proof, None, Some(&key)).await {
|
||||
Ok(tokens) => return Ok(tokens),
|
||||
// The network is down: the browser would not get further either.
|
||||
Err(error) if matches!(error.kind, ErrorKind::Io | ErrorKind::TimedOut) => return Err(error),
|
||||
Err(_) => browser::forget(&key),
|
||||
}
|
||||
}
|
||||
let (token, proof_key) =
|
||||
browser::authenticate(&self.shared.api, &login.account, &login.user, login.timeout).await?;
|
||||
let proof =
|
||||
vec![("AUTHENTICATOR", json!("EXTERNALBROWSER")), ("TOKEN", json!(token)), ("PROOF_KEY", json!(proof_key))];
|
||||
self.sign_in(proof, None, Some(&key)).await
|
||||
}
|
||||
|
||||
/// The login request. `id_token_key`: ask for an ID token and keep it under this key.
|
||||
async fn sign_in(
|
||||
&self,
|
||||
proof: Vec<(&'static str, Value)>,
|
||||
fingerprint: Option<String>,
|
||||
id_token_key: Option<&str>,
|
||||
) -> IdeResult<Tokens> {
|
||||
let login = &self.shared.login;
|
||||
let mut parameters: serde_json::Map<String, Value> =
|
||||
login.parameters.iter().map(|(name, value)| (name.to_ascii_uppercase(), parameter(value))).collect();
|
||||
if id_token_key.is_some() {
|
||||
parameters.insert("CLIENT_STORE_TEMPORARY_CREDENTIAL".into(), Value::Bool(true));
|
||||
}
|
||||
let mut data = json!({
|
||||
"CLIENT_APP_ID": APP,
|
||||
"CLIENT_APP_VERSION": env!("CARGO_PKG_VERSION"),
|
||||
"ACCOUNT_NAME": login.account,
|
||||
"LOGIN_NAME": login.user,
|
||||
"CLIENT_ENVIRONMENT": { "APPLICATION": APP, "OS": std::env::consts::OS },
|
||||
"SESSION_PARAMETERS": parameters,
|
||||
});
|
||||
for (name, value) in proof {
|
||||
data[name] = value;
|
||||
}
|
||||
let mut request = Request::post(LOGIN, json!({ "data": data }))
|
||||
.query("requestId", uuid())
|
||||
.query("request_guid", uuid())
|
||||
.timeout(login.timeout);
|
||||
for (name, value) in [
|
||||
("databaseName", &login.database),
|
||||
("schemaName", &login.schema),
|
||||
("warehouse", &login.warehouse),
|
||||
("roleName", &login.role),
|
||||
] {
|
||||
if !value.is_empty() {
|
||||
request = request.query(name, value.as_str());
|
||||
}
|
||||
}
|
||||
let envelope = self.shared.api.send(request).await?;
|
||||
if !envelope.success {
|
||||
let code = envelope.code.as_deref().unwrap_or_default();
|
||||
let mut failure = error::server(envelope.code.as_deref(), envelope.message.as_deref());
|
||||
if let Some(fingerprint) = fingerprint.filter(|_| error::key_rejected(code)) {
|
||||
failure.message.push_str(&format!("\nFingerprint of this private key: {fingerprint}"));
|
||||
}
|
||||
return Err(self.shared.api.scrub.clean(failure));
|
||||
}
|
||||
let text = |name: &str| envelope.data.get(name).and_then(Value::as_str).map(str::to_owned);
|
||||
let (Some(session), Some(master)) = (text("token"), text("masterToken")) else {
|
||||
return Err(IdeError::new(ErrorKind::Protocol, "Snowflake: the login response has no session token"));
|
||||
};
|
||||
self.shared.api.scrub.add(&session);
|
||||
self.shared.api.scrub.add(&master);
|
||||
if let (Some(key), Some(id_token)) = (id_token_key, text("idToken")) {
|
||||
self.shared.api.scrub.add(&id_token);
|
||||
browser::remember(key, &id_token);
|
||||
}
|
||||
let tokens = Tokens { session, master };
|
||||
if self.is_closed() {
|
||||
return Err(closed());
|
||||
}
|
||||
// A new session: nothing is known about where it is until a statement says.
|
||||
*self.lane().context.lock() = None;
|
||||
*self.lane().tokens.write() = Some(tokens.clone());
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
/// A fresh session token from the master token; a new login when that is refused.
|
||||
async fn renew(&self, stale: &Tokens) -> IdeResult<Tokens> {
|
||||
let request = Request::post(RENEW, json!({ "oldSessionToken": stale.session, "requestType": "RENEW" }))
|
||||
.query("requestId", uuid())
|
||||
.token(&stale.master)
|
||||
.timeout(self.shared.login.timeout);
|
||||
if let Ok(envelope) = self.shared.api.send(request).await
|
||||
&& envelope.success
|
||||
{
|
||||
let text = |name: &str| envelope.data.get(name).and_then(Value::as_str).map(str::to_owned);
|
||||
if let Some(session) = text("sessionToken") {
|
||||
let master = text("masterToken").unwrap_or_else(|| stale.master.clone());
|
||||
self.shared.api.scrub.add(&session);
|
||||
self.shared.api.scrub.add(&master);
|
||||
let tokens = Tokens { session, master };
|
||||
*self.lane().tokens.write() = Some(tokens.clone());
|
||||
return Ok(tokens);
|
||||
}
|
||||
}
|
||||
self.login().await
|
||||
}
|
||||
|
||||
async fn tokens(&self) -> IdeResult<Tokens> {
|
||||
let current = self.lane().tokens.read().clone();
|
||||
match current {
|
||||
Some(tokens) => Ok(tokens),
|
||||
None if self.is_closed() => Err(closed()),
|
||||
None => self.login().await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
|
||||
self.execute_from(sql, 0, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute`](Self::execute), leaving out the first `skip` rows (a later page): whole
|
||||
/// result chunks before the page are not downloaded.
|
||||
pub async fn execute_from(
|
||||
&self,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
self.execute_in(None, sql, skip, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute_from`](Self::execute_from) with unqualified names resolving in `schema`
|
||||
/// (a console's active schema; `None`: the data source's). The switch and the statement
|
||||
/// hold the session together, so consoles with different schemas do not cross.
|
||||
pub async fn execute_in(
|
||||
&self,
|
||||
schema: Option<&str>,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
if self.read_only.load(Ordering::Relaxed) && is_write(sql) {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"read-only data source: {} statements are not allowed",
|
||||
first_word(sql)
|
||||
)));
|
||||
}
|
||||
if matches!(first_word(sql).as_str(), "PUT" | "GET") {
|
||||
// The server only plans these; the client moves the files, which this driver does not do.
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} copies files between this computer and a stage, which OpenIDEA does not do yet: \
|
||||
run it with the Snowflake CLI (snow sql) or SnowSQL",
|
||||
first_word(sql)
|
||||
)));
|
||||
}
|
||||
let _turn = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
() = self.closed.cancelled() => return Err(closed()),
|
||||
turn = self.lane().turn.lock() => turn,
|
||||
};
|
||||
// A `USE` needs no schema to run in, and may be what makes the schema reachable. Ending a
|
||||
// transaction needs none either, and must not fail because the schema is gone meanwhile.
|
||||
let context = match first_word(sql).as_str() {
|
||||
"USE" | "COMMIT" | "ROLLBACK" => None,
|
||||
_ => {
|
||||
self.enter(schema, cancel).await?;
|
||||
self.context(schema)
|
||||
}
|
||||
};
|
||||
// These can move the session to another database or schema.
|
||||
let moves = matches!(first_word(sql).as_str(), "USE" | "CALL" | "EXECUTE");
|
||||
let result = self.statement(sql, skip, max_rows, Lost::Enter(context.as_deref()), cancel).await;
|
||||
if moves {
|
||||
*self.lane().context.lock() = None;
|
||||
}
|
||||
result.map_err(|error| explain_use(sql, error))
|
||||
}
|
||||
|
||||
/// Puts the session under this target's database and `schema` when it is not there already.
|
||||
async fn enter(&self, schema: Option<&str>, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
if let Some(wanted) = self.context(schema)
|
||||
&& self.lane().context.lock().as_deref() != Some(wanted.as_str())
|
||||
{
|
||||
*self.lane().context.lock() = None;
|
||||
// Refused (the session's role may not use this database or schema): the statement
|
||||
// does not run, since the session is still wherever it was, which may be another
|
||||
// target's database. Tried again with the next statement.
|
||||
if let Err(error) = self.statement(&wanted, 0, 0, Lost::Enter(None), cancel).await {
|
||||
return Err(IdeError {
|
||||
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
|
||||
..error
|
||||
});
|
||||
}
|
||||
*self.lane().context.lock() = Some(wanted);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs `statements` in one transaction; each must change one row when flagged
|
||||
/// (table editor submits). Nothing else runs on the session meanwhile.
|
||||
pub(crate) async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
|
||||
if self.read_only.load(Ordering::Relaxed) {
|
||||
return Err(IdeError::invalid("read-only data source: changes are not allowed"));
|
||||
}
|
||||
let _turn = self.lane().turn.lock().await;
|
||||
let cancel = CancellationToken::new();
|
||||
self.enter(None, &cancel).await?;
|
||||
self.statement("BEGIN", 0, 0, Lost::Enter(self.context(None).as_deref()), &cancel).await?;
|
||||
let changes = async {
|
||||
let mut total = 0;
|
||||
for (sql, expect_one) in statements {
|
||||
let changed = self.statement(sql, 0, 0, Lost::Fail, &cancel).await?.affected.unwrap_or(0.0) as u64;
|
||||
if *expect_one && changed != 1 {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{sql}: expected to change 1 row, changed {changed}; nothing was saved"
|
||||
)));
|
||||
}
|
||||
total += changed;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
.await;
|
||||
match changes {
|
||||
Ok(total) => {
|
||||
self.statement("COMMIT", 0, 0, Lost::Fail, &cancel).await?;
|
||||
Ok(total)
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = self.statement("ROLLBACK", 0, 0, Lost::Fail, &cancel).await;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One statement on the session; the caller holds the session's turn. `lost`: what to do when
|
||||
/// the session is gone and a new one is signed in to.
|
||||
async fn statement(
|
||||
&self,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
lost: Lost<'_>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let started = Instant::now();
|
||||
let mut tokens = self.tokens().await?;
|
||||
let mut renewed = false;
|
||||
loop {
|
||||
let request_id = uuid();
|
||||
let run = self.run(sql, skip, max_rows, &tokens, &request_id, started);
|
||||
let outcome = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => None,
|
||||
() = self.closed.cancelled() => None,
|
||||
outcome = run => Some(outcome),
|
||||
};
|
||||
let Some(outcome) = outcome else {
|
||||
if self.is_closed() {
|
||||
return Err(closed());
|
||||
}
|
||||
return Err(match self.abort(sql, &request_id, &tokens).await {
|
||||
Ok(()) => IdeError::cancelled(),
|
||||
Err(error) => IdeError::new(
|
||||
ErrorKind::Cancelled,
|
||||
format!("cancelled locally; Snowflake did not confirm the cancellation: {}", error.message),
|
||||
),
|
||||
});
|
||||
};
|
||||
match outcome {
|
||||
Ok(Outcome::Done(result)) => return Ok(*result),
|
||||
Ok(Outcome::Expired { gone }) if !renewed => {
|
||||
renewed = true;
|
||||
if !gone {
|
||||
tokens = self.renew(&tokens).await?;
|
||||
continue;
|
||||
}
|
||||
if let Lost::Fail = lost {
|
||||
// The transaction ended with the session: nothing of it was saved.
|
||||
let _ = self.login().await;
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
"Snowflake: the session was lost during the transaction; nothing was saved",
|
||||
));
|
||||
}
|
||||
// The new session is under the login's database and schema, which may be another
|
||||
// target's: back to this one's before the statement runs again.
|
||||
tokens = self.login().await?;
|
||||
if let Lost::Enter(Some(wanted)) = lost {
|
||||
self.reenter(wanted, &tokens, cancel).await?;
|
||||
}
|
||||
}
|
||||
Ok(Outcome::Expired { .. }) => {
|
||||
return Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"));
|
||||
}
|
||||
Err(error) => return Err(self.shared.api.scrub.clean(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`enter`](Self::enter) on a session just signed in to (its `tokens`), within [`statement`](Self::statement).
|
||||
async fn reenter(&self, wanted: &str, tokens: &Tokens, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
let request_id = uuid();
|
||||
let outcome = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
() = self.closed.cancelled() => return Err(closed()),
|
||||
outcome = self.run(wanted, 0, 0, tokens, &request_id, Instant::now()) => outcome,
|
||||
};
|
||||
match outcome {
|
||||
Ok(Outcome::Done(_)) => {
|
||||
*self.lane().context.lock() = Some(wanted.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
Ok(Outcome::Expired { .. }) => {
|
||||
Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"))
|
||||
}
|
||||
Err(error) => {
|
||||
let error = self.shared.api.scrub.clean(error);
|
||||
Err(IdeError {
|
||||
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
|
||||
..error
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
&self,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
tokens: &Tokens,
|
||||
request_id: &str,
|
||||
started: Instant,
|
||||
) -> IdeResult<Outcome> {
|
||||
let wanted = skip.saturating_add(max_rows).saturating_add(1);
|
||||
let body = json!({
|
||||
"sqlText": sql,
|
||||
"asyncExec": false,
|
||||
"sequenceId": self.lane().sequence.fetch_add(1, Ordering::Relaxed) + 1,
|
||||
"querySubmissionTime": SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64),
|
||||
// The grid's page, plus one row that tells whether more exist.
|
||||
"parameters": { "ROWS_PER_RESULTSET": wanted },
|
||||
});
|
||||
let request = Request::post(QUERY, body)
|
||||
.query("requestId", request_id)
|
||||
.query("request_guid", uuid())
|
||||
.token(&tokens.session);
|
||||
let mut envelope = self.shared.api.send(request).await?;
|
||||
while envelope.has_code(error::IN_PROGRESS) {
|
||||
let Some(url) = envelope.data.get("getResultUrl").and_then(Value::as_str).map(str::to_owned) else {
|
||||
return Err(IdeError::new(ErrorKind::Protocol, "Snowflake: a running statement has no result address"));
|
||||
};
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
envelope = self.shared.api.send(Request::get(url.trim_start_matches('/')).token(&tokens.session)).await?;
|
||||
}
|
||||
if let Some(expired) = expiry(&envelope) {
|
||||
return Ok(expired);
|
||||
}
|
||||
if !envelope.success {
|
||||
return Err(error::server(envelope.code.as_deref(), envelope.message.as_deref()));
|
||||
}
|
||||
let execution_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let data: ResultData = serde_json::from_value(envelope.data)
|
||||
.map_err(|e| IdeError::new(ErrorKind::Protocol, format!("Snowflake: unreadable result: {e}")))?;
|
||||
let arrow = data.query_result_format.as_deref().is_some_and(|f| !f.eq_ignore_ascii_case("json"));
|
||||
if arrow || (data.rowset_base64.as_deref().is_some_and(|r| !r.is_empty()) && data.rowset.is_empty()) {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Protocol,
|
||||
"Snowflake sent the result in a format other than JSON, which this driver does not read",
|
||||
));
|
||||
}
|
||||
let (rows, has_more) = self.rows(&data, skip, max_rows).await?;
|
||||
let kind = data.statement_type_id.unwrap_or(SELECT);
|
||||
let status_only = kind != SELECT && data.rowtype.len() == 1 && data.rowtype[0].name == "status";
|
||||
let (columns, rows, affected, has_more) = if DML.contains(&kind) {
|
||||
let changed: f64 = data
|
||||
.rowset
|
||||
.first()
|
||||
.map(|row| row.iter().filter_map(|v| v.as_str().and_then(|s| s.parse::<f64>().ok())).sum())
|
||||
.unwrap_or(0.0);
|
||||
(Vec::new(), Vec::new(), Some(changed), false)
|
||||
} else if status_only {
|
||||
(Vec::new(), Vec::new(), Some(0.0), false)
|
||||
} else {
|
||||
(data.rowtype.iter().map(RowType::column).collect(), rows, None, has_more)
|
||||
};
|
||||
Ok(Outcome::Done(Box::new(QueryResult {
|
||||
statement: sql.to_owned(),
|
||||
columns,
|
||||
rows,
|
||||
affected,
|
||||
has_more,
|
||||
elapsed_ms: started.elapsed().as_secs_f64() * 1000.0,
|
||||
execution_ms,
|
||||
})))
|
||||
}
|
||||
|
||||
/// The page of rows `skip..skip + max_rows`, and whether rows follow it.
|
||||
async fn rows(
|
||||
&self,
|
||||
data: &ResultData,
|
||||
mut skip: usize,
|
||||
max_rows: usize,
|
||||
) -> IdeResult<(Vec<Vec<Option<String>>>, bool)> {
|
||||
let mut rows = Vec::new();
|
||||
let mut more = false;
|
||||
let mut take = |batch: Vec<Vec<Value>>, skip: &mut usize| -> IdeResult<bool> {
|
||||
for row in batch {
|
||||
if *skip > 0 {
|
||||
*skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if rows.len() >= max_rows {
|
||||
return Ok(true);
|
||||
}
|
||||
if row.len() != data.rowtype.len() {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Protocol,
|
||||
"Snowflake: a result row does not match the result's columns",
|
||||
));
|
||||
}
|
||||
rows.push(
|
||||
row.into_iter().zip(&data.rowtype).map(|(value, column)| rows::cell(column, value)).collect(),
|
||||
);
|
||||
}
|
||||
Ok(false)
|
||||
};
|
||||
if take(data.rowset.clone(), &mut skip)? {
|
||||
more = true;
|
||||
}
|
||||
let headers: Vec<(String, String)> = if !data.chunk_headers.is_empty() {
|
||||
data.chunk_headers.iter().map(|(name, value)| (name.clone(), value.clone())).collect()
|
||||
} else if let Some(key) = &data.qrmk {
|
||||
vec![
|
||||
("x-amz-server-side-encryption-customer-algorithm".into(), "AES256".into()),
|
||||
("x-amz-server-side-encryption-customer-key".into(), key.clone()),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for chunk in &data.chunks {
|
||||
if more {
|
||||
break;
|
||||
}
|
||||
if chunk.row_count > 0 && skip >= chunk.row_count {
|
||||
skip -= chunk.row_count;
|
||||
continue;
|
||||
}
|
||||
let body = gunzip(self.shared.api.chunk(&chunk.url, &headers).await?)?;
|
||||
// A chunk is its rows separated by commas, without the enclosing brackets.
|
||||
let mut text = Vec::with_capacity(body.len() + 2);
|
||||
text.push(b'[');
|
||||
text.extend_from_slice(&body);
|
||||
text.push(b']');
|
||||
let batch: Vec<Vec<Value>> = serde_json::from_slice(&text)
|
||||
.map_err(|e| IdeError::new(ErrorKind::Protocol, format!("Snowflake: unreadable result chunk: {e}")))?;
|
||||
if take(batch, &mut skip)? {
|
||||
more = true;
|
||||
}
|
||||
}
|
||||
Ok((rows, more))
|
||||
}
|
||||
|
||||
async fn abort(&self, sql: &str, request_id: &str, tokens: &Tokens) -> IdeResult<()> {
|
||||
let request = Request::post(ABORT, json!({ "sqlText": sql, "requestId": request_id }))
|
||||
.query("requestId", uuid())
|
||||
.token(&tokens.session)
|
||||
.timeout(Duration::from_secs(5));
|
||||
let envelope = self.shared.api.send(request).await?;
|
||||
if envelope.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(self.shared.api.scrub.clean(error::server(envelope.code.as_deref(), envelope.message.as_deref())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
Done(Box<QueryResult>),
|
||||
/// The session token no longer works (`gone`: nor does the master token).
|
||||
Expired {
|
||||
gone: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn expiry(envelope: &Envelope) -> Option<Outcome> {
|
||||
if envelope.has_code(&[error::SESSION_EXPIRED]) {
|
||||
Some(Outcome::Expired { gone: false })
|
||||
} else if envelope.has_code(error::SESSION_GONE) {
|
||||
Some(Outcome::Expired { gone: true })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod commands;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Result decoding: Snowflake's JSON result format sends every cell as text in an internal
|
||||
//! form (dates as day numbers, timestamps as epoch seconds); these become the grid's text.
|
||||
|
||||
use crate::conn::ResultColumn;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A column of the result's `rowtype`.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub(crate) struct RowType {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub precision: Option<i64>,
|
||||
pub scale: Option<i64>,
|
||||
pub length: Option<i64>,
|
||||
}
|
||||
|
||||
/// `VARCHAR` without a length: Snowflake reports its maximum.
|
||||
const MAX_TEXT: i64 = 16_777_216;
|
||||
|
||||
impl RowType {
|
||||
pub(crate) fn column(&self) -> ResultColumn {
|
||||
let kind = self.kind.to_ascii_lowercase();
|
||||
let scaled = |name: &str| match self.scale {
|
||||
Some(scale) => format!("{name}({scale})"),
|
||||
None => name.to_owned(),
|
||||
};
|
||||
let type_name = match kind.as_str() {
|
||||
"fixed" => match (self.precision, self.scale) {
|
||||
(Some(p), Some(s)) => format!("NUMBER({p},{s})"),
|
||||
_ => "NUMBER".into(),
|
||||
},
|
||||
"real" => "FLOAT".into(),
|
||||
"text" => match self.length {
|
||||
Some(n) if n > 0 && n < MAX_TEXT => format!("VARCHAR({n})"),
|
||||
_ => "VARCHAR".into(),
|
||||
},
|
||||
"time" => scaled("TIME"),
|
||||
"timestamp_ntz" | "timestamp_ltz" | "timestamp_tz" => scaled(&kind.to_ascii_uppercase()),
|
||||
other => other.to_ascii_uppercase(),
|
||||
};
|
||||
ResultColumn { name: self.name.clone(), type_name }
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid text of one cell (`None`: SQL NULL).
|
||||
pub(crate) fn cell(column: &RowType, value: Value) -> Option<String> {
|
||||
let text = match value {
|
||||
Value::Null => return None,
|
||||
Value::String(text) => text,
|
||||
other => other.to_string(),
|
||||
};
|
||||
let scale = column.scale.unwrap_or(0).clamp(0, 9) as usize;
|
||||
let shown = match column.kind.to_ascii_lowercase().as_str() {
|
||||
"boolean" => Some(if matches!(text.as_str(), "1" | "true" | "TRUE") { "true" } else { "false" }.to_owned()),
|
||||
"date" => text.parse::<i64>().ok().map(date),
|
||||
"time" => nanos(&text).map(|n| time(n.rem_euclid(DAY), scale)),
|
||||
"timestamp_ntz" => nanos(&text).map(|n| timestamp(n, scale)),
|
||||
"timestamp_ltz" => nanos(&text).map(|n| format!("{} Z", timestamp(n, scale))),
|
||||
"timestamp_tz" => timestamp_tz(&text, scale),
|
||||
"binary" => Some(format!("0x{text}")),
|
||||
_ => None,
|
||||
};
|
||||
Some(shown.unwrap_or(text))
|
||||
}
|
||||
|
||||
const SECOND: i128 = 1_000_000_000;
|
||||
const DAY: i128 = 86_400 * SECOND;
|
||||
|
||||
/// `seconds[.fraction]` as nanoseconds.
|
||||
fn nanos(text: &str) -> Option<i128> {
|
||||
let text = text.trim();
|
||||
let (negative, digits) = match text.strip_prefix('-') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, text),
|
||||
};
|
||||
let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
|
||||
if whole.is_empty() || !whole.bytes().all(|b| b.is_ascii_digit()) || !fraction.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let mut fraction = fraction.to_owned();
|
||||
fraction.truncate(9);
|
||||
while fraction.len() < 9 {
|
||||
fraction.push('0');
|
||||
}
|
||||
let value = whole.parse::<i128>().ok()? * SECOND + fraction.parse::<i128>().ok()?;
|
||||
Some(if negative { -value } else { value })
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 as `YYYY-MM-DD` (proleptic Gregorian calendar).
|
||||
fn date(days: i64) -> String {
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
if year < 0 { format!("-{:04}-{month:02}-{day:02}", -year) } else { format!("{year:04}-{month:02}-{day:02}") }
|
||||
}
|
||||
|
||||
fn time(nanos_of_day: i128, scale: usize) -> String {
|
||||
let seconds = nanos_of_day / SECOND;
|
||||
let mut text = format!("{:02}:{:02}:{:02}", seconds / 3600, seconds / 60 % 60, seconds % 60);
|
||||
if scale > 0 {
|
||||
let fraction = format!("{:09}", nanos_of_day % SECOND);
|
||||
text.push('.');
|
||||
text.push_str(&fraction[..scale]);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn timestamp(nanos: i128, scale: usize) -> String {
|
||||
format!("{} {}", date(nanos.div_euclid(DAY) as i64), time(nanos.rem_euclid(DAY), scale))
|
||||
}
|
||||
|
||||
/// `seconds[.fraction] offset`, the offset in minutes plus 1440.
|
||||
fn timestamp_tz(text: &str, scale: usize) -> Option<String> {
|
||||
let (instant, offset) = text.trim().split_once(' ')?;
|
||||
let offset = offset.trim().parse::<i128>().ok()? - 1440;
|
||||
let local = nanos(instant)? + offset * 60 * SECOND;
|
||||
let sign = if offset < 0 { '-' } else { '+' };
|
||||
Some(format!("{} {sign}{:02}:{:02}", timestamp(local, scale), offset.abs() / 60, offset.abs() % 60))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn column(kind: &str, scale: Option<i64>) -> RowType {
|
||||
RowType { name: "C".into(), kind: kind.into(), scale, ..Default::default() }
|
||||
}
|
||||
|
||||
fn shown(kind: &str, scale: Option<i64>, value: &str) -> String {
|
||||
cell(&column(kind, scale), Value::String(value.into())).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_types_the_way_snowflake_spells_them() {
|
||||
let named = |kind: &str, precision, scale, length| {
|
||||
RowType { name: "C".into(), kind: kind.into(), precision, scale, length }.column().type_name
|
||||
};
|
||||
assert_eq!(named("fixed", Some(38), Some(0), None), "NUMBER(38,0)");
|
||||
assert_eq!(named("fixed", Some(10), Some(2), None), "NUMBER(10,2)");
|
||||
assert_eq!(named("real", None, None, None), "FLOAT");
|
||||
assert_eq!(named("text", None, None, Some(100)), "VARCHAR(100)");
|
||||
assert_eq!(named("text", None, None, Some(16_777_216)), "VARCHAR");
|
||||
assert_eq!(named("text", None, None, Some(134_217_728)), "VARCHAR");
|
||||
assert_eq!(named("timestamp_ntz", None, Some(9), None), "TIMESTAMP_NTZ(9)");
|
||||
assert_eq!(named("timestamp_tz", None, Some(3), None), "TIMESTAMP_TZ(3)");
|
||||
assert_eq!(named("time", None, Some(0), None), "TIME(0)");
|
||||
for kind in ["boolean", "date", "variant", "object", "array", "binary", "geography", "vector"] {
|
||||
assert_eq!(named(kind, None, None, None), kind.to_ascii_uppercase());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_and_plain_values_pass_through() {
|
||||
assert_eq!(cell(&column("fixed", Some(0)), Value::Null), None);
|
||||
assert_eq!(shown("fixed", Some(2), "12345678901234567890.12"), "12345678901234567890.12");
|
||||
assert_eq!(shown("real", None, "1.5e-7"), "1.5e-7");
|
||||
assert_eq!(shown("text", None, "a\nb"), "a\nb");
|
||||
assert_eq!(shown("variant", None, "{\n \"a\": 1\n}"), "{\n \"a\": 1\n}");
|
||||
assert_eq!(cell(&column("fixed", Some(0)), serde_json::json!(7)), Some("7".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans_and_binary() {
|
||||
assert_eq!(shown("boolean", None, "1"), "true");
|
||||
assert_eq!(shown("boolean", None, "true"), "true");
|
||||
assert_eq!(shown("boolean", None, "0"), "false");
|
||||
assert_eq!(shown("binary", None, "DEADBEEF"), "0xDEADBEEF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_count_days_from_the_epoch() {
|
||||
assert_eq!(shown("date", None, "0"), "1970-01-01");
|
||||
assert_eq!(shown("date", None, "19675"), "2023-11-14");
|
||||
assert_eq!(shown("date", None, "-1"), "1969-12-31");
|
||||
assert_eq!(shown("date", None, "11016"), "2000-02-29");
|
||||
assert_eq!(shown("date", None, "-719162"), "0001-01-01");
|
||||
assert_eq!(shown("date", None, "2932896"), "9999-12-31");
|
||||
assert_eq!(shown("date", None, "not a number"), "not a number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn times_and_timestamps_keep_their_scale() {
|
||||
assert_eq!(shown("time", Some(0), "3723"), "01:02:03");
|
||||
assert_eq!(shown("time", Some(3), "3723.5"), "01:02:03.500");
|
||||
assert_eq!(shown("time", Some(9), "86399.999999999"), "23:59:59.999999999");
|
||||
assert_eq!(shown("timestamp_ntz", Some(0), "1700000000"), "2023-11-14 22:13:20");
|
||||
assert_eq!(shown("timestamp_ntz", Some(3), "1700000000.123000000"), "2023-11-14 22:13:20.123");
|
||||
assert_eq!(shown("timestamp_ntz", Some(9), "1700000000.123456789"), "2023-11-14 22:13:20.123456789");
|
||||
assert_eq!(shown("timestamp_ntz", Some(3), "-0.500"), "1969-12-31 23:59:59.500");
|
||||
assert_eq!(shown("timestamp_ntz", Some(0), "-86401"), "1969-12-30 23:59:59");
|
||||
assert_eq!(shown("timestamp_ltz", Some(0), "1700000000"), "2023-11-14 22:13:20 Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoned_timestamps_show_local_time_and_offset() {
|
||||
assert_eq!(shown("timestamp_tz", Some(3), "1700000000.123 1440"), "2023-11-14 22:13:20.123 +00:00");
|
||||
assert_eq!(shown("timestamp_tz", Some(0), "1700000000 1560"), "2023-11-15 00:13:20 +02:00");
|
||||
assert_eq!(shown("timestamp_tz", Some(0), "1700000000 1110"), "2023-11-14 16:43:20 -05:30");
|
||||
assert_eq!(shown("timestamp_tz", Some(0), "garbage"), "garbage");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
|
||||
const FILE: &str = "data-sources.toml";
|
||||
pub(crate) const FILE: &str = "data-sources.toml";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -22,6 +22,7 @@ pub enum DbDriver {
|
||||
Clickhouse,
|
||||
/// Oracle Database (pure-Rust thin driver, no client library).
|
||||
Oracle,
|
||||
Snowflake,
|
||||
}
|
||||
|
||||
impl DbDriver {
|
||||
@@ -33,6 +34,7 @@ impl DbDriver {
|
||||
DbDriver::SqlServer => Some(1433),
|
||||
DbDriver::Clickhouse => Some(8123),
|
||||
DbDriver::Oracle => Some(1521),
|
||||
DbDriver::Snowflake => Some(443),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +133,37 @@ pub struct DbProperty {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// How a Snowflake data source signs in. The data source's one saved secret is what the
|
||||
/// method needs: the private key's passphrase, the password, or the token.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DbSnowflakeAuth {
|
||||
/// RSA key pair: a JWT signed with the private key file.
|
||||
#[default]
|
||||
KeyPair,
|
||||
/// User name and password.
|
||||
Password,
|
||||
/// Programmatic access token.
|
||||
Token,
|
||||
/// OAuth access token.
|
||||
OAuth,
|
||||
/// Single sign-on in the web browser.
|
||||
Browser,
|
||||
}
|
||||
|
||||
/// Snowflake connection settings beyond the shared ones: the account identifier is
|
||||
/// [`DataSource::host`], the schema [`DbOptions::default_schema`].
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DbSnowflake {
|
||||
pub warehouse: String,
|
||||
pub role: String,
|
||||
pub auth: DbSnowflakeAuth,
|
||||
/// PEM private key of [`DbSnowflakeAuth::KeyPair`]; `~/` is the home directory. Only the
|
||||
/// path is stored: the key is read from the file at each login.
|
||||
pub private_key_file: String,
|
||||
}
|
||||
|
||||
/// How the SSH tunnel authenticates (shared with Settings › SSH Configurations).
|
||||
pub use ide_core::remote_settings::SshAuth as DbSshAuth;
|
||||
|
||||
@@ -185,6 +218,8 @@ pub struct DataSource {
|
||||
pub options: DbOptions,
|
||||
pub introspection: DbIntrospection,
|
||||
pub properties: Vec<DbProperty>,
|
||||
/// Snowflake settings (`None`: not a Snowflake data source, or all defaults).
|
||||
pub snowflake: Option<DbSnowflake>,
|
||||
/// Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
/// so not written to it.
|
||||
pub global: bool,
|
||||
@@ -222,6 +257,7 @@ impl Default for DataSource {
|
||||
options: DbOptions::default(),
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
snowflake: None,
|
||||
global: false,
|
||||
plugin_owner: None,
|
||||
plugin_source_id: None,
|
||||
@@ -273,6 +309,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
|
||||
"oracle" | "oracledb" => Some(DbDriver::Oracle),
|
||||
"snowflake" => Some(DbDriver::Snowflake),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -375,6 +412,9 @@ impl DataSource {
|
||||
}
|
||||
let mut properties = Vec::new();
|
||||
for (k, v) in u.params {
|
||||
if s.driver == DbDriver::Snowflake && s.snowflake_url_parameter(&k, &v) {
|
||||
continue;
|
||||
}
|
||||
match k.to_ascii_lowercase().as_str() {
|
||||
"sslmode" | "ssl-mode" => {
|
||||
s.ssl = match v.to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
@@ -405,6 +445,35 @@ impl DataSource {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Applies a parameter of a Snowflake JDBC URL that names a connection setting.
|
||||
fn snowflake_url_parameter(&mut self, name: &str, value: &str) -> bool {
|
||||
let settings = self.snowflake.get_or_insert_with(DbSnowflake::default);
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"db" | "database" => self.database = value.to_owned(),
|
||||
"schema" => self.options.default_schema = value.to_owned(),
|
||||
"warehouse" => settings.warehouse = value.to_owned(),
|
||||
"role" => settings.role = value.to_owned(),
|
||||
"private_key_file" => settings.private_key_file = value.to_owned(),
|
||||
"authenticator" => {
|
||||
settings.auth = match value.to_ascii_lowercase().as_str() {
|
||||
"snowflake_jwt" => DbSnowflakeAuth::KeyPair,
|
||||
"externalbrowser" => DbSnowflakeAuth::Browser,
|
||||
"oauth" => DbSnowflakeAuth::OAuth,
|
||||
"programmatic_access_token" => DbSnowflakeAuth::Token,
|
||||
"snowflake" => DbSnowflakeAuth::Password,
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The Snowflake settings (defaults when none were saved).
|
||||
pub fn snowflake(&self) -> DbSnowflake {
|
||||
self.snowflake.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The password written in the URL of a [`DataSource::url_only`] data source.
|
||||
pub fn url_password(&self) -> Option<String> {
|
||||
if !self.url_only {
|
||||
@@ -613,4 +682,72 @@ mod tests {
|
||||
let error = parse_url("clickhouse://reader:[email protected]:invalid/default").unwrap_err();
|
||||
assert!(!error.message.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_settings_round_trip_and_stay_out_of_other_sources() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let snowflake = DataSource {
|
||||
id: "sf".into(),
|
||||
name: "warehouse".into(),
|
||||
driver: DbDriver::Snowflake,
|
||||
host: "myorg-myaccount".into(),
|
||||
user: "jane".into(),
|
||||
database: "ANALYTICS".into(),
|
||||
save_password: true,
|
||||
snowflake: Some(DbSnowflake {
|
||||
warehouse: "COMPUTE_WH".into(),
|
||||
role: "ANALYST".into(),
|
||||
auth: DbSnowflakeAuth::KeyPair,
|
||||
private_key_file: "~/.ssh/rsa_key.p8".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let other = DataSource { id: "pg".into(), name: "pg".into(), driver: DbDriver::Postgres, ..Default::default() };
|
||||
save(tmp.path(), &[snowflake.clone(), other.clone()]).unwrap();
|
||||
assert_eq!(load(tmp.path()).unwrap(), vec![snowflake, other]);
|
||||
let text = std::fs::read_to_string(project_dir::path(tmp.path(), FILE)).unwrap();
|
||||
assert_eq!(text.matches("snowflake]").count(), 1, "{text}");
|
||||
assert!(text.contains("driver = \"snowflake\""), "{text}");
|
||||
assert!(text.contains("auth = \"keyPair\""), "{text}");
|
||||
assert!(text.contains("privateKeyFile = \"~/.ssh/rsa_key.p8\""), "{text}");
|
||||
assert_eq!(DbDriver::Snowflake.default_port(), Some(443));
|
||||
assert_eq!(DataSource::default().snowflake(), DbSnowflake::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_jdbc_urls_name_the_session_settings() {
|
||||
let s = DataSource {
|
||||
driver: DbDriver::Snowflake,
|
||||
url_only: true,
|
||||
url: "jdbc:snowflake://myorg-myaccount.snowflakecomputing.com:443/?warehouse=COMPUTE_WH&db=ANALYTICS\
|
||||
&schema=PUBLIC&role=ANALYST&user=jane&authenticator=snowflake_jwt\
|
||||
&private_key_file=%7E%2Fkeys%2Frsa_key.p8&QUERY_TAG=ide"
|
||||
.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let r = s.resolved().unwrap();
|
||||
assert_eq!(r.host, "myorg-myaccount.snowflakecomputing.com");
|
||||
assert_eq!(r.port, Some(443));
|
||||
assert_eq!(r.user, "jane");
|
||||
assert_eq!(r.database, "ANALYTICS");
|
||||
assert_eq!(r.options.default_schema, "PUBLIC");
|
||||
assert_eq!(
|
||||
r.snowflake(),
|
||||
DbSnowflake {
|
||||
warehouse: "COMPUTE_WH".into(),
|
||||
role: "ANALYST".into(),
|
||||
auth: DbSnowflakeAuth::KeyPair,
|
||||
private_key_file: "~/keys/rsa_key.p8".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(r.properties, vec![DbProperty { name: "QUERY_TAG".into(), value: "ide".into() }]);
|
||||
let browser = DataSource {
|
||||
url: "snowflake://xy12345.eu-central-1.snowflakecomputing.com/DB?authenticator=externalbrowser".into(),
|
||||
..s
|
||||
};
|
||||
let r = browser.resolved().unwrap();
|
||||
assert_eq!(r.database, "DB");
|
||||
assert_eq!(r.snowflake().auth, DbSnowflakeAuth::Browser);
|
||||
assert_eq!(scheme_driver("snowflake"), Some(DbDriver::Snowflake));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
let clickhouse = driver == DbDriver::Clickhouse;
|
||||
// PL/SQL: `;` inside BEGIN…END / DECLARE…END blocks does not terminate a statement.
|
||||
let plsql = driver == DbDriver::Oracle;
|
||||
let snowflake = driver == DbDriver::Snowflake;
|
||||
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
||||
// UTF-16 offset of each char index (plus the end).
|
||||
let mut u16_at = Vec::with_capacity(chars.len() + 1);
|
||||
@@ -57,6 +58,10 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
let mut block = false;
|
||||
// `CREATE PACKAGE` / `TYPE BODY`: only `/` on its own line (or the end) ends it.
|
||||
let mut package = false;
|
||||
// The segment is a Snowflake Scripting block (`DECLARE … BEGIN … END`, `BEGIN … END`).
|
||||
let mut script = false;
|
||||
// The segment may carry a Scripting block as its body (`CREATE TASK … AS BEGIN … END`).
|
||||
let mut defines = false;
|
||||
let push = |out: &mut Vec<SqlStatement>, s: usize, e: usize, has_code: bool| {
|
||||
if !has_code {
|
||||
return;
|
||||
@@ -91,11 +96,29 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
// Re-read this character as code (it may start BEGIN / DECLARE).
|
||||
continue;
|
||||
}
|
||||
_ if snowflake && !has_code && c.is_alphabetic() => {
|
||||
if word_at(&chars, i, "DECLARE") {
|
||||
(script, block, declarations) = (true, true, true);
|
||||
} else if word_at(&chars, i, "BEGIN") {
|
||||
// `BEGIN;`, `BEGIN TRANSACTION`… open a transaction; anything else a block.
|
||||
let after = skip_ws(&chars, i + 5);
|
||||
let transaction = chars.get(after).is_none_or(|&(_, c)| c == ';')
|
||||
|| ["TRANSACTION", "WORK", "NAME"].into_iter().any(|w| word_at(&chars, after, w));
|
||||
(script, block) = (!transaction, !transaction);
|
||||
} else {
|
||||
defines = ["CREATE", "WITH", "EXECUTE"].into_iter().any(|w| word_at(&chars, i, w));
|
||||
}
|
||||
has_code = true;
|
||||
// Re-read this character as code (it starts BEGIN / DECLARE).
|
||||
continue;
|
||||
}
|
||||
';' if depth == 0 && !declarations && !package => {
|
||||
push(&mut out, start, if block { i + 1 } else { i }, has_code);
|
||||
start = i + 1;
|
||||
has_code = false;
|
||||
block = false;
|
||||
script = false;
|
||||
defines = false;
|
||||
}
|
||||
';' => has_code = true,
|
||||
'\'' => {
|
||||
@@ -132,7 +155,16 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
i += 1;
|
||||
}
|
||||
'#' if mysql => state = State::LineComment,
|
||||
'$' if driver == DbDriver::Postgres => {
|
||||
// The `//` of `file://…` (PUT, GET) or another URL is not a comment.
|
||||
'/' if snowflake && next == Some('/') && chars[..i].last().is_some_and(|&(_, c)| c == ':') => {
|
||||
has_code = true;
|
||||
i += 1;
|
||||
}
|
||||
'/' if snowflake && next == Some('/') => {
|
||||
state = State::LineComment;
|
||||
i += 1;
|
||||
}
|
||||
'$' if driver == DbDriver::Postgres || snowflake => {
|
||||
// `$tag$ … $tag$` (tag may be empty); `$1` parameters are not quotes.
|
||||
let rest = &text[byte_at(i)..];
|
||||
let tag_len = rest[1..].find('$').filter(|&n| {
|
||||
@@ -151,6 +183,31 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// An unquoted body: `… AS BEGIN … END`, `… AS DECLARE … BEGIN … END`, an alert's
|
||||
// `THEN BEGIN … END`. A column named `begin` is followed by `,`, `)`, `;` or FROM.
|
||||
_ if defines && !script && depth == 0 && scripting_body(&chars, i) => {
|
||||
script = true;
|
||||
let body = skip_ws(&chars, i + if word_at(&chars, i, "AS") { 2 } else { 4 });
|
||||
declarations = word_at(&chars, body, "DECLARE");
|
||||
i = body - 1;
|
||||
}
|
||||
_ if script && word_at(&chars, i, "BEGIN") => {
|
||||
depth += 1;
|
||||
declarations = false;
|
||||
}
|
||||
_ if script && word_at(&chars, i, "CASE") => depth += 1,
|
||||
_ if script && word_at(&chars, i, "END") => {
|
||||
// `END IF` / `FOR` / `WHILE` / `LOOP` / `REPEAT` close statements that opened no
|
||||
// block; `END` and `END CASE` close what BEGIN and CASE opened.
|
||||
let after = skip_ws(&chars, i + 3);
|
||||
let closing = ["IF", "FOR", "WHILE", "LOOP", "REPEAT", "CASE"]
|
||||
.into_iter()
|
||||
.find(|w| word_at(&chars, after, w));
|
||||
if matches!(closing, None | Some("CASE")) && depth > 0 {
|
||||
depth -= 1;
|
||||
}
|
||||
i = closing.map_or(i + 3, |w| after + w.len()) - 1;
|
||||
}
|
||||
_ if plsql && word_at(&chars, i, "BEGIN") => {
|
||||
depth += 1;
|
||||
declarations = false;
|
||||
@@ -183,7 +240,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
_ => {}
|
||||
},
|
||||
State::Single => {
|
||||
if (mysql || clickhouse) && c == '\\' {
|
||||
if (mysql || clickhouse || snowflake) && c == '\\' {
|
||||
i += 1;
|
||||
} else if c == '\'' {
|
||||
if next == Some('\'') {
|
||||
@@ -281,6 +338,20 @@ fn word_at(chars: &[(usize, char)], i: usize, word: &str) -> bool {
|
||||
&& chars.get(i + word.len()).is_none_or(|&(_, c)| !c.is_alphanumeric() && c != '_' && c != '$')
|
||||
}
|
||||
|
||||
/// Whether `AS` or `THEN` at `i` is followed by a Snowflake Scripting block.
|
||||
fn scripting_body(chars: &[(usize, char)], i: usize) -> bool {
|
||||
let Some(word) = ["AS", "THEN"].into_iter().find(|w| word_at(chars, i, w)) else { return false };
|
||||
let body = skip_ws(chars, i + word.len());
|
||||
if word_at(chars, body, "DECLARE") {
|
||||
return true;
|
||||
}
|
||||
if !word_at(chars, body, "BEGIN") {
|
||||
return false;
|
||||
}
|
||||
let after = skip_ws(chars, body + 5);
|
||||
chars.get(after).is_some_and(|&(_, c)| !matches!(c, ',' | ')' | ';')) && !word_at(chars, after, "FROM")
|
||||
}
|
||||
|
||||
/// Whether only whitespace follows `i` until the end of its line.
|
||||
fn ends_line(chars: &[(usize, char)], i: usize) -> bool {
|
||||
chars[i + 1..].iter().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace())
|
||||
@@ -467,4 +538,68 @@ mod tests {
|
||||
vec!["create view v as select 1 x from dual", "create type t as object (a number)", "select 4"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_bodies_comments_and_escapes_keep_statements_intact() {
|
||||
let procedure = "create procedure p() returns string language javascript as $$ var a = 1; return 'x;y'; $$";
|
||||
assert_eq!(
|
||||
texts(
|
||||
&format!("{procedure};\nselect 2; // done; really\nselect $1, 'it\\'s;here' from @stage;"),
|
||||
DbDriver::Snowflake
|
||||
),
|
||||
vec![procedure, "select 2", "// done; really\nselect $1, 'it\\'s;here' from @stage"]
|
||||
);
|
||||
assert_eq!(texts("select 1 / 2; select 3", DbDriver::Snowflake), vec!["select 1 / 2", "select 3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_unquoted_bodies_and_urls_keep_statements_intact() {
|
||||
let task = "create task t warehouse = w as\nbegin\n insert into a values (1);\n delete from b;\nend";
|
||||
assert_eq!(texts(&format!("{task};\nselect 2;"), DbDriver::Snowflake), vec![task, "select 2"]);
|
||||
let procedure = "create procedure p() returns number language sql as\ndeclare\n n number default 1;\nbegin\n return n;\nend";
|
||||
assert_eq!(texts(&format!("{procedure};\nselect 2"), DbDriver::Snowflake), vec![procedure, "select 2"]);
|
||||
let alert =
|
||||
"create alert a schedule = '1 minute' if (exists (select 1)) then begin insert into h values (1); end";
|
||||
assert_eq!(texts(&format!("{alert}; select 2"), DbDriver::Snowflake), vec![alert, "select 2"]);
|
||||
// A column or alias named `begin` opens nothing.
|
||||
for plain in [
|
||||
"create view v as select 1 as begin, 2 as n",
|
||||
"create view v as select a as begin from t",
|
||||
"with x as (select 1 as begin) select * from x",
|
||||
] {
|
||||
assert_eq!(texts(&format!("{plain}; select 2"), DbDriver::Snowflake), vec![plain, "select 2"]);
|
||||
}
|
||||
assert_eq!(
|
||||
texts(
|
||||
"put file:///tmp/a.csv @s; get @s/a.csv file:///tmp/; select 1 // note; here\n; select 2",
|
||||
DbDriver::Snowflake
|
||||
),
|
||||
vec!["put file:///tmp/a.csv @s", "get @s/a.csv file:///tmp/", "select 1 // note; here", "select 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_scripting_blocks_are_one_statement_and_transactions_are_not() {
|
||||
let block = "DECLARE\n total NUMBER DEFAULT 0;\n c CURSOR FOR SELECT n FROM t;\nBEGIN\n FOR r IN c DO\n \
|
||||
IF (r.n > 1) THEN total := total + r.n; END IF;\n END FOR;\n \
|
||||
LET label := CASE WHEN total > 9 THEN 'big' ELSE 'small' END;\n \
|
||||
BEGIN\n INSERT INTO log VALUES (:label);\n EXCEPTION WHEN OTHER THEN RETURN 'failed';\n END;\n \
|
||||
RETURN total;\nEND;";
|
||||
assert_eq!(texts(&format!("{block}\nselect 2;"), DbDriver::Snowflake), vec![block, "select 2"]);
|
||||
let plain = "BEGIN\n LET x := 1;\n WHILE (x < 3) DO x := x + 1; END WHILE;\n RETURN x;\nEND;";
|
||||
assert_eq!(
|
||||
texts(&format!("select 1;\n{plain}\nselect 3"), DbDriver::Snowflake),
|
||||
vec!["select 1", plain, "select 3"]
|
||||
);
|
||||
for begin in ["begin", "BEGIN TRANSACTION", "begin work", "BEGIN NAME t1"] {
|
||||
assert_eq!(
|
||||
texts(&format!("{begin};\ninsert into t values (1);\ncommit;"), DbDriver::Snowflake),
|
||||
vec![begin, "insert into t values (1)", "commit"]
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
texts("select case when a then 1 end, b from t; select 2", DbDriver::Snowflake),
|
||||
vec!["select case when a then 1 end, b from t", "select 2"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ pub fn quote_ident(driver: DbDriver, name: &str) -> String {
|
||||
|
||||
pub fn quote_literal(driver: DbDriver, value: &str) -> String {
|
||||
let mut s = value.replace('\'', "''");
|
||||
if matches!(driver, DbDriver::Mysql | DbDriver::Clickhouse) {
|
||||
if matches!(driver, DbDriver::Mysql | DbDriver::Clickhouse | DbDriver::Snowflake) {
|
||||
s = s.replace('\\', "\\\\");
|
||||
}
|
||||
format!("'{s}'")
|
||||
@@ -75,12 +75,19 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
DbDriver::Clickhouse => {
|
||||
"SELECT name FROM system.databases WHERE lower(name) NOT IN ('system', 'information_schema') ORDER BY name"
|
||||
}
|
||||
// `information_schema.schemata` lists only owned schemas before PostgreSQL 10 (and on servers
|
||||
// built on such a core): a read-only account would see none. Any schema it may use is listed.
|
||||
DbDriver::Postgres if system => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
|
||||
"SELECT nspname FROM pg_catalog.pg_namespace \
|
||||
WHERE nspname NOT LIKE 'pg\\_toast%' AND nspname NOT LIKE 'pg\\_temp%' \
|
||||
AND has_schema_privilege(nspname, 'USAGE') ORDER BY 1"
|
||||
}
|
||||
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::Snowflake if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::Snowflake => {
|
||||
"SELECT schema_name FROM information_schema.schemata WHERE schema_name <> 'INFORMATION_SCHEMA' ORDER BY 1"
|
||||
}
|
||||
DbDriver::Oracle if system => "SELECT username FROM all_users ORDER BY username",
|
||||
DbDriver::Oracle => {
|
||||
"SELECT username FROM all_users WHERE username NOT IN \
|
||||
@@ -88,9 +95,10 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') ORDER BY username"
|
||||
}
|
||||
DbDriver::Postgres => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
|
||||
AND schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
|
||||
"SELECT nspname FROM pg_catalog.pg_namespace \
|
||||
WHERE nspname NOT IN ('pg_catalog', 'information_schema') \
|
||||
AND nspname NOT LIKE 'pg\\_toast%' AND nspname NOT LIKE 'pg\\_temp%' \
|
||||
AND has_schema_privilege(nspname, 'USAGE') ORDER BY 1"
|
||||
}
|
||||
DbDriver::Mysql => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
@@ -117,6 +125,11 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
|
||||
quote_ident(driver, schema)
|
||||
),
|
||||
DbDriver::Snowflake => format!(
|
||||
"SELECT table_name, CASE table_type WHEN 'VIEW' THEN 'view' WHEN 'MATERIALIZED VIEW' THEN 'materialized view' \
|
||||
ELSE 'table' END FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT table_name, 'table' FROM all_tables WHERE owner = {} \
|
||||
UNION ALL SELECT view_name, 'view' FROM all_views WHERE owner = {} ORDER BY 1",
|
||||
@@ -142,6 +155,11 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Snowflake column type with its size, from `information_schema.columns`.
|
||||
const SNOWFLAKE_TYPE: &str = "CASE WHEN data_type = 'NUMBER' THEN 'NUMBER(' || numeric_precision || ',' || numeric_scale || ')' \
|
||||
WHEN data_type = 'TEXT' THEN 'VARCHAR(' || character_maximum_length || ')' \
|
||||
WHEN data_type LIKE 'TIME%' THEN data_type || '(' || datetime_precision || ')' ELSE data_type END";
|
||||
|
||||
/// Rows: `name, type, nullable (YES/NO or 0/1), default, is_pk (0/1)`.
|
||||
pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
@@ -182,6 +200,10 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \
|
||||
ORDER BY c.ordinal_position"
|
||||
),
|
||||
DbDriver::Snowflake => format!(
|
||||
"SELECT column_name, {SNOWFLAKE_TYPE}, is_nullable, column_default, 0 \
|
||||
FROM information_schema.columns WHERE table_schema = {s} AND table_name = {t} ORDER BY ordinal_position"
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), c.data_default, \
|
||||
CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END \
|
||||
@@ -230,6 +252,7 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \
|
||||
ORDER BY i.name, ic.key_ordinal"
|
||||
),
|
||||
DbDriver::Snowflake => "SELECT '', 0, '' WHERE 1 = 0".to_owned(),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT i.index_name, CASE WHEN i.uniqueness = 'UNIQUE' THEN 1 ELSE 0 END, ic.column_name \
|
||||
FROM all_indexes i JOIN all_ind_columns ic \
|
||||
@@ -296,6 +319,15 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
WHERE c.table_schema NOT IN ('sys', 'information_schema') \
|
||||
ORDER BY c.table_schema, c.table_name, c.ordinal_position"
|
||||
}
|
||||
DbDriver::Snowflake => {
|
||||
"SELECT c.table_schema, c.table_name, CASE t.table_type WHEN 'VIEW' THEN 'view' \
|
||||
WHEN 'MATERIALIZED VIEW' THEN 'materialized view' ELSE 'table' END, c.column_name, c.data_type, \
|
||||
c.is_nullable, 0, CASE WHEN c.column_default IS NOT NULL THEN 1 ELSE 0 END \
|
||||
FROM information_schema.columns c JOIN information_schema.tables t \
|
||||
ON t.table_schema = c.table_schema AND t.table_name = c.table_name \
|
||||
WHERE c.table_schema <> 'INFORMATION_SCHEMA' \
|
||||
ORDER BY c.table_schema, c.table_name, c.ordinal_position LIMIT 50000"
|
||||
}
|
||||
DbDriver::Oracle => {
|
||||
"SELECT c.owner, c.table_name, CASE WHEN v.view_name IS NOT NULL THEN 'view' ELSE 'table' END, \
|
||||
c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), \
|
||||
@@ -320,6 +352,7 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
pub fn foreign_keys(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => "SELECT '', '', '', '', '', '', '' WHERE 0",
|
||||
DbDriver::Snowflake => "SELECT '', '', '', '', '', '', '' WHERE 1 = 0",
|
||||
DbDriver::Sqlite => {
|
||||
"SELECT 'main', m.name, f.id, f.\"from\", 'main', f.\"table\", f.\"to\" \
|
||||
FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) f \
|
||||
@@ -370,7 +403,7 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
|
||||
pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => foreign_keys(driver).to_owned(),
|
||||
DbDriver::Clickhouse | DbDriver::Snowflake => foreign_keys(driver).to_owned(),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT {s}, {t}, f.id, f.\"from\", {s}, f.\"table\", f.\"to\" FROM pragma_foreign_key_list({t}, {s}) f ORDER BY f.id, f.seq"
|
||||
),
|
||||
@@ -398,6 +431,7 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
|
||||
DbDriver::Mysql => Some("SELECT DATABASE()"),
|
||||
DbDriver::SqlServer => Some("SELECT DB_NAME()"),
|
||||
DbDriver::Oracle => Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"),
|
||||
DbDriver::Snowflake => Some("SELECT CURRENT_SCHEMA()"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +444,7 @@ pub fn use_schema(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
DbDriver::Postgres => Some(format!("SET search_path TO {}", quote_ident(driver, schema))),
|
||||
DbDriver::Mysql | DbDriver::SqlServer => Some(format!("USE {}", quote_ident(driver, schema))),
|
||||
DbDriver::Oracle => Some(format!("ALTER SESSION SET CURRENT_SCHEMA = {}", quote_ident(driver, schema))),
|
||||
DbDriver::Snowflake => Some(format!("USE SCHEMA {}", quote_ident(driver, schema))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,6 +555,9 @@ pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowE
|
||||
DbDriver::Mysql => format!("INSERT INTO {target} () VALUES ()"),
|
||||
// Oracle has no DEFAULT VALUES; without columns there is nothing to insert.
|
||||
DbDriver::Oracle => return Err("Oracle needs at least one column value to insert".into()),
|
||||
DbDriver::Snowflake => {
|
||||
return Err("Snowflake needs at least one column value to insert".into());
|
||||
}
|
||||
_ => format!("INSERT INTO {target} DEFAULT VALUES"),
|
||||
});
|
||||
}
|
||||
@@ -588,6 +626,7 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => "SELECT '', '', '' WHERE 0".to_owned(),
|
||||
DbDriver::Snowflake => "SELECT '', '', '' WHERE 1 = 0".to_owned(),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT name, sql FROM {}.sqlite_master WHERE type = 'trigger' AND tbl_name = {t} ORDER BY name",
|
||||
quote_ident(driver, schema)
|
||||
@@ -656,6 +695,12 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
"SELECT r.routine_name, lower(r.routine_type), '', r.data_type \
|
||||
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
|
||||
)),
|
||||
DbDriver::Snowflake => Some(format!(
|
||||
"SELECT function_name, 'function', argument_signature, data_type FROM information_schema.functions \
|
||||
WHERE function_schema = {s} UNION ALL \
|
||||
SELECT procedure_name, 'procedure', argument_signature, data_type FROM information_schema.procedures \
|
||||
WHERE procedure_schema = {s} ORDER BY 1, 3"
|
||||
)),
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT object_name, CASE object_type WHEN 'PROCEDURE' THEN 'procedure' ELSE 'function' END, '', NULL \
|
||||
FROM all_objects WHERE owner = {s} AND object_type IN ('PROCEDURE', 'FUNCTION') \
|
||||
@@ -697,6 +742,10 @@ pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
"SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
DbDriver::Snowflake => Some(format!(
|
||||
"SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
// Every sequence is numeric; ALL_SEQUENCES has no data_type column before 18c.
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT sequence_name, 'NUMBER' FROM all_sequences WHERE sequence_owner = {} ORDER BY 1",
|
||||
@@ -930,6 +979,17 @@ mod tests {
|
||||
assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_lists_the_schemas_the_user_may_use() {
|
||||
for system in [false, true] {
|
||||
let sql = schemas(DbDriver::Postgres, system);
|
||||
assert!(sql.contains("FROM pg_catalog.pg_namespace"), "{sql}");
|
||||
assert!(sql.contains("has_schema_privilege(nspname, 'USAGE')"), "{sql}");
|
||||
assert!(sql.contains("NOT LIKE 'pg\\_toast%'"), "{sql}");
|
||||
assert_eq!(sql.contains("NOT IN ('pg_catalog', 'information_schema')"), !system, "{sql}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_quoting_and_schema() {
|
||||
assert_eq!(quote_ident(DbDriver::SqlServer, "a]b"), "[a]]b]");
|
||||
@@ -1086,4 +1146,30 @@ mod tests {
|
||||
"create table \"public\".\"t\"\n(\n \"id\" integer not null,\n \"name\" character varying(40) default 'x'::character varying,\n constraint \"t_pkey\" PRIMARY KEY (id)\n);\n\nCREATE INDEX t_name ON public.t USING btree (name);\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowflake_quotes_names_and_reads_its_information_schema() {
|
||||
let driver = DbDriver::Snowflake;
|
||||
assert_eq!(quote_ident(driver, "My \"Table\""), "\"My \"\"Table\"\"\"");
|
||||
assert_eq!(quote_literal(driver, "it's a \\ path"), "'it''s a \\\\ path'");
|
||||
assert_eq!(qualified(driver, "PUBLIC", "ORDERS"), "\"PUBLIC\".\"ORDERS\"");
|
||||
assert_eq!(current_schema(driver), Some("SELECT CURRENT_SCHEMA()"));
|
||||
assert_eq!(use_schema(driver, "Sales"), Some("USE SCHEMA \"Sales\"".into()));
|
||||
assert!(schemas(driver, true).contains("information_schema.schemata"));
|
||||
assert!(schemas(driver, false).contains("<> 'INFORMATION_SCHEMA'"));
|
||||
assert!(tables(driver, "PUBLIC").contains("table_schema = 'PUBLIC'"));
|
||||
let columns = columns(driver, "PUBLIC", "ORDERS");
|
||||
assert!(columns.contains("'NUMBER(' || numeric_precision"), "{columns}");
|
||||
assert!(columns.contains("table_name = 'ORDERS' ORDER BY ordinal_position"), "{columns}");
|
||||
assert!(completion_columns(driver).contains("LIMIT 50000"));
|
||||
assert!(routines(driver, "PUBLIC").unwrap().contains("information_schema.procedures"));
|
||||
assert!(sequences(driver, "PUBLIC").unwrap().contains("information_schema.sequences"));
|
||||
assert_eq!(
|
||||
table_page(driver, "PUBLIC", "ORDERS", None, &[], 50, 25),
|
||||
"SELECT * FROM \"PUBLIC\".\"ORDERS\" LIMIT 26 OFFSET 50"
|
||||
);
|
||||
assert!(edit_statement(driver, "PUBLIC", "T", &DbRowEdit::Insert { values: vec![] }).is_err());
|
||||
assert_eq!(crate::conn::begin_statement(driver), "BEGIN");
|
||||
assert_eq!(crate::plan::explain_statement(driver, "select 1;"), "EXPLAIN USING TABULAR select 1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Data source files edited outside the Database tool window (by hand, by a coding
|
||||
//! agent, by `git checkout`) are picked up while the project is open, as IDEA does
|
||||
//! for `dataSources.xml`. The project file tree's watcher is no help here: it never
|
||||
//! reports a modification inside a gitignored `.openidea/`.
|
||||
|
||||
use crate::source::FILE;
|
||||
use ide_core::{IdeError, IdeResult, project_dir};
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use parking_lot::Mutex;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Weak, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long a burst of writes (an editor's save, an agent's rewrite) settles before
|
||||
/// the files are read.
|
||||
const SETTLE: Duration = Duration::from_millis(200);
|
||||
|
||||
/// Watches `<root>/.openidea/data-sources.toml` and `<global>/data-sources.toml`;
|
||||
/// stops when dropped.
|
||||
pub struct SourcesWatch {
|
||||
_watcher: Arc<Mutex<RecommendedWatcher>>,
|
||||
}
|
||||
|
||||
impl SourcesWatch {
|
||||
/// Calls `on_change` once a burst of changes to either file settled, on a
|
||||
/// background thread.
|
||||
pub fn start(root: &Path, global: Option<&Path>, on_change: Box<dyn Fn() + Send>) -> IdeResult<Self> {
|
||||
let config: PathBuf = root.join(project_dir::DIR);
|
||||
let (tx, rx) = mpsc::channel::<()>();
|
||||
let relevant = {
|
||||
let config = config.clone();
|
||||
move |p: &Path| p.file_name().is_some_and(|n| n == FILE) || p == config
|
||||
};
|
||||
let watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
if let Ok(e) = res
|
||||
&& e.paths.iter().any(|p| relevant(p))
|
||||
{
|
||||
let _ = tx.send(());
|
||||
}
|
||||
})
|
||||
.map_err(|e| IdeError::internal(format!("data sources watcher: {e}")))?;
|
||||
let watcher = Arc::new(Mutex::new(watcher));
|
||||
{
|
||||
let mut w = watcher.lock();
|
||||
// The root (not recursively) sees `.openidea/` itself appear or go.
|
||||
w.watch(root, RecursiveMode::NonRecursive)
|
||||
.map_err(|e| IdeError::internal(format!("data sources watcher: {e}")))?;
|
||||
if let Some(dir) = global.filter(|d| d.is_dir())
|
||||
&& let Err(e) = w.watch(dir, RecursiveMode::NonRecursive)
|
||||
{
|
||||
tracing::warn!("global data sources watcher: {e}");
|
||||
}
|
||||
}
|
||||
let mut watching = watch_config(&watcher, &config, false);
|
||||
let weak: Weak<Mutex<RecommendedWatcher>> = Arc::downgrade(&watcher);
|
||||
std::thread::Builder::new().name("openidea-db-sources-watch".into()).spawn(move || {
|
||||
while rx.recv().is_ok() {
|
||||
// Wait until the writes stop.
|
||||
loop {
|
||||
match rx.recv_timeout(SETTLE) {
|
||||
Ok(()) => {}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => break,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
}
|
||||
let Some(watcher) = weak.upgrade() else { return };
|
||||
watching = watch_config(&watcher, &config, watching);
|
||||
drop(watcher);
|
||||
on_change();
|
||||
}
|
||||
})?;
|
||||
Ok(Self { _watcher: watcher })
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches `.openidea/` once it exists (again: a removed folder loses its watch);
|
||||
/// returns whether it is watched.
|
||||
fn watch_config(watcher: &Mutex<RecommendedWatcher>, config: &Path, watching: bool) -> bool {
|
||||
if !config.is_dir() {
|
||||
return false;
|
||||
}
|
||||
if watching {
|
||||
return true;
|
||||
}
|
||||
match watcher.lock().watch(config, RecursiveMode::NonRecursive) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!("data sources watcher {}: {e}", config.display());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
fn wait_for(count: &AtomicUsize, at_least: usize) -> bool {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
if count.load(Ordering::SeqCst) >= at_least {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn counting() -> (Arc<AtomicUsize>, Box<dyn Fn() + Send>) {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let c = count.clone();
|
||||
(count, Box::new(move || _ = c.fetch_add(1, Ordering::SeqCst)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_project_and_global_files_and_a_config_folder_created_later() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let (project, global) = (root.join("p"), root.join("app"));
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
std::fs::create_dir_all(&global).unwrap();
|
||||
let (count, on_change) = counting();
|
||||
let _watch = SourcesWatch::start(&project, Some(&global), on_change).unwrap();
|
||||
|
||||
// No `.openidea/` yet: it is created, then the file written into it.
|
||||
std::fs::create_dir_all(project.join(".openidea")).unwrap();
|
||||
assert!(wait_for(&count, 1));
|
||||
let seen = count.load(Ordering::SeqCst);
|
||||
std::fs::write(project.join(".openidea/data-sources.toml"), "").unwrap();
|
||||
assert!(wait_for(&count, seen + 1), "project file");
|
||||
|
||||
let seen = count.load(Ordering::SeqCst);
|
||||
std::fs::write(global.join("data-sources.toml"), "").unwrap();
|
||||
assert!(wait_for(&count, seen + 1), "global file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_other_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let (count, on_change) = counting();
|
||||
let _watch = SourcesWatch::start(&root, None, on_change).unwrap();
|
||||
// FSEvents can deliver the creation of `.openidea/` above after the watch started.
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
let seen = count.load(Ordering::SeqCst);
|
||||
std::fs::write(root.join(".openidea/settings.toml"), "").unwrap();
|
||||
std::fs::write(root.join("main.py"), "").unwrap();
|
||||
std::thread::sleep(SETTLE * 3);
|
||||
assert_eq!(count.load(Ordering::SeqCst), seen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
Throwaway keys for the Snowflake key-pair tests. They were generated for this repository,
|
||||
are registered with no account, and protect nothing.
|
||||
|
||||
| File | Format |
|
||||
|---|---|
|
||||
| `rsa_key.p8` | PKCS#8, unencrypted, RSA 2048 |
|
||||
| `rsa_key_encrypted.p8` | PKCS#8, PBES2 (PBKDF2 + AES-256-CBC), passphrase `test-passphrase` |
|
||||
| `rsa_key_scrypt.p8` | PKCS#8, PBES2 (scrypt + AES-256-CBC), passphrase `test-passphrase` |
|
||||
| `rsa_key_pkcs1.pem` | PKCS#1 (`BEGIN RSA PRIVATE KEY`), unencrypted |
|
||||
| `rsa_key_legacy_encrypted.pem` | PKCS#1 with `Proc-Type: 4,ENCRYPTED` (rejected) |
|
||||
| `rsa_key_1024.p8` | RSA 1024 (rejected: too short) |
|
||||
| `ec_key.p8` | EC P-256 (rejected: not RSA) |
|
||||
| `rsa_key.pub` | Public key of `rsa_key.p8` |
|
||||
|
||||
Fingerprint of `rsa_key.pub` from OpenSSL
|
||||
(`openssl rsa -pubin -in rsa_key.pub -outform DER | openssl dgst -sha256 -binary | openssl enc -base64`):
|
||||
`ajjL6Bf6l1FIIyTcJli4i2AWPYDVJGQnSZr+9q9ClgI=`
|
||||
|
||||
`commands.json` holds one sample statement for every command of Snowflake's SQL reference
|
||||
(<https://docs.snowflake.com/en/sql-reference/sql-all>, 786 names). The driver's tests
|
||||
(`src/snowflake/commands.rs`) and the console's (`src/features/database/sql/snowflakeCommands.test.ts`)
|
||||
run all of them; the ignored live test runs the listing ones against a real account. `reads`: allowed
|
||||
on a read-only data source. `rerunnable`: safe to run again for another page of the result. The
|
||||
samples exercise the client, not Snowflake's grammar, and name no real object.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwYBQrdPGgoV9c58S
|
||||
q6Fc5aso2M4wVyXE9WxrpPzcrHahRANCAAR0SjYZ1Pvc5jG2ll+pTSdBiVRccnSB
|
||||
BirgCQL5f1g4CuEK4YNqTIvXMqJ2bBVx6f2MTbLeCmu8EebiJ99dI/ZM
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDPfjbU88uYwtpN
|
||||
hthRdv7h1cJJ1p2nnb8pKLMi/SdPSfSZLm0eA2Tbjp6HtGqv91enrCPyy++hQOl0
|
||||
KtfoPGbzDqxnzZPK4BtckmxHiBQifG7A0d3jpq8Y2JjIkkMItnF7kXQob89TOaFj
|
||||
+pW8/XH6Y+w5cbMYdkf3RXKjZpYRLRBxnzAl4lMhP/boE7reoWuBIwPFVXnX8AVP
|
||||
p0bjkpR2I1fgUKZxMw/mbRhWVy/9b7Vh79RwPG85rvCia2e7OdDLxNYUcEHocvfb
|
||||
8phUHtI7ZvnCJcbXJX7ON9/JdU/DN9RHc+az1ZjB4Za1cHoLXlbnUisSf1e3tnaY
|
||||
uZnGqGLPAgMBAAECggEAQ+8N4sEDSruZH8LjdiCgqK4E04oJygQyvzYNGIqL4UDU
|
||||
bYauwSHZVq0mKEYgnAY+U65fgrHUfhYzmQ4h7G5CQpfuSHodRlIDg1aJTw41kWju
|
||||
FgFgRZWgRGE9/IUun16kXxGrV0XsOlJnDcXQWrUcKR+oCtK4EW1AeKvrMaZ3wZ1J
|
||||
i0xqMlU5bk77kvskBRL4k51V3gOfDoflus2nHpES8yZSAP1uiivbtTOXYB2dRHuD
|
||||
omB4KlscUdIwT9vTMC7kxECEKDwd5fxJkdIJ/NsBi98tOZS0QrH7y7FrBA3K7gLY
|
||||
VJSIckKP8rz3kUAbW9vYV2dRwxIMQj2kZJEKa0kA6QKBgQDsNsJPJdt5BcAPYjV0
|
||||
1LX91ooLCDYux5yUNEno2rmQ/qv5g0d12L94aASklGIIfJV75dXmwa8q4erZU0hs
|
||||
9/XhyUnjDlBLd6oEztDjDA49J57g2nPmzrOtjugucH7Qe8EAJzz+O0PbtkcQzRmV
|
||||
8vnYptWHvEKmnjh8KOb5wOMVFQKBgQDg35SYkiys8V3tQApbp6U7Q3fq3vbVu+2A
|
||||
avBYIE0qgG9fhFrQdihVX1MZ3L4TtXtiikMPO8MOGBjSgN/qbelChhkQV0l+CIf0
|
||||
nOo1SeoEXp16q1NcZTEohnbaboMjWrkl6Br6SUNvEt7Y63UOi62E+ll50ye8wKgA
|
||||
yBHrGXOZUwKBgQDoTaIivJRvHNyZRHhWAatJjGgOGXFP6Ddw+UuGmTTOB+eRZy4s
|
||||
E5GaFJIcWWFf/0Xzp0EJw4lFnxCb0m/z2pgSCSeFCVqmHXUzbUG5SO3n7sMtVymM
|
||||
39OM6HIm2bOdzVctk2lEjXxa45RYTZzP/zj/fHObHeZeflQGpyMglDW/UQKBgQDE
|
||||
wRuFOzC3ySCDu6rUcyTRa8D60kdqz1mJKQhOM0URUPshue6zww6bVCKj2E6/c+UX
|
||||
XR47r/CE3nSpinVg6vZTbH11nMoC6nf6gWkzYw7M7/QeJtVzXF7ctHo9uMApKPIi
|
||||
xqkEISJNh1dQuQPczD3wwhOT0jtMnczhkiyLZeONXQKBgE4wo4IkUhH2SQRiu8Je
|
||||
sg18oa1sABaL6bCQ7vqJ2+cZzj5Pz0WxMoyc7ZJlIiKt2joGQwqmRAqVohHpJylm
|
||||
9RKvymaM0BGVKkAASfwyEf15YvZQx/9IK3uWpRoGrlu7jyu0UlzCgpHXfOu1FKCx
|
||||
DsVT35WOOVJXuDrNhpYAdg5U
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz3421PPLmMLaTYbYUXb+
|
||||
4dXCSdadp52/KSizIv0nT0n0mS5tHgNk246eh7Rqr/dXp6wj8svvoUDpdCrX6Dxm
|
||||
8w6sZ82TyuAbXJJsR4gUInxuwNHd46avGNiYyJJDCLZxe5F0KG/PUzmhY/qVvP1x
|
||||
+mPsOXGzGHZH90Vyo2aWES0QcZ8wJeJTIT/26BO63qFrgSMDxVV51/AFT6dG45KU
|
||||
diNX4FCmcTMP5m0YVlcv/W+1Ye/UcDxvOa7womtnuznQy8TWFHBB6HL32/KYVB7S
|
||||
O2b5wiXG1yV+zjffyXVPwzfUR3Pms9WYweGWtXB6C15W51IrEn9Xt7Z2mLmZxqhi
|
||||
zwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,16 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBANqRvj6Xi55mKH0x
|
||||
96UVh/4tMgBb+5TVT5MN39diDr6iP7vFycEnnHDVXaeQJBog+P7NiYC7XY+7yUL3
|
||||
2J3VxMBzGirm5Sj12uU1C+U4f0Nxx4nVKqonQD8GHJGJxypSZvIculFV9CYSHfoc
|
||||
JYHR95k1Ce7echs7VnXfjvaOBdaLAgMBAAECgYBYecC6E5xn7VCchTqK7/FR2bew
|
||||
34syjzqb+3TN+hqf3ovQFdt6ve8c2bIEn1uN3CUO9+KeYroX8dshqbRdB+iRgx8i
|
||||
EzHAmKLkQj/QF7j1bTLD3zMq9XmUAOaUQwtlzRKSJIOa6EoYlGTcsefZhLhctegE
|
||||
dOfNbBM6En2el2O3MQJBAPX3BX2hkVU42nRYkmqoqz2DEFrcd7T3WpyxT8U42d4z
|
||||
rn8iJJAYwpg5SPf23PNrT+0IGGcI2LFWji9ENXM7H5kCQQDjfJahG/PvElaDGGlh
|
||||
JGjDn5FwR2bs8rII/gxhBXZJSyettkm0PIPnijlcQv3L6lRHi+7LXLFUbV8C6DWn
|
||||
sg3DAkAGnd2M8qK5Oy1NQfC2Y9Evd24iM5oYySqr2m097fiWbKd+DgoTwkbB15Cp
|
||||
8v8goTtmMfiOFQPXSW/Lv7b8YZPJAkEA0aC1jbEckGntntCYCxRqFhqtAzJI/oCG
|
||||
3xJ8hctqvQ+seL9KG/TH+E2/ne19wrMlBulEG9JG9u3yZ9Nj3iyN0QJAM6ROygXj
|
||||
2ReQpmst38WnAaQvT+0Nd8cWoliGz24Vx1cAmlHI4NrCQsODNuR+gJ4vCbfq8Sa/
|
||||
qJQfaDmu34JhJw==
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,30 @@
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQrUgr104rqBLzsVP2
|
||||
13KRWQICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEKZLvVKRzfkt0lie
|
||||
Hvjy+vYEggTQ05I/45BV/X9VcWZ0W4OC8JdtYC0nosK3ymyl4iQGo4sOSLkJGpzu
|
||||
0LhexFRDICNLqHkcs2XQ4Jti8lZyArY3Wql+vl9Z/FVl9s4zJIg8VDXqrPSJ6Ha+
|
||||
wvVhY59UreqlQeLoFdhCK8O9/QeJow/ZQnVshJCLLQ4jiwYUHpuTrRPInZp/Jt5X
|
||||
KD7N4oLsXg7NrBDyQu/DxRiPbtOu07NqMFHel4mRXCz0oKhG0pQ0KuqP0arA9RGN
|
||||
CpT2SuhA3xMl6DMqQhCsGyUVhJhD7FyH/2fxlWHH2WVGWbza3L6l293fpRP2d0Nh
|
||||
VET2Ia93brNpYsXT1BVAvnXG+yddbCSgTjIh/YU+ciKcaaRsZXSpB3ON2yysBXo1
|
||||
XRiXbqfzx22ah9p1sT3ed3QRZ5PPOAYLNqEvUz/eNDBBiwOAdSuS4JAYYS0VyJdG
|
||||
v93pWEf6fuFXdzJsOcmXy86t6JMfgV45zuiUFBD7+coNh8Yn1O+SMSTe3kKjgcaw
|
||||
j7xMVDlNy6okpDv5mIFTkzhLCPZPNhGTjYBs21edEmB7PyGf9l8d+Mv+ItT9Ualy
|
||||
5MUJ4ZzOPIT+Umv97DUB8CIvRSuLq34K1puvjoPa1oz6etUkMVxOEG40+h+b3JNo
|
||||
RiwONkO/0Mvew8uUywnwrdS08I8Y2NeFwQsYf90371xKqSqtwLKS9CLI225WSOXQ
|
||||
CTeEItkmJRa+RQApLZIvhJZQ0Hreb89W0wjsz4VStsS0laLekfh1UCZnvVZRNxCn
|
||||
FM0aTTLL5TJAFMiEYDzfrADgbgm56DCMW80ByBpHiqwmysXnAX20IhDcnP6A4iDJ
|
||||
ukW0qlojrAEh79Y7nCHC8y4CCsEwwUxuxXfSRrFVQeS6K2crGoalnJfXFMhHr8SW
|
||||
YBD9J4x6tVhKB++rmy3LkX7lHzVLlZbfd8lJouNxTlj9BWwzm3k96v9V6KO/vDLn
|
||||
Iqh2qENSNAWbRrFoCn1d6XPYt4L9kFRUrKClGVbgMyVpKNX0izVb2TElViRGiA2+
|
||||
Tq2gXNGuCNcz2Y5EURaJozjCb4udRazXTraZJ4YZgfwdrHzTz+neBSEsiXU7OGrY
|
||||
R+Q5ijkahA7I0BUUabPEVmxup7iOkJmagjPu8ve75c+VTqs3QHMFjEkYLazxB7a3
|
||||
N6LtM9fp9X5RtQQBM+ppGjMtQD6Jkp0/npUUCbfPNnH0ZXxiNh6GaYuTY58C9ob8
|
||||
zBBgfiBH2Olzrd/bZv695Ohxtd97eEqMosixKJLcYsvSqeJlmx8XmcNoWesVmooT
|
||||
gR4QB1x22UdcZa0ShQLSuDTDEGiU33uJF9VM1CAHWQNEHHfVMUpdCV/m5AkyPm/1
|
||||
rp4IRG2oJkpId4PHu8aGD+/4T8mnALbSzooButfj9m1u9Mo1156hi+F9Z2jN63aA
|
||||
Ehnzc0aRAJ5wbnq3RzaB7sfP0+/fh6e97gW+aNeLP5l6wYzDVqwLdIpt8o7s5rvq
|
||||
FFFqChnzTRUXAkRMEn7WqMU1LeAF9S+rMTcawuolADVd2ntNWMmA6bqrpgq2C/0w
|
||||
qk3npanYBmHg7KzJMg8jJNIPhRNV0PyMM9RYdwfDiuMW9Gw+pi3DgUOO027Vr7j1
|
||||
VNlZgdaA9uYT2qETf4/N1tyqKxsrimdUaG9dQe3w2R9SEMXw28doihc=
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
@@ -0,0 +1,30 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
Proc-Type: 4,ENCRYPTED
|
||||
DEK-Info: AES-256-CBC,A5A125F46CCED791DF3B43E3BFFC72EF
|
||||
|
||||
j/qWilDpPFE84GAHLgUM4m+yXGbgjePy/G9GB8jV3doCyDRJhjvqKfJmXmypj5IM
|
||||
tz2Ab059Gz396FK2N0OEtj4Us9TgPtvW/fTPNoLQp9mcIrRB7+s3TFoxNn9sUxuv
|
||||
fnEcYWmrrTElin7DbWn1RrilCkf3jiTINysFt2ypUwCqpDKebQ2WRXdUCjIevul4
|
||||
8hmNoS8YCbr++HkcRqnDTYPrNU1sV4Fl8cXs4BnEBlWULxlA6Y6xBsDur58X/UO3
|
||||
Up+WLZ8ptCWZMNYlOGNnS3TUzMn6sWWVJaShChvJBaqwTfNoFThzD8bBxf1PXN+7
|
||||
PkdkDcX5oHt+p0q1EtteFZ55VwWWMDdQTfG7dYAqTm+K4YCVaRh2xm4QTke5Kbol
|
||||
0B9C3/RaR9zLA4XEf2mq+5yc3PPBcKq+aWX7pGL+9kxMXz2HsiMWb9ir+FNxb2nc
|
||||
4mG5P/bmersRXhlmUl3bGXx3m5+4nnfBzO88S1MgZo4Z2L4TzJEaF7KjQENicDlA
|
||||
UVMaVvq9+xUPPtIT8o/VTc5x/drO0HayhMfkz8Yxka2DLsMnLhU9NiDO6nKREEjL
|
||||
GQvsan0/gIDweXzRFUY5AhPQHAOpjf/BnmTa80/DvW+WxA/B2EMMABbjxGcKK1aP
|
||||
FyJf0oVvNuC91RPNhCJ2jiGKlLxnWspcEG1hiFZJvQ4Z8u1TRItqIadE0Ar0z54A
|
||||
qJIr56SoU+xPyfXEt2GWwTPRu9W6l7EKo1yVKvqSdz1UGzrW3WuHI3G/UYvkqAs9
|
||||
fBQK47sYlrpLYS3qnFbYlrbR0RCHlNLVCs0V1Xb6U+qU3ExQRlo1YvkvH7dkvhqn
|
||||
Bdqv9iYnlryVP5SJcAeKZ7VqmM/LXq+bF7g/hTcHkg1yISqC1qaYDNi5D7uOVwW6
|
||||
ALiD5zFyLArnG7girpuT1hc8N+dkh1ZckOGf2nW3I27czY4Xsaz+9keXFvAy5F2K
|
||||
OGSu5iu/8p7M1bPRdiutcEVrT07FFvVZufASo4brMXptxpozd9o14uMhudneIqtb
|
||||
qgw+F4nl6LZkTmbdn2suXjG1jNxlv86bKsLuY/IoaOO70YTho2HS1zpQ7SS1Jj63
|
||||
5v4y6205m9xMJVZywbCwo7FQhxfBT3cmS2KkD9mYSZiFtYkRDLPxneNHKyegnuRy
|
||||
+rYelwbKCiFoUXg7QMOYfYDfC2UckCblcWlYgtSkL6TRnzl0aKBO8SQiph8LMvtB
|
||||
+/Om76T4OGlkk4PJsbLjt6uSEgcAwJBjG2ox0FpE75tLIO6ZBWdgn6TnBLFiIniw
|
||||
apomJZNc3bybpiuH+w9F1gOy/1c+nS6O82559Zn03ez6tX2SHqO7ROX06zLdcdVO
|
||||
Yg0PJiBaAPNgfKeSb/K4XX4L/bJi9lH6VjUBcQqDqHKSAonmZDLGrWxHyuHbBE1u
|
||||
wHXigKzIxR0nw8VUPIFDpdgnNlKe9eputx0+7rgrh6sW1rZL3EfuyX/62SLG8Y4L
|
||||
YYrIx1AcrkKct1yPUXgwUKYn1TItPhK3JSXxmCYbkEuVdZTKKtf/irqoauEIPjm2
|
||||
ufvhM0Y+PIGGOwAh7EvuDUUemeHd4Q2HnHwnROx5L1rSf2izC1GoxCtP8/UE2OTK
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -0,0 +1,27 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEAz3421PPLmMLaTYbYUXb+4dXCSdadp52/KSizIv0nT0n0mS5t
|
||||
HgNk246eh7Rqr/dXp6wj8svvoUDpdCrX6Dxm8w6sZ82TyuAbXJJsR4gUInxuwNHd
|
||||
46avGNiYyJJDCLZxe5F0KG/PUzmhY/qVvP1x+mPsOXGzGHZH90Vyo2aWES0QcZ8w
|
||||
JeJTIT/26BO63qFrgSMDxVV51/AFT6dG45KUdiNX4FCmcTMP5m0YVlcv/W+1Ye/U
|
||||
cDxvOa7womtnuznQy8TWFHBB6HL32/KYVB7SO2b5wiXG1yV+zjffyXVPwzfUR3Pm
|
||||
s9WYweGWtXB6C15W51IrEn9Xt7Z2mLmZxqhizwIDAQABAoIBAEPvDeLBA0q7mR/C
|
||||
43YgoKiuBNOKCcoEMr82DRiKi+FA1G2GrsEh2VatJihGIJwGPlOuX4Kx1H4WM5kO
|
||||
IexuQkKX7kh6HUZSA4NWiU8ONZFo7hYBYEWVoERhPfyFLp9epF8Rq1dF7DpSZw3F
|
||||
0Fq1HCkfqArSuBFtQHir6zGmd8GdSYtMajJVOW5O+5L7JAUS+JOdVd4Dnw6H5brN
|
||||
px6REvMmUgD9boor27Uzl2AdnUR7g6JgeCpbHFHSME/b0zAu5MRAhCg8HeX8SZHS
|
||||
CfzbAYvfLTmUtEKx+8uxawQNyu4C2FSUiHJCj/K895FAG1vb2FdnUcMSDEI9pGSR
|
||||
CmtJAOkCgYEA7DbCTyXbeQXAD2I1dNS1/daKCwg2LseclDRJ6Nq5kP6r+YNHddi/
|
||||
eGgEpJRiCHyVe+XV5sGvKuHq2VNIbPf14clJ4w5QS3eqBM7Q4wwOPSee4Npz5s6z
|
||||
rY7oLnB+0HvBACc8/jtD27ZHEM0ZlfL52KbVh7xCpp44fCjm+cDjFRUCgYEA4N+U
|
||||
mJIsrPFd7UAKW6elO0N36t721bvtgGrwWCBNKoBvX4Ra0HYoVV9TGdy+E7V7YopD
|
||||
DzvDDhgY0oDf6m3pQoYZEFdJfgiH9JzqNUnqBF6deqtTXGUxKIZ22m6DI1q5Jega
|
||||
+klDbxLe2Ot1DouthPpZedMnvMCoAMgR6xlzmVMCgYEA6E2iIryUbxzcmUR4VgGr
|
||||
SYxoDhlxT+g3cPlLhpk0zgfnkWcuLBORmhSSHFlhX/9F86dBCcOJRZ8Qm9Jv89qY
|
||||
EgknhQlaph11M21BuUjt5+7DLVcpjN/TjOhyJtmznc1XLZNpRI18WuOUWE2cz/84
|
||||
/3xzmx3mXn5UBqcjIJQ1v1ECgYEAxMEbhTswt8kgg7uq1HMk0WvA+tJHas9ZiSkI
|
||||
TjNFEVD7Ibnus8MOm1Qio9hOv3PlF10eO6/whN50qYp1YOr2U2x9dZzKAup3+oFp
|
||||
M2MOzO/0HibVc1xe3LR6PbjAKSjyIsapBCEiTYdXULkD3Mw98MITk9I7TJ3M4ZIs
|
||||
i2XjjV0CgYBOMKOCJFIR9kkEYrvCXrINfKGtbAAWi+mwkO76idvnGc4+T89FsTKM
|
||||
nO2SZSIirdo6BkMKpkQKlaIR6ScpZvUSr8pmjNARlSpAAEn8MhH9eWL2UMf/SCt7
|
||||
lqUaBq5bu48rtFJcwoKR13zrtRSgsQ7FU9+VjjlSV7g6zYaWAHYOVA==
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -0,0 +1,30 @@
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkrBgEEAdpHBAswHAQQRIkBIQM391D42515
|
||||
x8qX7AICQAACAQgCAQEwHQYJYIZIAWUDBAEqBBAV4ik0WbzhBWcA0q2nB8/oBIIE
|
||||
0KHh9w0Hi98G2eAdpBRCpAC2FryLKvfPcD6puChTFFwSIryfPt9dSrLJHB83c6/+
|
||||
uYfaQskgdoA1aCx97A+lMAXFemURUBnI59+2eqdkFdMPsWTkRQC0iL0XiRzuwUA7
|
||||
SbhSopssc21ZtiVvlxpDWaFupOqwu8mb79Lp17Rriirc/dy/e2ODStFzQyCvBJmh
|
||||
rSbWwFBsZc0zB4Ptp4Ekeah5DEbxKXG5Lw56DetDiLukXlVeVJg+Gw2ZXHlipj4s
|
||||
/wqbrnbX3VQCWuBJot3F3wYqhGZO7e52Ge+jiojfU1zT4NwMgIAjIv7jIxcxHINj
|
||||
c+Iuql1Vq3QSM+sU7MYDMxsfgsEEzYgZDtcBMqJTYy0Dq4MixkSgc8zYI+V2axl9
|
||||
RKkBQAOQQcgCN+hrPgV8JKmeNP8OBsIX3VmXgTBhRt6xpTgW2YtV0Xc4HbTY9YoF
|
||||
tm1NI6gtcOlBPewJWI6GD8JueqY88lmrG/Qf2g4x7cQz3TCTlwpB6DXHX/blRSVe
|
||||
kGJVu0CWTRy9yM49xnnZGOrK8UjL61Nd6on8vb9HC+wFsDFh2kJ1/8RvCWLyqPJY
|
||||
xM8PVmPOwKhigEF2QN/bt1eESm3D1OTeV05TnBDgRPcDwVm/ym80D7tS4Vwj7xgf
|
||||
gWzVQNoI1wtXMaJalmxSQ5qDdm78T/Mj3EJIDLoBtnkGJGQFkDxpZ4y5jQSHAAc6
|
||||
7kXnM7SAI4LUvME5LcwJZlHcUnZ7FHqCdJWh+FrMUpsL807Gcd7KMcsx++lQAAVR
|
||||
B/OUVykJHD4JnZtt3W5yyTA8E8nWbiDHro9n1w6n/fdSTj1AnwWncEx4o0CrSulf
|
||||
eZ/78eE2ba4cOyOnE1VuMmvqfH98xcXwk1mME59HWbxxuryUgqoJX40dVSay0tTt
|
||||
Ccfn0oi0WN+P8Qoq/7AueyeBbzN2dTEyDTj/C7dFFeerJT/gV3bfke3DL+J3E4zq
|
||||
+U5qrI0Z+FXw3cSF66pCY+uWruSllkwGXLjTYWSXJ7oXaANu56zAjhjGgdzZ3o9F
|
||||
Ub2JuvIuAZ+cYCJnSDPU1r4hUvMoJcfBu7YqkFQXztLU0kvdWR3fmOCapKFHaKXh
|
||||
y0KVUzi2tkTwj22+hYOTuHTLtleiFmtJERWeCkmn2eyMhSJsbxanlSRddVsMs5mJ
|
||||
vMpnXh1AlDbKHRqQIxOUhf01Dw1Q4VpQfMuxPcN4GQxaj2N0Kn6OmvxF42ze8OuA
|
||||
vO1orsV8VRDApFAVmhsfjet0hXo/EnWL+oJ5WadY5hfYX1cXe8BdjiDRyCCDv9ob
|
||||
lZUHadw2sbr1FkJ3TsxnX0OkOQ+07pDq03EjyScHyNyCForw8F0a867F23TBKunq
|
||||
q0XuwGG+EgkVEHvy/Ksqrfc60jbDyu3kAusmnLMQv07OHUiiqlBbtLQO31FQH7QE
|
||||
KIm6BxiZ8A2DxdsbyWO1LZ9+SHttUA3OAAKjFeOO89A7iK+8asRTjIRSt6NtDI6V
|
||||
rbbWFKrZY9ACfXWoFuCrfIjipPrc8q293rJ8mVxZf34rvEjt0WuV17VDEbozt8XZ
|
||||
4F2mnYeVF8LX0sdy+OBVfpTGpmn8Ubsxond4OQyzxI3MbT4WOey2BdznmdROEYbJ
|
||||
RmVzKCBEWVuHptyL6LrWbO3pu+Kdkh/1mK/E+UhFyrjk
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
@@ -183,3 +183,51 @@ async fn a_reused_physical_connection_restores_the_configured_database() -> IdeR
|
||||
assert_eq!(pool.execute("SELECT CONNECTION_ID()", 1, &c).await?.rows, backend);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forwards to the test server, adding `CLIENT_DEPRECATE_EOF` to the capabilities its greeting
|
||||
/// advertises, like proxies that set the flag but leave a 5.x server sending legacy EOF packets.
|
||||
async fn deprecate_eof_proxy(host: String, port: u16) -> u16 {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let local = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut client, _)) = listener.accept().await {
|
||||
let mut server = tokio::net::TcpStream::connect((host.as_str(), port)).await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let mut header = [0u8; 4];
|
||||
server.read_exact(&mut header).await.unwrap();
|
||||
let len = u32::from_le_bytes([header[0], header[1], header[2], 0]) as usize;
|
||||
let mut greeting = vec![0u8; len];
|
||||
server.read_exact(&mut greeting).await.unwrap();
|
||||
// protocol version, NUL-terminated server version, connection id, 8 bytes of
|
||||
// auth data, filler, lower capabilities, charset, status, upper capabilities.
|
||||
let version_end = 1 + greeting[1..].iter().position(|&b| b == 0).unwrap();
|
||||
let upper = version_end + 1 + 4 + 8 + 1 + 2 + 1 + 2;
|
||||
greeting[upper + 1] |= 0x01; // bit 24 of the capability flags
|
||||
client.write_all(&header).await.unwrap();
|
||||
client.write_all(&greeting).await.unwrap();
|
||||
let _ = tokio::io::copy_bidirectional(&mut client, &mut server).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
local
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn servers_that_advertise_deprecate_eof_but_send_legacy_eof_packets() -> IdeResult<()> {
|
||||
use ide_db::Pool;
|
||||
let Some((host, port, user, password)) = server() else { return Ok(()) };
|
||||
let source = DataSource {
|
||||
driver: DbDriver::Mysql,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(deprecate_eof_proxy(host, port).await),
|
||||
user,
|
||||
..Default::default()
|
||||
};
|
||||
let pool = Pool::connect(&source, Some(&password), None).await?;
|
||||
assert_eq!(pool.rows("SELECT 1 + 1", 1).await?, vec![vec![Some("2".into())]]);
|
||||
let c = CancellationToken::new();
|
||||
let r = pool.execute("SELECT 'a' UNION ALL SELECT 'b'", 10, &c).await?;
|
||||
assert_eq!(r.rows, vec![vec![Some("a".into())], vec![Some("b".into())]]);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Against a real Snowflake account, with key-pair authentication:
|
||||
//!
|
||||
//! ```sh
|
||||
//! OPENIDEA_TEST_SNOWFLAKE_ACCOUNT=myorg-myaccount OPENIDEA_TEST_SNOWFLAKE_USER=jane \
|
||||
//! OPENIDEA_TEST_SNOWFLAKE_KEY_FILE=~/.ssh/rsa_key.p8 \
|
||||
//! cargo test -p ide-db --test snowflake -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Optional: `…_KEY_PASSPHRASE`, `…_WAREHOUSE`, `…_ROLE`, `…_DATABASE`, `…_SCHEMA`.
|
||||
//! The output holds no credential.
|
||||
|
||||
use ide_core::{CancellationToken, error::ErrorKind};
|
||||
use ide_db::{DataSource, DbDriver, DbOptions, DbSnowflake, DbSnowflakeAuth, Pool};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn setting(name: &str) -> Option<String> {
|
||||
std::env::var(format!("OPENIDEA_TEST_SNOWFLAKE_{name}")).ok().filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
fn source() -> DataSource {
|
||||
DataSource {
|
||||
id: "snowflake".into(),
|
||||
name: "snowflake".into(),
|
||||
driver: DbDriver::Snowflake,
|
||||
host: setting("ACCOUNT").expect("OPENIDEA_TEST_SNOWFLAKE_ACCOUNT"),
|
||||
user: setting("USER").expect("OPENIDEA_TEST_SNOWFLAKE_USER"),
|
||||
database: setting("DATABASE").unwrap_or_default(),
|
||||
options: DbOptions { default_schema: setting("SCHEMA").unwrap_or_default(), ..Default::default() },
|
||||
snowflake: Some(DbSnowflake {
|
||||
warehouse: setting("WAREHOUSE").unwrap_or_default(),
|
||||
role: setting("ROLE").unwrap_or_default(),
|
||||
auth: DbSnowflakeAuth::KeyPair,
|
||||
private_key_file: setting("KEY_FILE").expect("OPENIDEA_TEST_SNOWFLAKE_KEY_FILE"),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires OPENIDEA_TEST_SNOWFLAKE_ACCOUNT, _USER and _KEY_FILE"]
|
||||
async fn key_pair_session_against_a_real_account() {
|
||||
let passphrase = setting("KEY_PASSPHRASE");
|
||||
let started = Instant::now();
|
||||
let pool = match Pool::connect(&source(), passphrase.as_deref(), None).await {
|
||||
Ok(pool) => pool,
|
||||
Err(error) => panic!("login failed ({:?}): {}", error.kind, error.message),
|
||||
};
|
||||
println!("[1] logged in with the key pair in {:?}", started.elapsed());
|
||||
let cancel = CancellationToken::new();
|
||||
let cell = |rows: &[Vec<Option<String>>], i: usize| rows[0][i].clone().unwrap_or_else(|| "<null>".into());
|
||||
|
||||
let version = pool.execute("SELECT 'Snowflake ' || CURRENT_VERSION()", 1, &cancel).await.unwrap();
|
||||
println!("[2] {}", cell(&version.rows, 0));
|
||||
|
||||
let context = pool
|
||||
.execute("SELECT CURRENT_WAREHOUSE(), CURRENT_DATABASE(), CURRENT_SCHEMA(), CURRENT_ROLE()", 1, &cancel)
|
||||
.await
|
||||
.unwrap();
|
||||
println!(
|
||||
"[3] context: warehouse={} database={} schema={} role={}",
|
||||
cell(&context.rows, 0),
|
||||
cell(&context.rows, 1),
|
||||
cell(&context.rows, 2),
|
||||
cell(&context.rows, 3)
|
||||
);
|
||||
for (wanted, i) in
|
||||
[(setting("WAREHOUSE"), 0), (setting("DATABASE"), 1), (setting("SCHEMA"), 2), (setting("ROLE"), 3)]
|
||||
{
|
||||
if let Some(wanted) = wanted {
|
||||
assert!(cell(&context.rows, i).eq_ignore_ascii_case(&wanted), "context {i} is not {wanted}");
|
||||
}
|
||||
}
|
||||
|
||||
let types = pool
|
||||
.execute(
|
||||
"SELECT 12345.67::NUMBER(10,2) AS n, 1.5::FLOAT AS f, 'text' AS t, TRUE AS b, \
|
||||
'2023-11-14'::DATE AS d, '01:02:03.5'::TIME(3) AS tm, \
|
||||
'2023-11-14 22:13:20.123'::TIMESTAMP_NTZ(3) AS ntz, \
|
||||
'2023-11-14 22:13:20 +02:00'::TIMESTAMP_TZ(0) AS tz, \
|
||||
'2023-11-14 22:13:20'::TIMESTAMP_LTZ(0) AS ltz, \
|
||||
PARSE_JSON('{\"a\":[1,2]}') AS v, TO_BINARY('DEADBEEF', 'HEX') AS bin, NULL AS nothing",
|
||||
1,
|
||||
&cancel,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for (column, value) in types.columns.iter().zip(&types.rows[0]) {
|
||||
println!("[4] {:8} {:18} {}", column.name, column.type_name, value.as_deref().unwrap_or("<null>"));
|
||||
}
|
||||
let shown: Vec<Option<&str>> = types.rows[0].iter().map(|v| v.as_deref()).collect();
|
||||
assert_eq!(shown[0], Some("12345.67"));
|
||||
assert_eq!(shown[2], Some("text"));
|
||||
assert_eq!(shown[3], Some("true"));
|
||||
assert_eq!(shown[4], Some("2023-11-14"));
|
||||
assert_eq!(shown[5], Some("01:02:03.500"));
|
||||
assert_eq!(shown[6], Some("2023-11-14 22:13:20.123"));
|
||||
assert_eq!(shown[7], Some("2023-11-14 22:13:20 +02:00"));
|
||||
assert_eq!(shown[10], Some("0xDEADBEEF"));
|
||||
assert_eq!(shown[11], None);
|
||||
assert_eq!(types.columns[0].type_name, "NUMBER(10,2)");
|
||||
|
||||
pool.execute("SET openidea_probe = 41", 0, &cancel).await.unwrap();
|
||||
let kept = pool.execute("SELECT $openidea_probe + 1", 1, &cancel).await.unwrap();
|
||||
assert_eq!(cell(&kept.rows, 0), "42");
|
||||
println!("[5] session state is kept between statements");
|
||||
|
||||
let databases = pool.execute("SHOW TERSE DATABASES", 1000, &cancel).await.unwrap();
|
||||
println!(
|
||||
"[6] SHOW TERSE DATABASES: {} databases, columns {:?}",
|
||||
databases.rows.len(),
|
||||
databases.columns.iter().map(|c| c.name.as_str()).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let missing = pool.execute("SELECT * FROM openidea_no_such_table_x", 1, &cancel).await.unwrap_err();
|
||||
println!("[7] error for a missing table ({:?}): {}", missing.kind, missing.message.replace('\n', " | "));
|
||||
assert_eq!(missing.kind, ErrorKind::InvalidInput);
|
||||
|
||||
let limited = pool.execute("SELECT SEQ4() FROM TABLE(GENERATOR(ROWCOUNT => 100))", 2, &cancel).await;
|
||||
match limited {
|
||||
Ok(limited) => {
|
||||
assert_eq!(limited.rows.len(), 2);
|
||||
assert!(limited.has_more);
|
||||
println!("[8] a row limit keeps 2 of 100 rows and reports more");
|
||||
let page = pool
|
||||
.execute_from(
|
||||
"SELECT SEQ4() AS n, REPEAT('x', 200) FROM TABLE(GENERATOR(ROWCOUNT => 300000)) ORDER BY n",
|
||||
250_000,
|
||||
3,
|
||||
&cancel,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
page.rows.iter().map(|r| r[0].clone().unwrap()).collect::<Vec<_>>(),
|
||||
["250000", "250001", "250002"]
|
||||
);
|
||||
println!(
|
||||
"[9] a page deep in a large result: {:?} total, {:?} to first answer",
|
||||
page.elapsed_ms, page.execution_ms
|
||||
);
|
||||
|
||||
let stop = CancellationToken::new();
|
||||
let waited = Instant::now();
|
||||
let statement = pool.execute("CALL SYSTEM$WAIT(120)", 1, &stop);
|
||||
let cancelling = async {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
stop.cancel();
|
||||
};
|
||||
let (outcome, ()) = tokio::join!(statement, cancelling);
|
||||
let error = outcome.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::Cancelled, "{}", error.message);
|
||||
assert!(waited.elapsed() < Duration::from_secs(30));
|
||||
println!("[10] cancelled a 120 s statement after {:?}: {}", waited.elapsed(), error.message);
|
||||
let after = pool.execute("SELECT 1", 1, &cancel).await.unwrap();
|
||||
assert_eq!(cell(&after.rows, 0), "1");
|
||||
println!("[11] the session works after the cancellation");
|
||||
}
|
||||
Err(error) => {
|
||||
println!("[8] skipped warehouse checks ({:?}): {}", error.kind, error.message.replace('\n', " | "))
|
||||
}
|
||||
}
|
||||
|
||||
pool.close().await;
|
||||
assert!(pool.is_closed());
|
||||
println!("[12] session closed");
|
||||
}
|
||||
|
||||
/// The Database tree's metadata calls. Prints counts and column shapes only, never object names.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires OPENIDEA_TEST_SNOWFLAKE_ACCOUNT, _USER and _KEY_FILE"]
|
||||
async fn explorer_metadata_against_a_real_account() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path().to_path_buf();
|
||||
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(dir.join("ssh-passwords.json")));
|
||||
let db = ide_db::DbManager::new(dir.clone(), dir.join("passwords.json"), dir.join("history.json"), ssh);
|
||||
db.save_sources(vec![source()]).await.unwrap();
|
||||
db.set_password("snowflake", setting("KEY_PASSPHRASE"), false).await.unwrap();
|
||||
|
||||
let databases = db.databases("snowflake").await.unwrap();
|
||||
println!("[m1] databases: {} (current set: {})", databases.names.len(), !databases.current.is_empty());
|
||||
assert!(!databases.names.is_empty());
|
||||
let database = setting("DATABASE")
|
||||
.map(|d| d.to_uppercase())
|
||||
.filter(|d| databases.names.contains(d))
|
||||
.unwrap_or_else(|| databases.names[0].clone());
|
||||
let target = format!("snowflake:{database}");
|
||||
|
||||
let schemas = db.schemas(&target).await.unwrap();
|
||||
println!("[m2] schemas in the chosen database: {}", schemas.len());
|
||||
assert!(!schemas.iter().any(|s| s == "INFORMATION_SCHEMA"));
|
||||
|
||||
// The first schema that has tables, so the deeper calls have something to read.
|
||||
let mut found = None;
|
||||
for schema in schemas.iter().take(25) {
|
||||
let tables = db.tables(&target, schema).await.unwrap();
|
||||
if !tables.is_empty() {
|
||||
found = Some((schema.clone(), tables));
|
||||
break;
|
||||
}
|
||||
}
|
||||
match found {
|
||||
None => println!("[m3] no tables in the first 25 schemas: table checks skipped"),
|
||||
Some((schema, tables)) => {
|
||||
let views = tables.iter().filter(|t| t.kind != ide_db::DbTableKind::Table).count();
|
||||
println!("[m3] tables and views in one schema: {} ({} views)", tables.len(), views);
|
||||
let details = db.table_details(&target, &schema, &tables[0].name).await.unwrap();
|
||||
let typed = details.columns.iter().filter(|c| !c.data_type.is_empty()).count();
|
||||
println!(
|
||||
"[m4] first table: {} columns ({} typed, {} primary key, {} nullable), {} foreign keys; first type: {}",
|
||||
details.columns.len(),
|
||||
typed,
|
||||
details.columns.iter().filter(|c| c.primary_key).count(),
|
||||
details.columns.iter().filter(|c| c.nullable).count(),
|
||||
details.foreign_keys.len(),
|
||||
details.columns.first().map_or("", |c| c.data_type.as_str())
|
||||
);
|
||||
assert!(!details.columns.is_empty());
|
||||
assert_eq!(typed, details.columns.len());
|
||||
let objects = db.schema_objects(&target, &schema).await.unwrap();
|
||||
let mut kinds = std::collections::BTreeMap::new();
|
||||
for other in &objects.others {
|
||||
*kinds.entry(other.kind.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
println!(
|
||||
"[m5] schema objects: {} routines, {} sequences, others {:?}",
|
||||
objects.routines.len(),
|
||||
objects.sequences.len(),
|
||||
kinds
|
||||
);
|
||||
let ddl = db.ddl(&target, &schema, &tables[0].name).await.unwrap();
|
||||
println!(
|
||||
"[m6] DDL of the first table: {} characters, starts with {:?}",
|
||||
ddl.len(),
|
||||
ddl.split_whitespace().next()
|
||||
);
|
||||
assert!(ddl.to_lowercase().starts_with("create"));
|
||||
let page =
|
||||
db.table_page(&target, &schema, &tables[0].name, None, &[], 0, 5, &CancellationToken::new()).await;
|
||||
match page {
|
||||
Ok(page) => println!(
|
||||
"[m7] table data page: {} rows, {} columns",
|
||||
page.result.rows.len(),
|
||||
page.result.columns.len()
|
||||
),
|
||||
Err(error) => {
|
||||
println!("[m7] table data page failed ({:?}): {}", error.kind, error.message.replace('\n', " | "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let loading = Instant::now();
|
||||
let completion = db.completion(&target).await.unwrap();
|
||||
println!("[m8] completion model loaded in {:?}", loading.elapsed());
|
||||
println!(
|
||||
"[m8] completion model: {} tables ({} with columns), {} columns, default schema set: {}",
|
||||
completion.tables.len(),
|
||||
completion.tables.iter().filter(|t| !t.columns.is_empty()).count(),
|
||||
completion.tables.iter().map(|t| t.columns.len()).sum::<usize>(),
|
||||
!completion.default_schema.is_empty()
|
||||
);
|
||||
|
||||
let session = db.session_context(&target).await.unwrap();
|
||||
println!(
|
||||
"[m9] session: warehouse set: {}, role set: {}, {} warehouses, {} roles to pick from",
|
||||
!session.warehouse.is_empty(),
|
||||
!session.role.is_empty(),
|
||||
session.warehouses.len(),
|
||||
session.roles.len()
|
||||
);
|
||||
assert!(!session.role.is_empty());
|
||||
|
||||
let plan = db.explain(&target, "SELECT 1 AS n", &CancellationToken::new(), None).await;
|
||||
match plan {
|
||||
Ok(plan) => println!(
|
||||
"[m10] explain plan: {} operations, first {:?}",
|
||||
plan.len(),
|
||||
plan.first().map(|n| n.operation.clone())
|
||||
),
|
||||
Err(error) => println!("[m10] explain failed ({:?}): {}", error.kind, error.message.replace('\n', " | ")),
|
||||
}
|
||||
// Introspection as the app runs it: every database through the data source itself.
|
||||
let listed = db.databases("snowflake").await.unwrap();
|
||||
let (mut read, mut failed, mut schemas, mut tables) = (0, 0, 0, 0);
|
||||
let mut first_failure = None;
|
||||
for database in &listed.names {
|
||||
match db.database_catalog("snowflake", database).await {
|
||||
Ok(catalog) => {
|
||||
read += 1;
|
||||
schemas += catalog.len();
|
||||
tables += catalog.iter().map(|s| s.tables.len()).sum::<usize>();
|
||||
}
|
||||
Err(error) => {
|
||||
failed += 1;
|
||||
// Names stay out of the output: quoted parts of the message are masked.
|
||||
let mut masked = String::new();
|
||||
let mut quote = None;
|
||||
for c in error.message.replace('\n', " | ").chars() {
|
||||
match quote {
|
||||
Some(q) if c == q => quote = None,
|
||||
Some(_) => continue,
|
||||
None if c == '\'' || c == '"' => quote = Some(c),
|
||||
None => {}
|
||||
}
|
||||
masked.push(c);
|
||||
}
|
||||
first_failure.get_or_insert(format!("{:?}: {masked}", error.kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"[m11] introspection: {} databases listed, {read} read, {failed} failed, {schemas} schemas, {tables} tables and views",
|
||||
listed.names.len()
|
||||
);
|
||||
if let Some(failure) = first_failure {
|
||||
println!("[m11] first failure: {failure}");
|
||||
}
|
||||
assert!(read > 0, "no database could be introspected");
|
||||
// The reference's commands that only list things and name no object, against the real server:
|
||||
// each is either answered or refused by Snowflake (an edition or privilege it needs), never
|
||||
// misread by the client. `USE` goes to what the session already has.
|
||||
let fixture: serde_json::Value =
|
||||
serde_json::from_str(include_str!("fixtures/snowflake/commands.json")).expect("the fixture parses");
|
||||
let mut statements: Vec<(String, String)> = fixture["commands"]
|
||||
.as_array()
|
||||
.expect("commands")
|
||||
.iter()
|
||||
.map(|c| {
|
||||
(c["command"].as_str().unwrap_or_default().to_owned(), c["sql"].as_str().unwrap_or_default().to_owned())
|
||||
})
|
||||
.filter(|(_, sql)| sql.starts_with("SHOW ") && !sql.to_lowercase().contains("my_") && !sql.contains("analyst"))
|
||||
.collect();
|
||||
let quoted = |name: &str| format!("\"{}\"", name.replace('"', "\"\""));
|
||||
statements.push(("USE ROLE".into(), format!("USE ROLE {}", quoted(&session.role))));
|
||||
if !session.warehouse.is_empty() {
|
||||
statements.push(("USE WAREHOUSE".into(), format!("USE WAREHOUSE {}", quoted(&session.warehouse))));
|
||||
}
|
||||
statements.push(("USE DATABASE".into(), format!("USE DATABASE {}", quoted(&database))));
|
||||
statements.push(("USE SECONDARY ROLES".into(), "USE SECONDARY ROLES ALL".into()));
|
||||
let (mut answered, mut refused, mut broken) = (0, Vec::new(), Vec::new());
|
||||
for (command, sql) in &statements {
|
||||
match db.execute(&target, sql, 1, &CancellationToken::new()).await {
|
||||
Ok(_) => answered += 1,
|
||||
Err(error) if error.kind == ErrorKind::InvalidInput => {
|
||||
// Snowflake's own refusal: the command and the error code only.
|
||||
let code = error.message.split(['[', ']']).nth(1).unwrap_or("?").to_owned();
|
||||
refused.push(format!("{command} [{code}]"));
|
||||
}
|
||||
Err(error) => broken.push(format!("{command} ({:?})", error.kind)),
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"[m12] commands: {} run, {answered} answered, {} refused by Snowflake, {} failed in the client",
|
||||
statements.len(),
|
||||
refused.len(),
|
||||
broken.len()
|
||||
);
|
||||
if !refused.is_empty() {
|
||||
println!("[m12] refused: {}", refused.join(", "));
|
||||
}
|
||||
assert!(broken.is_empty(), "the client failed on: {}", broken.join(", "));
|
||||
assert!(answered > 0);
|
||||
db.disconnect_source("snowflake").await;
|
||||
println!("[m13] disconnected");
|
||||
}
|
||||
@@ -342,3 +342,31 @@ async fn applies_driver_properties_and_the_startup_script_on_connect() -> IdeRes
|
||||
assert_eq!(db.schemas("s1").await?, vec!["main"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_sources_edited_on_disk_close_their_connections() -> IdeResult<()> {
|
||||
let (tmp, db) = setup().await;
|
||||
std::fs::File::create(tmp.path().join("other.db")).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let _watch = db.watch_sources(Box::new(move || _ = tx.send(())))?;
|
||||
db.pool("s1").await?;
|
||||
assert_eq!(db.connected(), vec!["s1"]);
|
||||
|
||||
// Our own save is no edit on disk.
|
||||
let mut sources = db.sources()?;
|
||||
sources[0].name = "renamed".into();
|
||||
db.save_sources(sources.clone()).await?;
|
||||
assert!(!db.reconcile_sources().await);
|
||||
|
||||
// Edited outside the IDE (by hand, by an agent): the watcher reports it, and the
|
||||
// connection made with the old settings closes.
|
||||
db.pool("s1").await?;
|
||||
sources[0].file = Some("other.db".into());
|
||||
ide_db::source::save(tmp.path(), &sources)?;
|
||||
rx.recv_timeout(std::time::Duration::from_secs(10)).expect("the watcher reports the edit");
|
||||
assert!(db.reconcile_sources().await);
|
||||
assert!(db.connected().is_empty());
|
||||
assert_eq!(db.sources()?[0].file.as_ref().map(|f| f.0.clone()), Some("other.db".into()));
|
||||
assert!(!db.reconcile_sources().await);
|
||||
Ok(())
|
||||
}
|
||||
@@ -136,24 +136,19 @@ pub struct DockerRegistry {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerSettings {
|
||||
#[serde(default = "default_connections")]
|
||||
#[serde(default)]
|
||||
pub connections: Vec<DockerConnection>,
|
||||
#[serde(default)]
|
||||
pub registries: Vec<DockerRegistry>,
|
||||
}
|
||||
|
||||
impl Default for DockerSettings {
|
||||
fn default() -> Self {
|
||||
Self { connections: default_connections(), registries: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Before anything is configured the CLI's own daemon is shown, as before connections existed.
|
||||
fn default_connections() -> Vec<DockerConnection> {
|
||||
vec![DockerConnection { id: "default".into(), name: "Docker".into(), endpoint: DockerEndpoint::Default }]
|
||||
/// Whether `c` is the connection older versions injected when nothing was configured:
|
||||
/// Services lists only connections the user added, so this one is dropped on load.
|
||||
fn is_injected_default(c: &DockerConnection) -> bool {
|
||||
c.id == "default" && c.name == "Docker" && c.endpoint == DockerEndpoint::Default
|
||||
}
|
||||
|
||||
const SETTINGS_FILE: &str = "docker.json";
|
||||
@@ -161,10 +156,17 @@ const PASSWORDS_FILE: &str = "docker-passwords.json";
|
||||
|
||||
pub fn load_settings(data_dir: &Path) -> DockerSettings {
|
||||
match std::fs::read(data_dir.join(SETTINGS_FILE)) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("{SETTINGS_FILE}: {e}");
|
||||
DockerSettings::default()
|
||||
}),
|
||||
// A file written before the opt-in Services rework carries the injected default
|
||||
// (saved along with a registry or another connection); drop it unless edited.
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map(|mut s: DockerSettings| {
|
||||
s.connections.retain(|c| !is_injected_default(c));
|
||||
s
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("{SETTINGS_FILE}: {e}");
|
||||
DockerSettings::default()
|
||||
}),
|
||||
Err(_) => DockerSettings::default(),
|
||||
}
|
||||
}
|
||||
@@ -373,7 +375,8 @@ mod tests {
|
||||
fn settings_round_trip_and_passwords() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
assert_eq!(load_settings(dir).connections[0].endpoint, DockerEndpoint::Default);
|
||||
// Nothing configured: no connection is invented.
|
||||
assert!(load_settings(dir).connections.is_empty());
|
||||
let settings = DockerSettings {
|
||||
connections: vec![DockerConnection {
|
||||
id: "c1".into(),
|
||||
@@ -395,8 +398,32 @@ mod tests {
|
||||
assert!(auth_for(dir, "postgres").is_none());
|
||||
save_settings(dir, &DockerSettings { registries: Vec::new(), ..settings }).unwrap();
|
||||
assert_eq!(registry_password(dir, "r1"), None);
|
||||
// An empty list stays empty (the default connection is only for a missing file).
|
||||
// An empty list stays empty.
|
||||
save_settings(dir, &DockerSettings { connections: Vec::new(), registries: Vec::new() }).unwrap();
|
||||
assert!(load_settings(dir).connections.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_drops_the_injected_default_connection() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
let injected =
|
||||
|| DockerConnection { id: "default".into(), name: "Docker".into(), endpoint: DockerEndpoint::Default };
|
||||
let registry =
|
||||
DockerRegistry { id: "r1".into(), name: "GHCR".into(), address: "ghcr.io".into(), username: "me".into() };
|
||||
// Saved untouched by an older version (e.g. along with a registry): dropped on load.
|
||||
save_settings(dir, &DockerSettings { connections: vec![injected()], registries: vec![registry.clone()] })
|
||||
.unwrap();
|
||||
let loaded = load_settings(dir);
|
||||
assert!(loaded.connections.is_empty());
|
||||
assert_eq!(loaded.registries, vec![registry]);
|
||||
// A connection the user renamed or re-pointed stays theirs.
|
||||
let renamed = DockerConnection { name: "Local engine".into(), ..injected() };
|
||||
save_settings(dir, &DockerSettings { connections: vec![renamed.clone()], registries: Vec::new() }).unwrap();
|
||||
assert_eq!(load_settings(dir).connections, vec![renamed]);
|
||||
let repointed =
|
||||
DockerConnection { endpoint: DockerEndpoint::Socket { path: "/var/run/docker.sock".into() }, ..injected() };
|
||||
save_settings(dir, &DockerSettings { connections: vec![repointed.clone()], registries: Vec::new() }).unwrap();
|
||||
assert_eq!(load_settings(dir).connections, vec![repointed]);
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,6 @@ impl Capability {
|
||||
match self {
|
||||
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
|
||||
Self::EditorWrite
|
||||
| Self::ProjectWrite
|
||||
| Self::DatabaseSources
|
||||
| Self::VcsWrite
|
||||
| Self::Net(_)
|
||||
@@ -80,7 +79,14 @@ impl Capability {
|
||||
| Self::Secrets
|
||||
| Self::InputEvents
|
||||
| Self::Clipboard => Risk::Medium,
|
||||
Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
|
||||
// A trusted project's own files name the programs the IDE starts (its settings, its
|
||||
// build scripts), so writing them is running code.
|
||||
Self::ProjectWrite
|
||||
| Self::IdeCommands
|
||||
| Self::Ipc(_)
|
||||
| Self::Process(_)
|
||||
| Self::FsRead(_)
|
||||
| Self::FsWrite(_) => Risk::High,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +408,15 @@ mod tests {
|
||||
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
|
||||
}
|
||||
|
||||
/// A trusted project's files name programs the IDE starts by itself: the git and language
|
||||
/// server executables in `.openidea/settings.toml`, its build scripts. Writing them is
|
||||
/// running code, and the install dialog says so.
|
||||
#[test]
|
||||
fn writing_project_files_is_a_high_risk_grant() {
|
||||
assert_eq!(Capability::ProjectWrite.risk(), Risk::High);
|
||||
assert!(!Capability::ProjectWrite.auto_migrates());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_patterns() {
|
||||
assert!(host_matches("crates.io", "crates.io"));
|
||||
|
||||
@@ -458,16 +458,13 @@ fn canonical_dir(p: &Path) -> PathBuf {
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
let Ok(meta) = std::fs::metadata(p) else { return false };
|
||||
if !meta.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
meta.permissions().mode() & 0o111 != 0
|
||||
meta.is_file() && meta.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
true
|
||||
meta.is_file()
|
||||
}
|
||||
|
||||
/// blake3 of a file, hex.
|
||||
|
||||
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.5.0";
|
||||
pub const ENGINE_VERSION: &str = "1.7.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
@@ -151,6 +151,8 @@ impl ActivationEvent {
|
||||
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
|
||||
/// `scheduled` comes from `on-schedule` instead.
|
||||
pub const EVENT_KINDS: &[&str] = &[
|
||||
"document-changed",
|
||||
"websocket-ready",
|
||||
"run-exited",
|
||||
"terminal-exited",
|
||||
"content-tab-action",
|
||||
@@ -366,6 +368,10 @@ impl Manifest {
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for event in &self.activation {
|
||||
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "document-changed" | "websocket-ready"))
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 6)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 4)?;
|
||||
@@ -582,6 +588,8 @@ mod tests {
|
||||
ActivationEvent::parse("on-event:files-changed"),
|
||||
Ok(ActivationEvent::Event("files-changed".into()))
|
||||
);
|
||||
assert!(ActivationEvent::parse("on-event:document-changed").is_ok());
|
||||
assert!(ActivationEvent::parse("on-event:websocket-ready").is_ok());
|
||||
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
|
||||
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
|
||||
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
|
||||
@@ -860,7 +868,7 @@ mod commands_api_tests {
|
||||
mod net_ask_api_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn net_ask_requires_api_1_5() {
|
||||
fn api_1_5_declarations_require_api_1_5() {
|
||||
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
|
||||
for engine in ["^1.4", "^1.5"] {
|
||||
let manifest = format!(
|
||||
@@ -875,4 +883,23 @@ mod net_ask_api_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realtime_events_require_api_1_6() {
|
||||
for declaration in
|
||||
["activation = [\"on-event:document-changed\"]", "activation = [\"on-event:websocket-ready\"]"]
|
||||
{
|
||||
for engine in ["^1.5", "^1.6"] {
|
||||
let manifest = format!(
|
||||
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
|
||||
);
|
||||
let parsed = Manifest::parse(&manifest);
|
||||
if engine == "^1.6" {
|
||||
parsed.unwrap();
|
||||
} else {
|
||||
assert!(parsed.unwrap_err().contains("new in plugin API 1.6"), "{engine}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,6 +352,22 @@ pub struct IndexEntry {
|
||||
/// the IDE before showing it.
|
||||
#[serde(default)]
|
||||
pub icon: Option<PluginIcon>,
|
||||
/// Where to read more: the manifest's homepage, else the registry's source repository.
|
||||
#[serde(default)]
|
||||
pub homepage: Option<String>,
|
||||
/// This version's release notes (the manifest's `change-notes`).
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
/// Downloads of the plugin's packages, as counted by whoever serves them.
|
||||
#[serde(default)]
|
||||
pub downloads: Option<u64>,
|
||||
/// Picked by the marketplace's maintainers.
|
||||
#[serde(default)]
|
||||
pub featured: bool,
|
||||
/// Project-relative paths whose presence makes the plugin worth suggesting
|
||||
/// (`pom.xml`, `config/routes.rb`).
|
||||
#[serde(default)]
|
||||
pub suggest: Vec<String>,
|
||||
}
|
||||
|
||||
impl IndexEntry {
|
||||
@@ -389,10 +405,31 @@ impl Index {
|
||||
/// revocations, publisher keys (revoked ones) and `moved` — and lists no
|
||||
/// plugins ([`Index::is_newer`]): the IDE needs updating to install from it.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let (index, dropped) = Self::parse_dropping(text)?;
|
||||
if let Some(first) = dropped.first() {
|
||||
// One line per index, and a bounded one: whoever wrote the index chose the text.
|
||||
let id: String = first.id.chars().take(80).collect();
|
||||
tracing::warn!(
|
||||
"index: {} entries are not offered, their id or version is not one a manifest may carry (first: {id:?})",
|
||||
dropped.len()
|
||||
);
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// [`Index::parse`], with the entries it does not offer: those whose id or
|
||||
/// version no manifest may carry, so no IDE could install them.
|
||||
pub fn parse_dropping(text: &str) -> Result<(Self, Vec<IndexEntry>), String> {
|
||||
let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
|
||||
let format = value.get("format").and_then(serde_json::Value::as_u64).ok_or("index: `format` is missing")?;
|
||||
if format == u64::from(INDEX_FORMAT) {
|
||||
return serde_json::from_value(value).map_err(|e| format!("index: {e}"));
|
||||
let mut index: Index = serde_json::from_value(value).map_err(|e| format!("index: {e}"))?;
|
||||
// What no manifest may carry is not offered: the id and version also name files on disk.
|
||||
let (offered, dropped) = std::mem::take(&mut index.plugins)
|
||||
.into_iter()
|
||||
.partition(|e| crate::validate_plugin_id(&e.id).is_ok() && e.semver().is_some());
|
||||
index.plugins = offered;
|
||||
return Ok((index, dropped));
|
||||
}
|
||||
if format < u64::from(INDEX_FORMAT) {
|
||||
return Err(format!("index format {format} is not supported (this IDE reads {INDEX_FORMAT})"));
|
||||
@@ -401,13 +438,14 @@ impl Index {
|
||||
let items = value.get(field).and_then(serde_json::Value::as_array);
|
||||
items.into_iter().flatten().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()
|
||||
}
|
||||
Ok(Index {
|
||||
let index = Index {
|
||||
format: u32::try_from(format).unwrap_or(u32::MAX),
|
||||
publishers: each(&value, "publishers"),
|
||||
plugins: Vec::new(),
|
||||
revoked: each(&value, "revoked"),
|
||||
moved: value.get("moved").and_then(serde_json::Value::as_str).map(str::to_owned),
|
||||
})
|
||||
};
|
||||
Ok((index, Vec::new()))
|
||||
}
|
||||
|
||||
/// Written in a format newer than this IDE reads: it lists no plugins.
|
||||
@@ -685,9 +723,33 @@ mod tests {
|
||||
engine: "^1.0".into(),
|
||||
categories: Vec::new(),
|
||||
icon: None,
|
||||
homepage: None,
|
||||
notes: None,
|
||||
downloads: None,
|
||||
featured: false,
|
||||
suggest: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_details_are_optional() {
|
||||
let mut json = serde_json::to_value(entry("acme.a", "acme")).unwrap();
|
||||
for key in ["homepage", "notes", "downloads", "featured", "suggest"] {
|
||||
json.as_object_mut().unwrap().remove(key);
|
||||
}
|
||||
assert_eq!(serde_json::from_value::<IndexEntry>(json).unwrap(), entry("acme.a", "acme"));
|
||||
let full = IndexEntry {
|
||||
homepage: Some("https://example.com".into()),
|
||||
notes: Some("Fixes".into()),
|
||||
downloads: Some(42),
|
||||
featured: true,
|
||||
suggest: vec!["pom.xml".into()],
|
||||
..entry("acme.a", "acme")
|
||||
};
|
||||
let back: IndexEntry = serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap();
|
||||
assert_eq!(back, full);
|
||||
}
|
||||
|
||||
fn version(id: &str, version: &str, engine: &str) -> IndexEntry {
|
||||
IndexEntry { version: version.into(), engine: engine.into(), ..entry(id, "acme") }
|
||||
}
|
||||
@@ -868,6 +930,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_offers_only_the_ids_and_versions_a_manifest_may_carry() {
|
||||
let invalid_ids = [
|
||||
"../x",
|
||||
"../../escape",
|
||||
"acme/../x",
|
||||
r"acme.\..\x",
|
||||
"acme./../../../escape",
|
||||
"/abs",
|
||||
r"C:\x",
|
||||
"ACME.x",
|
||||
"acme.",
|
||||
"acme..x",
|
||||
"acme.x y",
|
||||
"",
|
||||
];
|
||||
let invalid_versions =
|
||||
["../1.0.0", "1.0.0/../../../escape", r"1.0.0\..\x", "1.0.0-..", " 1.0.0", "1.0", "latest", ""];
|
||||
let valid = [("acme.x", "1.0.0"), ("acme.tools-2_b", "1.0.0-rc.1+b.2")];
|
||||
let mut listed: Vec<(&str, &str)> = valid.to_vec();
|
||||
listed.extend(invalid_ids.iter().map(|id| (*id, "1.0.0")));
|
||||
listed.extend(invalid_versions.iter().map(|version| ("acme.x", *version)));
|
||||
let plugins: Vec<serde_json::Value> = listed
|
||||
.iter()
|
||||
.map(|(id, version)| {
|
||||
serde_json::json!({
|
||||
"id": id, "name": "X", "version": version, "publisher": "acme",
|
||||
"download": "x.oiplugin", "digest": "d", "engine": "^1.0",
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let text = serde_json::json!({ "format": INDEX_FORMAT, "plugins": plugins }).to_string();
|
||||
let pairs = |entries: &[IndexEntry]| -> Vec<(String, String)> {
|
||||
entries.iter().map(|e| (e.id.clone(), e.version.clone())).collect()
|
||||
};
|
||||
let owned = |pairs: &[(&str, &str)]| -> Vec<(String, String)> {
|
||||
pairs.iter().map(|(id, version)| ((*id).to_owned(), (*version).to_owned())).collect()
|
||||
};
|
||||
|
||||
assert_eq!(pairs(&Index::parse(&text).unwrap().plugins), owned(&valid));
|
||||
let (index, dropped) = Index::parse_dropping(&text).unwrap();
|
||||
assert_eq!(pairs(&index.plugins), owned(&valid));
|
||||
assert_eq!(pairs(&dropped), owned(&listed[valid.len()..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_index_still_revokes_and_moves() {
|
||||
let newer = format!(
|
||||
|
||||
@@ -102,6 +102,14 @@ fn parse<T>(r: serde_json::Result<T>) -> IdeResult<T> {
|
||||
r.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Protocol, format!("unexpected gh output: {e}")))
|
||||
}
|
||||
|
||||
/// The repository of `root`, free to execute: the host runs the `gh_*` commands only in a
|
||||
/// trusted project.
|
||||
fn trusted_repo(root: &Path) -> IdeResult<Repo> {
|
||||
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
|
||||
repo.set_execution_allowed(true);
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// Whether gh is installed and signed in, and which GitHub repository `root` maps to.
|
||||
pub fn availability(root: &Path) -> GhAvailability {
|
||||
if gh_executable().is_none() {
|
||||
@@ -233,7 +241,7 @@ pub fn create(
|
||||
return Err(IdeError::invalid("choose a base branch"));
|
||||
}
|
||||
if req.push {
|
||||
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
|
||||
let repo = trusted_repo(root)?;
|
||||
repo.push(false, true, cancel, on_output)?;
|
||||
}
|
||||
let mut args = vec!["pr", "create", "--title", req.title.trim(), "--body", &req.body, "--base", req.base.trim()];
|
||||
@@ -354,7 +362,7 @@ pub fn prepare_diff(
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<PrDiffRevisions> {
|
||||
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
|
||||
let repo = trusted_repo(root)?;
|
||||
if !repo.has_commit(head_oid) || !repo.has_commit(base_oid) {
|
||||
let remotes = repo.remotes()?;
|
||||
let remote = remote_for(&remotes, name_with_owner)
|
||||
|
||||
@@ -19,6 +19,9 @@ const CHUNK_SLOT_BITS: u32 = 17;
|
||||
const WORD_BITS: u64 = 64;
|
||||
const WORDS_PER_CHUNK: usize = (1 << CHUNK_SLOT_BITS) / WORD_BITS as usize;
|
||||
|
||||
// The bit table is matched in hot paths; boxing the big variant would only add
|
||||
// an indirection to save stack space `IdMap` never holds by value in bulk.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum Repr {
|
||||
Bits {
|
||||
/// Chunk number of the first table entry.
|
||||
|
||||
@@ -14,6 +14,7 @@ encoding_rs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-mcp.workspace = true
|
||||
futures.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
regex-syntax.workspace = true
|
||||
@@ -45,6 +46,7 @@ globset.workspace = true
|
||||
ide-db.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-remote.workspace = true
|
||||
ide-web.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-agent.workspace = true
|
||||
ide-notebook.workspace = true
|
||||
|
||||
Loaded 100 of 1383 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача